style: auto-format codebase with Prettier

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

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

View file

@ -65,6 +65,7 @@ mana-core-auth/
### 2. Database Schema ✅
**Auth Schema:**
- `auth.users` - User accounts with soft delete support
- `auth.sessions` - Active sessions with device tracking
- `auth.passwords` - Separate password storage (bcrypt hashed)
@ -74,6 +75,7 @@ mana-core-auth/
- `auth.security_events` - Security audit log
**Credits Schema:**
- `credits.balances` - User credit balances with optimistic locking
- `credits.transactions` - Double-entry transaction ledger
- `credits.packages` - Credit pricing packages
@ -81,6 +83,7 @@ mana-core-auth/
- `credits.usage_stats` - Usage analytics per app
**Key Features:**
- Row-Level Security (RLS) policies on all tables
- Optimistic locking for balance updates (prevents race conditions)
- Idempotency keys for transactions
@ -89,6 +92,7 @@ mana-core-auth/
### 3. Authentication System ✅
**Endpoints Implemented:**
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/login` - Login with credentials
- `POST /api/v1/auth/refresh` - Refresh access token
@ -96,6 +100,7 @@ mana-core-auth/
- `POST /api/v1/auth/validate` - Validate JWT token
**Security Features:**
- RS256 JWT algorithm (asymmetric keys)
- Access tokens: 15 minutes expiry
- Refresh tokens: 7 days expiry with rotation
@ -107,6 +112,7 @@ mana-core-auth/
### 4. Credit System ✅
**Endpoints Implemented:**
- `GET /api/v1/credits/balance` - Get current balance
- `POST /api/v1/credits/use` - Deduct credits
- `GET /api/v1/credits/transactions` - Transaction history
@ -114,6 +120,7 @@ mana-core-auth/
- `GET /api/v1/credits/packages` - Available packages
**Features:**
- Signup bonus: 150 free credits
- Daily free credits: 5 credits every 24 hours
- Automatic daily reset with transaction logging
@ -123,12 +130,14 @@ mana-core-auth/
- Complete audit trail via double-entry ledger
**Credit Pricing:**
- 100 mana = €1.00 (configurable)
- Stored as integer (euro cents) for precision
### 5. Docker Infrastructure ✅
**Services Configured:**
- **Traefik** - Reverse proxy with automatic SSL (Let's Encrypt)
- **PostgreSQL 16** - Database with SCRAM-SHA-256 auth
- **PgBouncer** - Connection pooling (transaction mode)
@ -138,6 +147,7 @@ mana-core-auth/
- **Grafana** - Monitoring dashboards
**Docker Features:**
- Multi-stage Dockerfile (optimized build)
- Health checks for all services
- Volume persistence for data
@ -148,6 +158,7 @@ mana-core-auth/
### 6. Configuration & Environment ✅
**Environment Variables:**
- Database connection (PostgreSQL)
- Redis configuration
- JWT keys (RS256 public/private)
@ -157,6 +168,7 @@ mana-core-auth/
- Rate limiting configuration
**Configuration Files:**
- `.env.example` - Template with all variables
- `configuration.ts` - Type-safe config loading
- `docker-compose.yml` - Full stack orchestration
@ -164,6 +176,7 @@ mana-core-auth/
### 7. Security Features ✅
**Application Level:**
- Helmet.js security headers
- CORS protection
- Rate limiting (100 req/min per IP)
@ -172,6 +185,7 @@ mana-core-auth/
- Refresh token rotation
**Database Level:**
- Row-Level Security (RLS) policies
- Helper functions: `auth.uid()`, `auth.role()`
- Separate password table
@ -179,6 +193,7 @@ mana-core-auth/
- Security events logging
**Infrastructure Level:**
- Traefik rate limiting
- PostgreSQL SCRAM-SHA-256
- Redis password protection
@ -188,11 +203,13 @@ mana-core-auth/
### 8. Additional Features ✅
**Scripts:**
- `generate-keys.sh` - Generate RS256 key pair
- Migration management via Drizzle Kit
- Docker health checks
**Documentation:**
- README.md - Complete setup guide
- API endpoint documentation
- Architecture overview
@ -286,6 +303,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
### 5. Future Implementation Tasks
**Phase 1: Stripe Integration**
- [ ] Implement Stripe payment intent creation
- [ ] Add webhook handler for payment events
- [ ] Create credit packages in database
@ -293,12 +311,14 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
- [ ] Test payment flow end-to-end
**Phase 2: OAuth Providers**
- [ ] Configure OAuth providers (Google, GitHub, Apple)
- [ ] Add OAuth login endpoints
- [ ] Handle account linking
- [ ] Test social login flow
**Phase 3: Advanced Features**
- [ ] Implement 2FA setup and verification
- [ ] Add email verification system
- [ ] Create password reset flow
@ -306,6 +326,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
- [ ] Admin dashboard
**Phase 4: Shared Package**
- [ ] Create `@manacore/shared-auth` package
- [ ] Platform-agnostic auth service
- [ ] Auto-refresh logic
@ -313,6 +334,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
- [ ] App-token generation
**Phase 5: Production Deployment**
- [ ] Set up VPS (Hetzner CPX31)
- [ ] Configure DNS records
- [ ] Deploy with docker-compose
@ -324,38 +346,38 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
### Authentication
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/v1/auth/register` | POST | None | Register new user |
| `/api/v1/auth/login` | POST | None | Login with credentials |
| `/api/v1/auth/refresh` | POST | None | Refresh access token |
| `/api/v1/auth/logout` | POST | Bearer | Logout and revoke session |
| `/api/v1/auth/validate` | POST | None | Validate JWT token |
| Endpoint | Method | Auth | Description |
| ----------------------- | ------ | ------ | ------------------------- |
| `/api/v1/auth/register` | POST | None | Register new user |
| `/api/v1/auth/login` | POST | None | Login with credentials |
| `/api/v1/auth/refresh` | POST | None | Refresh access token |
| `/api/v1/auth/logout` | POST | Bearer | Logout and revoke session |
| `/api/v1/auth/validate` | POST | None | Validate JWT token |
### Credits
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/v1/credits/balance` | GET | Bearer | Get current balance |
| `/api/v1/credits/use` | POST | Bearer | Deduct credits |
| `/api/v1/credits/transactions` | GET | Bearer | Transaction history |
| `/api/v1/credits/purchases` | GET | Bearer | Purchase history |
| `/api/v1/credits/packages` | GET | Bearer | Available packages |
| Endpoint | Method | Auth | Description |
| ------------------------------ | ------ | ------ | ------------------- |
| `/api/v1/credits/balance` | GET | Bearer | Get current balance |
| `/api/v1/credits/use` | POST | Bearer | Deduct credits |
| `/api/v1/credits/transactions` | GET | Bearer | Transaction history |
| `/api/v1/credits/purchases` | GET | Bearer | Purchase history |
| `/api/v1/credits/packages` | GET | Bearer | Available packages |
## Technical Stack Summary
| Component | Technology | Version |
|-----------|-----------|---------|
| Framework | NestJS | 10.4.x |
| Runtime | Node.js | 20+ |
| Package Manager | pnpm | 9.15.0 |
| Database | PostgreSQL | 16 |
| ORM | Drizzle | 0.38.x |
| Cache | Redis | 7 |
| Payment | Stripe | 17.x |
| Reverse Proxy | Traefik | 3.0 |
| Connection Pool | PgBouncer | Latest |
| Monitoring | Prometheus + Grafana | Latest |
| Component | Technology | Version |
| --------------- | -------------------- | ------- |
| Framework | NestJS | 10.4.x |
| Runtime | Node.js | 20+ |
| Package Manager | pnpm | 9.15.0 |
| Database | PostgreSQL | 16 |
| ORM | Drizzle | 0.38.x |
| Cache | Redis | 7 |
| Payment | Stripe | 17.x |
| Reverse Proxy | Traefik | 3.0 |
| Connection Pool | PgBouncer | Latest |
| Monitoring | Prometheus + Grafana | Latest |
## File Locations
@ -370,6 +392,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
## Success Metrics
✅ **Core Implementation Complete**
- 12 database tables with RLS policies
- 10 API endpoints (5 auth + 5 credits)
- Docker deployment infrastructure
@ -380,6 +403,7 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
## Estimated Time to Production
Based on remaining tasks:
- JWT key generation: 5 minutes
- Environment configuration: 15 minutes
- Local testing: 30 minutes
@ -392,6 +416,7 @@ Based on remaining tasks:
## Support
For questions or issues:
1. Check README.md in the package
2. Review master plan in .hive-mind/
3. Contact the development team

View file

@ -15,6 +15,7 @@ The Mana Core Auth system is a **central authentication service** that serves th
- `chat/`
This matches the monorepo structure where:
- **Root-level projects** = Complete applications/services
- **`packages/` directory** = Shared libraries and utilities (e.g., `@manacore/shared-auth`, `@manacore/shared-types`)
@ -39,29 +40,36 @@ manacore-monorepo/
## Files Updated
### 1. docker-compose.yml
- Changed postgres init volume: `./mana-core-auth/postgres/init`
- Changed Dockerfile path: `./mana-core-auth/Dockerfile`
### 2. mana-core-auth/Dockerfile
- Updated all `packages/mana-core-auth/` references to `mana-core-auth/`
### 3. mana-core-auth/package.json
- Changed name from `@manacore/auth` to `mana-core-auth`
- Reflects that it's a standalone service, not a shared package
### 4. Documentation Files
- All `.md` files updated to reference correct path
- `QUICKSTART.md`, `README.md`, `IMPLEMENTATION_SUMMARY.md` all updated
## Impact
### No Breaking Changes ✅
- The service is standalone and doesn't affect other projects
- Docker configuration updated to match new location
- All internal references corrected
### Workspace Configuration
The service is still part of the pnpm workspace (via `pnpm-workspace.yaml`), so you can still run:
```bash
pnpm install
pnpm --filter mana-core-auth start:dev
@ -93,11 +101,13 @@ pnpm start:dev
## Integration with Other Projects
When you create the `@manacore/shared-auth` package for mobile/web apps, it will:
- Live in `packages/shared-auth/` (shared library)
- Connect to the `mana-core-auth` service (central service)
- Be imported as `import { AuthService } from '@manacore/shared-auth'`
Clear separation:
- **`mana-core-auth/`** = The backend service (NestJS, PostgreSQL)
- **`packages/shared-auth/`** = Client library for apps (React Native, SvelteKit)

View file

@ -21,6 +21,7 @@ When you run `docker-compose up`, migrations are **automatically applied** befor
For local development, you have two options:
#### Option 1: Automatic Schema Sync (Recommended)
```bash
# Sync schema to database (creates/updates tables)
pnpm db:push
@ -29,6 +30,7 @@ pnpm db:push
This is the **fastest** way during development. It pushes your schema changes directly to the database without generating migration files.
#### Option 2: Generated Migrations (Production-style)
```bash
# 1. Generate migration files from schema changes
pnpm migration:generate
@ -41,17 +43,19 @@ Use this approach when you want explicit migration files for version control.
## Commands Reference
| Command | Description |
|---------|-------------|
| `pnpm db:push` | Sync schema to database (no migration files) |
| `pnpm db:studio` | Open Drizzle Studio to view/edit data |
| `pnpm migration:generate` | Generate migration files from schema |
| `pnpm migration:run` | Apply pending migrations |
| Command | Description |
| ------------------------- | -------------------------------------------- |
| `pnpm db:push` | Sync schema to database (no migration files) |
| `pnpm db:studio` | Open Drizzle Studio to view/edit data |
| `pnpm migration:generate` | Generate migration files from schema |
| `pnpm migration:run` | Apply pending migrations |
## How It Works
### Schema Location
All database tables are defined in TypeScript:
```
src/db/schema/
├── auth.schema.ts # Users, sessions, passwords, etc.
@ -92,11 +96,13 @@ exec node dist/main.js
When starting fresh:
1. **Start PostgreSQL**:
```bash
docker compose up postgres -d
```
2. **Apply Schema**:
```bash
pnpm db:push
```
@ -116,6 +122,7 @@ docker compose up -d mana-core-auth
```
The service will:
1. Wait for PostgreSQL to be healthy (`depends_on`)
2. Run migrations via entrypoint script
3. Start the NestJS application
@ -123,17 +130,21 @@ The service will:
## Troubleshooting
### "relation does not exist"
**Problem**: Schema not synced to database
**Solution**:
```bash
pnpm db:push
```
### "schema already exists"
**Problem**: Partial migration state
**Solution**:
```bash
# Option 1: Force push
pnpm db:push --force
@ -145,26 +156,31 @@ pnpm db:push
```
### Migration fails in Docker
**Problem**: Database credentials or connection
**Solution**:
Check `docker-compose.yml` environment variables:
- `DATABASE_URL`
- `POSTGRES_PASSWORD`
## Best Practices
### Development
- ✅ Use `pnpm db:push` for fast iteration
- ✅ Use Drizzle Studio to inspect data: `pnpm db:studio`
- ❌ Don't commit generated migration files during active development
### Production
- ✅ Let Docker handle migrations automatically
- ✅ Monitor container logs for migration success
- ✅ Ensure DATABASE_URL is correct in environment
### Schema Changes
- ✅ Make schema changes in `src/db/schema/*.ts`
- ✅ Test locally with `pnpm db:push`
- ✅ Commit schema changes to git
@ -174,9 +190,9 @@ Check `docker-compose.yml` environment variables:
This project uses **"push-based migrations"** rather than explicit migration files:
| Approach | When to Use |
|----------|-------------|
| **Push (`db:push`)** | Development, Docker, quick iteration |
| Approach | When to Use |
| ------------------------ | --------------------------------------------- |
| **Push (`db:push`)** | Development, Docker, quick iteration |
| **Generated Migrations** | When you need explicit SQL files, audit trail |
The push-based approach is **simpler** and **faster** for most use cases, which is why it's used in the Docker entrypoint.
@ -190,6 +206,7 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname
```
In Docker Compose, this is auto-configured:
```yaml
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pgbouncer:6432/${POSTGRES_DB}
```
@ -197,16 +214,19 @@ DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@pgbouncer:6432/
## Health Checks
The service won't start until:
1. ✅ PostgreSQL is healthy
2. ✅ Migrations complete successfully
3. ✅ Application boots without errors
Check container logs:
```bash
docker logs manacore-auth
```
Look for:
```
🔄 Running database migrations...
✅ Migrations complete

View file

@ -32,6 +32,7 @@ cp .env.example .env
```
**Minimum required changes in .env:**
```env
POSTGRES_PASSWORD=your-secure-password-here
REDIS_PASSWORD=your-redis-password-here
@ -57,6 +58,7 @@ pnpm migration:run
```
Expected output:
```
Running migrations...
Migrations completed successfully
@ -69,6 +71,7 @@ pnpm start:dev
```
You should see:
```
🚀 Mana Core Auth running on: http://localhost:3001
📚 Environment: development
@ -89,12 +92,13 @@ curl -X POST http://localhost:3001/api/v1/auth/register \
```
Expected response:
```json
{
"id": "uuid-here",
"email": "test@example.com",
"name": "Test User",
"createdAt": "2025-11-25T..."
"id": "uuid-here",
"email": "test@example.com",
"name": "Test User",
"createdAt": "2025-11-25T..."
}
```
@ -110,18 +114,19 @@ curl -X POST http://localhost:3001/api/v1/auth/login \
```
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"
"user": {
"id": "uuid-here",
"email": "test@example.com",
"name": "Test User",
"role": "user"
},
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
"refreshToken": "long-random-string",
"expiresIn": 900,
"tokenType": "Bearer"
}
```
@ -134,13 +139,14 @@ curl -X GET http://localhost:3001/api/v1/credits/balance \
```
Expected response:
```json
{
"balance": 0,
"freeCreditsRemaining": 150,
"totalEarned": 0,
"totalSpent": 0,
"dailyFreeCredits": 5
"balance": 0,
"freeCreditsRemaining": 150,
"totalEarned": 0,
"totalSpent": 0,
"dailyFreeCredits": 5
}
```
@ -159,25 +165,26 @@ curl -X POST http://localhost:3001/api/v1/credits/use \
```
Expected response:
```json
{
"success": true,
"transaction": {
"id": "uuid-here",
"userId": "uuid-here",
"type": "usage",
"status": "completed",
"amount": -10,
"balanceBefore": 150,
"balanceAfter": 140,
"appId": "test",
"description": "Test credit usage"
},
"newBalance": {
"balance": 0,
"freeCreditsRemaining": 140,
"totalSpent": 10
}
"success": true,
"transaction": {
"id": "uuid-here",
"userId": "uuid-here",
"type": "usage",
"status": "completed",
"amount": -10,
"balanceBefore": 150,
"balanceAfter": 140,
"appId": "test",
"description": "Test credit usage"
},
"newBalance": {
"balance": 0,
"freeCreditsRemaining": 140,
"totalSpent": 10
}
}
```
@ -211,6 +218,7 @@ The authentication system is now running and ready to use.
**Problem:** Database not ready yet
**Solution:**
```bash
docker-compose ps # Check if postgres is healthy
docker-compose logs postgres # Check logs
@ -221,6 +229,7 @@ docker-compose logs postgres # Check logs
**Problem:** JWT keys not set in .env
**Solution:**
```bash
# Run the key generator again
./scripts/generate-keys.sh
@ -234,6 +243,7 @@ docker-compose logs postgres # Check logs
**Problem:** Database schema issues
**Solution:**
```bash
# Drop and recreate database
docker-compose down -v
@ -247,6 +257,7 @@ pnpm migration:run
**Problem:** Another service is using the port
**Solution:**
```bash
# Change PORT in .env
echo "PORT=3002" >> .env
@ -315,11 +326,13 @@ pnpm db:studio
## Environment Variables Reference
### Required
- `DATABASE_URL` - PostgreSQL connection string
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
### Optional (have defaults)
- `PORT` - Server port (default: 3001)
- `NODE_ENV` - Environment (default: development)
- `REDIS_HOST` - Redis host (default: localhost)
@ -328,6 +341,7 @@ pnpm db:studio
- `CREDITS_DAILY_FREE` - Daily free credits (default: 5)
### For Production
- `STRIPE_SECRET_KEY` - Stripe secret key
- `STRIPE_WEBHOOK_SECRET` - Stripe webhook signing secret
- `ACME_EMAIL` - Email for Let's Encrypt SSL
@ -343,6 +357,7 @@ pnpm db:studio
## Support
If you encounter issues:
1. Check this guide first
2. Review the logs
3. Consult the master plan

View file

@ -37,34 +37,40 @@ Central authentication and credit management system for the Mana Universe ecosys
### 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
```
@ -74,18 +80,21 @@ Central authentication and credit management system for the Mana Universe ecosys
### 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
```
@ -101,26 +110,31 @@ Central authentication and credit management system for the Mana Universe ecosys
### 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 }`
@ -128,27 +142,32 @@ Central authentication and credit management system for the Mana Universe ecosys
### Credits
**GET** `/api/v1/credits/balance`
- Get current credit balance
- Requires: Bearer token
- Returns: `{ balance, freeCreditsRemaining, 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
@ -156,6 +175,7 @@ Central authentication and credit management system for the Mana Universe ecosys
## Database Schema
### Auth Schema
- `auth.users` - User accounts
- `auth.sessions` - Active sessions
- `auth.passwords` - Hashed passwords
@ -165,6 +185,7 @@ Central authentication and credit management system for the Mana Universe ecosys
- `auth.security_events` - Security audit log
### Credits Schema
- `credits.balances` - User credit balances
- `credits.transactions` - Transaction ledger
- `credits.packages` - Available credit packages
@ -176,6 +197,7 @@ Central authentication and credit management system for the Mana Universe ecosys
See `.env.example` for all available configuration options.
Key variables:
- `DATABASE_URL` - PostgreSQL connection string
- `JWT_PUBLIC_KEY` - RS256 public key (PEM format)
- `JWT_PRIVATE_KEY` - RS256 private key (PEM format)

View file

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

View file

@ -1,10 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false,
"tsConfigPath": "tsconfig.json"
}
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"webpack": false,
"tsConfigPath": "tsconfig.json"
}
}

View file

@ -1,78 +1,78 @@
{
"name": "mana-core-auth",
"version": "0.1.0",
"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",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"bcrypt": "^5.1.1",
"better-auth": "^1.1.1",
"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",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"nanoid": "^5.0.9",
"postgres": "^3.4.5",
"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/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@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"
}
"name": "mana-core-auth",
"version": "0.1.0",
"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",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"bcrypt": "^5.1.1",
"better-auth": "^1.1.1",
"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",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"nanoid": "^5.0.9",
"postgres": "^3.4.5",
"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/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@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"
}
}

View file

@ -8,25 +8,25 @@ import { CreditsModule } from './credits/credits.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
ThrottlerModule.forRoot([
{
ttl: 60000, // 60 seconds
limit: 100, // 100 requests per minute
},
]),
AuthModule,
CreditsModule,
],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
ThrottlerModule.forRoot([
{
ttl: 60000, // 60 seconds
limit: 100, // 100 requests per minute
},
]),
AuthModule,
CreditsModule,
],
providers: [
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},
],
})
export class AppModule {}

View file

@ -9,45 +9,45 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(private readonly authService: AuthService) {}
@Post('register')
async register(
@Body() registerDto: RegisterDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string,
) {
return this.authService.register(registerDto, ipAddress, userAgent);
}
@Post('register')
async register(
@Body() registerDto: RegisterDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string
) {
return this.authService.register(registerDto, ipAddress, userAgent);
}
@Post('login')
async login(
@Body() loginDto: LoginDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string,
) {
return this.authService.login(loginDto, ipAddress, userAgent);
}
@Post('login')
async login(
@Body() loginDto: LoginDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string
) {
return this.authService.login(loginDto, ipAddress, userAgent);
}
@Post('refresh')
async refresh(
@Body() refreshTokenDto: RefreshTokenDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string,
) {
return this.authService.refreshToken(refreshTokenDto.refreshToken, ipAddress, userAgent);
}
@Post('refresh')
async refresh(
@Body() refreshTokenDto: RefreshTokenDto,
@Ip() ipAddress: string,
@Headers('user-agent') userAgent: string
) {
return this.authService.refreshToken(refreshTokenDto.refreshToken, ipAddress, userAgent);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request & { user: CurrentUserData }) {
// Extract sessionId from JWT (would need to be added to the CurrentUserData interface)
// For now, we'll use a placeholder
return this.authService.logout('session-id');
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Req() req: Request & { user: CurrentUserData }) {
// Extract sessionId from JWT (would need to be added to the CurrentUserData interface)
// For now, we'll use a placeholder
return this.authService.logout('session-id');
}
@Post('validate')
async validate(@Body() body: { token: string }) {
return this.authService.validateToken(body.token);
}
@Post('validate')
async validate(@Body() body: { token: string }) {
return this.authService.validateToken(body.token);
}
}

View file

@ -3,8 +3,8 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View file

@ -1,4 +1,9 @@
import { Injectable, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, isNull } from 'drizzle-orm';
import * as bcrypt from 'bcrypt';
@ -11,281 +16,275 @@ import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
export interface TokenPayload {
sub: string;
email: string;
role: string;
sessionId: string;
deviceId?: string;
sub: string;
email: string;
role: string;
sessionId: string;
deviceId?: string;
}
@Injectable()
export class AuthService {
constructor(private configService: ConfigService) {}
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
// Check if user already exists
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, registerDto.email.toLowerCase()))
.limit(1);
// Check if user already exists
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, registerDto.email.toLowerCase()))
.limit(1);
if (existingUser.length > 0) {
throw new ConflictException('User with this email already exists');
}
if (existingUser.length > 0) {
throw new ConflictException('User with this email already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
// Hash password
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
// Create user
const [newUser] = await db
.insert(users)
.values({
email: registerDto.email.toLowerCase(),
name: registerDto.name,
role: 'user',
})
.returning();
// Create user
const [newUser] = await db
.insert(users)
.values({
email: registerDto.email.toLowerCase(),
name: registerDto.name,
role: 'user',
})
.returning();
// Store password
await db.insert(passwords).values({
userId: newUser.id,
hashedPassword,
});
// Store password
await db.insert(passwords).values({
userId: newUser.id,
hashedPassword,
});
// Initialize credit balance (done via trigger or separate service call)
// This will be handled by the credits service
// Initialize credit balance (done via trigger or separate service call)
// This will be handled by the credits service
return {
id: newUser.id,
email: newUser.email,
name: newUser.name,
createdAt: newUser.createdAt,
};
}
return {
id: newUser.id,
email: newUser.email,
name: newUser.name,
createdAt: newUser.createdAt,
};
}
async login(loginDto: LoginDto, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
async login(loginDto: LoginDto, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
// Find user
const [user] = await db
.select()
.from(users)
.where(eq(users.email, loginDto.email.toLowerCase()))
.limit(1);
// Find user
const [user] = await db
.select()
.from(users)
.where(eq(users.email, loginDto.email.toLowerCase()))
.limit(1);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if user is soft-deleted
if (user.deletedAt) {
throw new UnauthorizedException('Account has been deleted');
}
// Check if user is soft-deleted
if (user.deletedAt) {
throw new UnauthorizedException('Account has been deleted');
}
// Get password
const [passwordRecord] = await db
.select()
.from(passwords)
.where(eq(passwords.userId, user.id))
.limit(1);
// Get password
const [passwordRecord] = await db
.select()
.from(passwords)
.where(eq(passwords.userId, user.id))
.limit(1);
if (!passwordRecord) {
throw new UnauthorizedException('Invalid credentials');
}
if (!passwordRecord) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
// Verify password
const isPasswordValid = await bcrypt.compare(loginDto.password, passwordRecord.hashedPassword);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Generate tokens
const tokenData = await this.generateTokens(
user.id,
user.email,
user.role,
loginDto.deviceId,
loginDto.deviceName,
ipAddress,
userAgent,
);
// Generate tokens
const tokenData = await this.generateTokens(
user.id,
user.email,
user.role,
loginDto.deviceId,
loginDto.deviceName,
ipAddress,
userAgent
);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
...tokenData,
};
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
...tokenData,
};
}
async refreshToken(refreshToken: string, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
async refreshToken(refreshToken: string, ipAddress?: string, userAgent?: string) {
const db = this.getDb();
// Find session by refresh token
const [session] = await db
.select()
.from(sessions)
.where(and(eq(sessions.refreshToken, refreshToken), isNull(sessions.revokedAt)))
.limit(1);
// 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');
}
if (!session) {
throw new UnauthorizedException('Invalid refresh token');
}
// Check if refresh token is expired
if (new Date() > session.refreshTokenExpiresAt) {
throw new UnauthorizedException('Refresh token expired');
}
// Check if refresh token is expired
if (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);
// 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');
}
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));
// Revoke old session (refresh token rotation)
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, session.id));
// Generate new tokens
const tokenData = await this.generateTokens(
user.id,
user.email,
user.role,
session.deviceId ?? undefined,
session.deviceName ?? undefined,
ipAddress,
userAgent,
);
// Generate new tokens
const tokenData = await this.generateTokens(
user.id,
user.email,
user.role,
session.deviceId ?? undefined,
session.deviceName ?? undefined,
ipAddress,
userAgent
);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
...tokenData,
};
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
...tokenData,
};
}
async logout(sessionId: string) {
const db = this.getDb();
async logout(sessionId: string) {
const db = this.getDb();
await db
.update(sessions)
.set({ revokedAt: new Date() })
.where(eq(sessions.id, sessionId));
await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, sessionId));
return { message: 'Logged out successfully' };
}
return { message: 'Logged out successfully' };
}
private async generateTokens(
userId: string,
email: string,
role: string,
deviceId?: string,
deviceName?: string,
ipAddress?: string,
userAgent?: string,
) {
const db = this.getDb();
private async generateTokens(
userId: string,
email: string,
role: string,
deviceId?: string,
deviceName?: string,
ipAddress?: string,
userAgent?: string
) {
const db = this.getDb();
const privateKeyRaw = this.configService.get<string>('jwt.privateKey');
if (!privateKeyRaw) {
throw new Error('JWT private key not configured');
}
const privateKey: string = privateKeyRaw;
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
const issuer = this.configService.get<string>('jwt.issuer');
const audience = this.configService.get<string>('jwt.audience');
const privateKeyRaw = this.configService.get<string>('jwt.privateKey');
if (!privateKeyRaw) {
throw new Error('JWT private key not configured');
}
const privateKey: string = privateKeyRaw;
const accessTokenExpiry = this.configService.get<string>('jwt.accessTokenExpiry') || '15m';
const refreshTokenExpiry = this.configService.get<string>('jwt.refreshTokenExpiry') || '7d';
const issuer = this.configService.get<string>('jwt.issuer');
const audience = this.configService.get<string>('jwt.audience');
// Generate session ID (must be UUID for database)
const sessionId = randomUUID();
// Generate session ID (must be UUID for database)
const sessionId = randomUUID();
// Create session record
const refreshTokenString = nanoid(64);
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
// Create session record
const refreshTokenString = nanoid(64);
const refreshTokenExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const accessTokenExpiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.insert(sessions).values({
id: sessionId,
userId,
token: sessionId,
refreshToken: refreshTokenString,
refreshTokenExpiresAt,
ipAddress,
userAgent,
deviceId,
deviceName,
expiresAt: accessTokenExpiresAt,
});
await db.insert(sessions).values({
id: sessionId,
userId,
token: sessionId,
refreshToken: refreshTokenString,
refreshTokenExpiresAt,
ipAddress,
userAgent,
deviceId,
deviceName,
expiresAt: accessTokenExpiresAt,
});
// Generate JWT payload
const tokenPayload: Record<string, unknown> = {
sub: userId,
email,
role,
sessionId,
...(deviceId && { deviceId }),
};
// Generate JWT payload
const tokenPayload: Record<string, unknown> = {
sub: userId,
email,
role,
sessionId,
...(deviceId && { deviceId }),
};
// Sign access token
const accessToken = jwt.sign(tokenPayload, privateKey, {
algorithm: 'RS256' as const,
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
...(issuer && { issuer }),
...(audience && { audience }),
});
// Sign access token
const accessToken = jwt.sign(tokenPayload, privateKey, {
algorithm: 'RS256' as const,
expiresIn: accessTokenExpiry as jwt.SignOptions['expiresIn'],
...(issuer && { issuer }),
...(audience && { audience }),
});
return {
accessToken,
refreshToken: refreshTokenString,
expiresIn: 15 * 60, // 15 minutes in seconds
tokenType: 'Bearer',
};
}
return {
accessToken,
refreshToken: refreshTokenString,
expiresIn: 15 * 60, // 15 minutes in seconds
tokenType: 'Bearer',
};
}
async validateToken(token: string) {
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new Error('JWT public key not configured');
}
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
async validateToken(token: string) {
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new Error('JWT public key not configured');
}
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as TokenPayload;
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as TokenPayload;
return {
valid: true,
payload,
};
} catch (error) {
return {
valid: false,
error: error.message,
};
}
}
return {
valid: true,
payload,
};
} catch (error) {
return {
valid: false,
error: error.message,
};
}
}
}

View file

@ -1,17 +1,17 @@
import { IsEmail, IsString, IsOptional } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsEmail()
email: string;
@IsString()
password: string;
@IsString()
password: string;
@IsString()
@IsOptional()
deviceId?: string;
@IsString()
@IsOptional()
deviceId?: string;
@IsString()
@IsOptional()
deviceName?: string;
@IsString()
@IsOptional()
deviceName?: string;
}

View file

@ -1,6 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
@IsString()
refreshToken: string;
}

View file

@ -1,16 +1,16 @@
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsEmail()
email: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;
}

View file

@ -1,14 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
userId: string;
email: string;
role: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);

View file

@ -3,37 +3,37 @@ import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let errors: any = undefined;
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 (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;
}
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,
};
const errorResponse = {
statusCode: status,
message,
...(errors && { errors }),
timestamp: new Date().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
response.status(status).json(errorResponse);
}
}

View file

@ -4,45 +4,45 @@ import * as jwt from 'jsonwebtoken';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new UnauthorizedException('JWT configuration error');
}
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
try {
const publicKey = this.configService.get<string>('jwt.publicKey');
if (!publicKey) {
throw new UnauthorizedException('JWT configuration error');
}
const audience = this.configService.get<string>('jwt.audience');
const issuer = this.configService.get<string>('jwt.issuer');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as jwt.JwtPayload;
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience,
issuer,
}) as jwt.JwtPayload;
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
};
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
};
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -1,44 +1,47 @@
export default () => ({
port: parseInt(process.env.PORT || '3001', 10),
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3001', 10),
nodeEnv: process.env.NODE_ENV || 'development',
database: {
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
},
database: {
url: process.env.DATABASE_URL || 'postgresql://manacore:password@localhost:5432/manacore',
},
jwt: {
publicKey: process.env.JWT_PUBLIC_KEY || '',
privateKey: process.env.JWT_PRIVATE_KEY || '',
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
issuer: process.env.JWT_ISSUER || 'manacore',
audience: process.env.JWT_AUDIENCE || 'manacore',
},
jwt: {
publicKey: process.env.JWT_PUBLIC_KEY || '',
privateKey: process.env.JWT_PRIVATE_KEY || '',
accessTokenExpiry: process.env.JWT_ACCESS_TOKEN_EXPIRY || '15m',
refreshTokenExpiry: process.env.JWT_REFRESH_TOKEN_EXPIRY || '7d',
issuer: process.env.JWT_ISSUER || 'manacore',
audience: process.env.JWT_AUDIENCE || 'manacore',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
},
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
},
cors: {
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000', 'http://localhost:8081'],
credentials: true,
},
cors: {
origin: process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:3000',
'http://localhost:8081',
],
credentials: true,
},
rateLimit: {
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
},
rateLimit: {
ttl: parseInt(process.env.RATE_LIMIT_TTL || '60', 10),
limit: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
},
credits: {
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
},
credits: {
signupBonus: parseInt(process.env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(process.env.CREDITS_DAILY_FREE || '5', 10),
},
});

View file

@ -7,34 +7,34 @@ import { UseCreditsDto } from './dto/use-credits.dto';
@Controller('credits')
@UseGuards(JwtAuthGuard)
export class CreditsController {
constructor(private readonly creditsService: CreditsService) {}
constructor(private readonly creditsService: CreditsService) {}
@Get('balance')
async getBalance(@CurrentUser() user: CurrentUserData) {
return this.creditsService.getBalance(user.userId);
}
@Get('balance')
async getBalance(@CurrentUser() user: CurrentUserData) {
return this.creditsService.getBalance(user.userId);
}
@Post('use')
async useCredits(@CurrentUser() user: CurrentUserData, @Body() useCreditsDto: UseCreditsDto) {
return this.creditsService.useCredits(user.userId, useCreditsDto);
}
@Post('use')
async useCredits(@CurrentUser() user: CurrentUserData, @Body() useCreditsDto: UseCreditsDto) {
return this.creditsService.useCredits(user.userId, useCreditsDto);
}
@Get('transactions')
async getTransactionHistory(
@CurrentUser() user: CurrentUserData,
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
) {
return this.creditsService.getTransactionHistory(user.userId, limit, offset);
}
@Get('transactions')
async getTransactionHistory(
@CurrentUser() user: CurrentUserData,
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number
) {
return this.creditsService.getTransactionHistory(user.userId, limit, offset);
}
@Get('purchases')
async getPurchaseHistory(@CurrentUser() user: CurrentUserData) {
return this.creditsService.getPurchaseHistory(user.userId);
}
@Get('purchases')
async getPurchaseHistory(@CurrentUser() user: CurrentUserData) {
return this.creditsService.getPurchaseHistory(user.userId);
}
@Get('packages')
async getPackages() {
return this.creditsService.getPackages();
}
@Get('packages')
async getPackages() {
return this.creditsService.getPackages();
}
}

View file

@ -3,8 +3,8 @@ import { CreditsController } from './credits.controller';
import { CreditsService } from './credits.service';
@Module({
controllers: [CreditsController],
providers: [CreditsService],
exports: [CreditsService],
controllers: [CreditsController],
providers: [CreditsService],
exports: [CreditsService],
})
export class CreditsModule {}

View file

@ -1,4 +1,9 @@
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
import {
Injectable,
BadRequestException,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, desc } from 'drizzle-orm';
import { getDb } from '../db/connection';
@ -7,269 +12,261 @@ import { UseCreditsDto } from './dto/use-credits.dto';
@Injectable()
export class CreditsService {
constructor(private configService: ConfigService) {}
constructor(private configService: ConfigService) {}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
private getDb() {
const databaseUrl = this.configService.get<string>('database.url');
return getDb(databaseUrl!);
}
async initializeUserBalance(userId: string) {
const db = this.getDb();
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
async initializeUserBalance(userId: string) {
const db = this.getDb();
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
// Check if balance already exists
const [existingBalance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
// Check if balance already exists
const [existingBalance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
if (existingBalance) {
return existingBalance;
}
if (existingBalance) {
return existingBalance;
}
// Create initial balance with signup bonus
const [balance] = await db
.insert(balances)
.values({
userId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
})
.returning();
// Create initial balance with signup bonus
const [balance] = await db
.insert(balances)
.values({
userId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
})
.returning();
// Create transaction record for signup bonus
await db.insert(transactions).values({
userId,
type: 'bonus',
status: 'completed',
amount: signupBonus,
balanceBefore: 0,
balanceAfter: 0,
appId: 'system',
description: 'Signup bonus',
completedAt: new Date(),
});
// Create transaction record for signup bonus
await db.insert(transactions).values({
userId,
type: 'bonus',
status: 'completed',
amount: signupBonus,
balanceBefore: 0,
balanceAfter: 0,
appId: 'system',
description: 'Signup bonus',
completedAt: new Date(),
});
return balance;
}
return balance;
}
async getBalance(userId: string) {
const db = this.getDb();
async getBalance(userId: string) {
const db = this.getDb();
// Check and apply daily free credits reset
await this.checkDailyReset(userId);
// Check and apply daily free credits reset
await this.checkDailyReset(userId);
const [balance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
if (!balance) {
// Initialize balance if it doesn't exist
return this.initializeUserBalance(userId);
}
if (!balance) {
// Initialize balance if it doesn't exist
return this.initializeUserBalance(userId);
}
return {
balance: balance.balance,
freeCreditsRemaining: balance.freeCreditsRemaining,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
dailyFreeCredits: balance.dailyFreeCredits,
};
}
return {
balance: balance.balance,
freeCreditsRemaining: balance.freeCreditsRemaining,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
dailyFreeCredits: balance.dailyFreeCredits,
};
}
async useCredits(userId: string, useCreditsDto: UseCreditsDto) {
const db = this.getDb();
async useCredits(userId: string, useCreditsDto: UseCreditsDto) {
const db = this.getDb();
// Check for idempotency
if (useCreditsDto.idempotencyKey) {
const [existingTransaction] = await db
.select()
.from(transactions)
.where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey))
.limit(1);
// Check for idempotency
if (useCreditsDto.idempotencyKey) {
const [existingTransaction] = await db
.select()
.from(transactions)
.where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey))
.limit(1);
if (existingTransaction) {
return {
success: true,
transaction: existingTransaction,
message: 'Transaction already processed',
};
}
}
if (existingTransaction) {
return {
success: true,
transaction: existingTransaction,
message: 'Transaction already processed',
};
}
}
// Use a transaction for atomicity
return await db.transaction(async (tx) => {
// Get current balance with row lock (SELECT FOR UPDATE)
const [currentBalance] = await tx
.select()
.from(balances)
.where(eq(balances.userId, userId))
.for('update')
.limit(1);
// Use a transaction for atomicity
return await db.transaction(async (tx) => {
// Get current balance with row lock (SELECT FOR UPDATE)
const [currentBalance] = await tx
.select()
.from(balances)
.where(eq(balances.userId, userId))
.for('update')
.limit(1);
if (!currentBalance) {
throw new NotFoundException('User balance not found');
}
if (!currentBalance) {
throw new NotFoundException('User balance not found');
}
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
if (totalAvailable < useCreditsDto.amount) {
throw new BadRequestException('Insufficient credits');
}
if (totalAvailable < useCreditsDto.amount) {
throw new BadRequestException('Insufficient credits');
}
// Calculate deduction from free and paid credits
let freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
let paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
// Calculate deduction from free and paid credits
let freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
let paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
const newBalance = currentBalance.balance - paidCreditsUsed;
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
const newBalance = currentBalance.balance - paidCreditsUsed;
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
// Update balance with optimistic locking
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
version: currentBalance.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version)))
.returning();
// Update balance with optimistic locking
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
version: currentBalance.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version)))
.returning();
if (updateResult.length === 0) {
throw new ConflictException('Balance was modified by another transaction. Please retry.');
}
if (updateResult.length === 0) {
throw new ConflictException('Balance was modified by another transaction. Please retry.');
}
// Create transaction record
const [transaction] = await tx
.insert(transactions)
.values({
userId,
type: 'usage',
status: 'completed',
amount: -useCreditsDto.amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: newBalance + newFreeCredits,
appId: useCreditsDto.appId,
description: useCreditsDto.description,
metadata: useCreditsDto.metadata,
idempotencyKey: useCreditsDto.idempotencyKey,
completedAt: new Date(),
})
.returning();
// Create transaction record
const [transaction] = await tx
.insert(transactions)
.values({
userId,
type: 'usage',
status: 'completed',
amount: -useCreditsDto.amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: newBalance + newFreeCredits,
appId: useCreditsDto.appId,
description: useCreditsDto.description,
metadata: useCreditsDto.metadata,
idempotencyKey: useCreditsDto.idempotencyKey,
completedAt: new Date(),
})
.returning();
// Track usage stats (for analytics)
const today = new Date();
today.setHours(0, 0, 0, 0);
// Track usage stats (for analytics)
const today = new Date();
today.setHours(0, 0, 0, 0);
await tx.insert(usageStats).values({
userId,
appId: useCreditsDto.appId,
creditsUsed: useCreditsDto.amount,
date: today,
metadata: useCreditsDto.metadata,
});
await tx.insert(usageStats).values({
userId,
appId: useCreditsDto.appId,
creditsUsed: useCreditsDto.amount,
date: today,
metadata: useCreditsDto.metadata,
});
return {
success: true,
transaction,
newBalance: {
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
},
};
});
}
return {
success: true,
transaction,
newBalance: {
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
},
};
});
}
async getTransactionHistory(userId: string, limit: number = 50, offset: number = 0) {
const db = this.getDb();
async getTransactionHistory(userId: string, limit: number = 50, offset: number = 0) {
const db = this.getDb();
const transactionList = await db
.select()
.from(transactions)
.where(eq(transactions.userId, userId))
.orderBy(desc(transactions.createdAt))
.limit(limit)
.offset(offset);
const transactionList = await db
.select()
.from(transactions)
.where(eq(transactions.userId, userId))
.orderBy(desc(transactions.createdAt))
.limit(limit)
.offset(offset);
return transactionList;
}
return transactionList;
}
async getPurchaseHistory(userId: string) {
const db = this.getDb();
async getPurchaseHistory(userId: string) {
const db = this.getDb();
return await db
.select()
.from(purchases)
.where(eq(purchases.userId, userId))
.orderBy(desc(purchases.createdAt));
}
return await db
.select()
.from(purchases)
.where(eq(purchases.userId, userId))
.orderBy(desc(purchases.createdAt));
}
async getPackages() {
const db = this.getDb();
async getPackages() {
const db = this.getDb();
return await db
.select()
.from(packages)
.where(eq(packages.active, true))
.orderBy(packages.sortOrder);
}
return await db
.select()
.from(packages)
.where(eq(packages.active, true))
.orderBy(packages.sortOrder);
}
private async checkDailyReset(userId: string) {
const db = this.getDb();
private async checkDailyReset(userId: string) {
const db = this.getDb();
const [balance] = await db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
if (!balance) {
return;
}
if (!balance) {
return;
}
const now = new Date();
const lastReset = balance.lastDailyResetAt;
const now = new Date();
const lastReset = balance.lastDailyResetAt;
// Check if last reset was on a different day
if (
!lastReset ||
lastReset.getDate() !== now.getDate() ||
lastReset.getMonth() !== now.getMonth() ||
lastReset.getFullYear() !== now.getFullYear()
) {
// Reset daily free credits
await db
.update(balances)
.set({
freeCreditsRemaining: balance.freeCreditsRemaining + balance.dailyFreeCredits,
lastDailyResetAt: now,
updatedAt: now,
})
.where(eq(balances.userId, userId));
// Check if last reset was on a different day
if (
!lastReset ||
lastReset.getDate() !== now.getDate() ||
lastReset.getMonth() !== now.getMonth() ||
lastReset.getFullYear() !== now.getFullYear()
) {
// Reset daily free credits
await db
.update(balances)
.set({
freeCreditsRemaining: balance.freeCreditsRemaining + balance.dailyFreeCredits,
lastDailyResetAt: now,
updatedAt: now,
})
.where(eq(balances.userId, userId));
// Create transaction record for daily bonus
await db.insert(transactions).values({
userId,
type: 'bonus',
status: 'completed',
amount: balance.dailyFreeCredits,
balanceBefore: balance.balance + balance.freeCreditsRemaining,
balanceAfter: balance.balance + balance.freeCreditsRemaining + balance.dailyFreeCredits,
appId: 'system',
description: 'Daily free credits',
completedAt: now,
});
}
}
// Create transaction record for daily bonus
await db.insert(transactions).values({
userId,
type: 'bonus',
status: 'completed',
amount: balance.dailyFreeCredits,
balanceBefore: balance.balance + balance.freeCreditsRemaining,
balanceAfter: balance.balance + balance.freeCreditsRemaining + balance.dailyFreeCredits,
appId: 'system',
description: 'Daily free credits',
completedAt: now,
});
}
}
}

View file

@ -1,9 +1,9 @@
import { IsUUID, IsOptional } from 'class-validator';
export class PurchaseCreditsDto {
@IsUUID()
packageId: string;
@IsUUID()
packageId: string;
@IsOptional()
metadata?: Record<string, any>;
@IsOptional()
metadata?: Record<string, any>;
}

View file

@ -1,21 +1,21 @@
import { IsString, IsInt, IsPositive, IsOptional, IsObject } from 'class-validator';
export class UseCreditsDto {
@IsInt()
@IsPositive()
amount: number;
@IsInt()
@IsPositive()
amount: number;
@IsString()
appId: string;
@IsString()
appId: string;
@IsString()
description: string;
@IsString()
description: string;
@IsString()
@IsOptional()
idempotencyKey?: string;
@IsString()
@IsOptional()
idempotencyKey?: string;
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
}

View file

@ -6,28 +6,28 @@ let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
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;
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;
}
if (connection) {
await connection.end();
connection = null;
db = null;
}
}

View file

@ -6,24 +6,24 @@ import { getDb, closeConnection } from './connection';
config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
console.log('Running migrations...');
try {
const db = getDb(databaseUrl);
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeConnection();
}
try {
const db = getDb(databaseUrl);
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeConnection();
}
}
runMigrations();

View file

@ -1,13 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
}
]
}

View file

@ -8,87 +8,97 @@ export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
// Users table
export const users = authSchema.table('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').unique().notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
role: userRoleEnum('role').default('user').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').unique().notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
role: userRoleEnum('role').default('user').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
// Sessions table
export const sessions = authSchema.table('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
token: text('token').unique().notNull(),
refreshToken: text('refresh_token').unique().notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
deviceId: text('device_id'),
deviceName: text('device_name'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').unique().notNull(),
refreshToken: text('refresh_token').unique().notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
deviceId: text('device_id'),
deviceName: text('device_name'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
});
// Accounts table (for OAuth providers)
export const accounts = authSchema.table('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
tokenType: text('token_type'),
scope: text('scope'),
idToken: text('id_token'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
tokenType: text('token_type'),
scope: text('scope'),
idToken: text('id_token'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Verification tokens (for email verification, password reset)
export const verificationTokens = authSchema.table('verification_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
token: text('token').unique().notNull(),
type: text('type').notNull(), // 'email_verification', 'password_reset'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').unique().notNull(),
type: text('type').notNull(), // 'email_verification', 'password_reset'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
});
// Password table (separate for security)
export const passwords = authSchema.table('passwords', {
userId: uuid('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(),
userId: uuid('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: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
secret: text('secret').notNull(),
enabled: boolean('enabled').default(false).notNull(),
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
enabledAt: timestamp('enabled_at', { withTimezone: true }),
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
secret: text('secret').notNull(),
enabled: boolean('enabled').default(false).notNull(),
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
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(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -1,104 +1,136 @@
import { pgSchema, uuid, integer, text, timestamp, jsonb, index, pgEnum, boolean } from 'drizzle-orm/pg-core';
import {
pgSchema,
uuid,
integer,
text,
timestamp,
jsonb,
index,
pgEnum,
boolean,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
export const creditsSchema = pgSchema('credits');
// Transaction types enum
export const transactionTypeEnum = pgEnum('transaction_type', [
'purchase',
'usage',
'refund',
'bonus',
'expiry',
'adjustment',
'purchase',
'usage',
'refund',
'bonus',
'expiry',
'adjustment',
]);
// Transaction status enum
export const transactionStatusEnum = pgEnum('transaction_status', [
'pending',
'completed',
'failed',
'cancelled',
'pending',
'completed',
'failed',
'cancelled',
]);
// Credit balances (one per user)
export const balances = creditsSchema.table('balances', {
userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
freeCreditsRemaining: integer('free_credits_remaining').default(150).notNull(),
dailyFreeCredits: integer('daily_free_credits').default(5).notNull(),
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
totalEarned: integer('total_earned').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(), // For optimistic locking
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
freeCreditsRemaining: integer('free_credits_remaining').default(150).notNull(),
dailyFreeCredits: integer('daily_free_credits').default(5).notNull(),
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
totalEarned: integer('total_earned').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(), // For optimistic locking
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Transaction ledger
export const transactions = creditsSchema.table('transactions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: transactionTypeEnum('type').notNull(),
status: transactionStatusEnum('status').default('pending').notNull(),
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
description: text('description').notNull(),
metadata: jsonb('metadata'), // Additional context
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
appIdIdx: index('transactions_app_id_idx').on(table.appId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
}));
export const transactions = creditsSchema.table(
'transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
type: transactionTypeEnum('type').notNull(),
status: transactionStatusEnum('status').default('pending').notNull(),
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
description: text('description').notNull(),
metadata: jsonb('metadata'), // Additional context
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
appIdIdx: index('transactions_app_id_idx').on(table.appId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
})
);
// Credit packages (pricing tiers)
export const packages = creditsSchema.table('packages', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
credits: integer('credits').notNull(), // Number of credits
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
stripePriceId: text('stripe_price_id').unique(),
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(),
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
credits: integer('credits').notNull(), // Number of credits
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
stripePriceId: text('stripe_price_id').unique(),
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(),
});
// Purchase history
export const purchases = creditsSchema.table('purchases', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
packageId: uuid('package_id').references(() => packages.id),
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
stripeCustomerId: text('stripe_customer_id'),
status: transactionStatusEnum('status').default('pending').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => ({
userIdIdx: index('purchases_user_id_idx').on(table.userId),
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(table.stripePaymentIntentId),
}));
export const purchases = creditsSchema.table(
'purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
packageId: uuid('package_id').references(() => packages.id),
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
stripeCustomerId: text('stripe_customer_id'),
status: transactionStatusEnum('status').default('pending').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => ({
userIdIdx: index('purchases_user_id_idx').on(table.userId),
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(
table.stripePaymentIntentId
),
})
);
// Usage tracking (for analytics)
export const usageStats = creditsSchema.table('usage_stats', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
appId: text('app_id').notNull(),
creditsUsed: integer('credits_used').notNull(),
date: timestamp('date', { withTimezone: true }).notNull(),
metadata: jsonb('metadata'),
}, (table) => ({
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
}));
export const usageStats = creditsSchema.table(
'usage_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
appId: text('app_id').notNull(),
creditsUsed: integer('credits_used').notNull(),
date: timestamp('date', { withTimezone: true }).notNull(),
metadata: jsonb('metadata'),
},
(table) => ({
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
})
);

View file

@ -6,43 +6,43 @@ import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const configService = app.get(ConfigService);
// Security middleware
app.use(helmet());
app.use(cookieParser());
// Security middleware
app.use(helmet());
app.use(cookieParser());
// CORS configuration
const corsOrigins = configService.get<string[]>('cors.origin') || [];
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
});
// CORS configuration
const corsOrigins = configService.get<string[]>('cors.origin') || [];
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// Global prefix
app.setGlobalPrefix('api/v1');
// Global prefix
app.setGlobalPrefix('api/v1');
const port = configService.get<number>('port') || 3001;
await app.listen(port);
const port = configService.get<number>('port') || 3001;
await app.listen(port);
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
console.log(`🚀 Mana Core Auth running on: http://localhost:${port}`);
console.log(`📚 Environment: ${configService.get<string>('nodeEnv')}`);
}
bootstrap();

View file

@ -1,29 +1,29 @@
{
"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"]
"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"]
}