diff --git a/apps/core/.env.example b/apps/core/.env.example new file mode 100644 index 0000000..8e01ed9 --- /dev/null +++ b/apps/core/.env.example @@ -0,0 +1,165 @@ +# ============================================================================== +# Asset-Forge Environment Configuration +# ============================================================================== +# Copy this file to .env and fill in your values +# Required variables are marked with (REQUIRED) +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Node Environment +# ------------------------------------------------------------------------------ +NODE_ENV=development # development | production | test + +# ------------------------------------------------------------------------------ +# Database (REQUIRED) +# ------------------------------------------------------------------------------ +DATABASE_URL=postgresql://user:password@localhost:5432/assetforge + +# ------------------------------------------------------------------------------ +# Server Configuration +# ------------------------------------------------------------------------------ +PORT=3004 +API_PORT=3004 + +# ------------------------------------------------------------------------------ +# Authentication (Privy) +# Both required for authentication to work +# ------------------------------------------------------------------------------ +PRIVY_APP_ID=your_privy_app_id +PRIVY_APP_SECRET=your_privy_app_secret + +# Frontend Privy ID (same as PRIVY_APP_ID, used by Vite) +VITE_PRIVY_APP_ID=your_privy_app_id + +# ------------------------------------------------------------------------------ +# API Key Encryption +# Required for secure storage of user-provided API keys +# Generate with: openssl rand -base64 32 +# ------------------------------------------------------------------------------ +API_KEY_ENCRYPTION_SECRET=your_32_character_encryption_secret + +# ------------------------------------------------------------------------------ +# AI Services +# At least one AI service is recommended +# ------------------------------------------------------------------------------ +# Vercel AI Gateway (recommended - single key for multiple providers) +AI_GATEWAY_API_KEY=your_ai_gateway_key + +# Or direct provider keys +OPENAI_API_KEY=sk-your_openai_key +# ANTHROPIC_API_KEY=your_anthropic_key + +# ------------------------------------------------------------------------------ +# 3D Asset Generation (Meshy AI) +# Required for 3D model generation features +# ------------------------------------------------------------------------------ +MESHY_API_KEY=your_meshy_api_key +MESHY_MODEL_DEFAULT=meshy-4 +MESHY_POLL_INTERVAL_MS=10000 +MESHY_TIMEOUT_MS=300000 + +# ------------------------------------------------------------------------------ +# Voice/Audio Generation (ElevenLabs) +# Optional - for voice synthesis and sound effects +# ------------------------------------------------------------------------------ +ELEVENLABS_API_KEY=your_elevenlabs_key + +# ------------------------------------------------------------------------------ +# Vector Database (Qdrant) +# Optional - for semantic search features +# ------------------------------------------------------------------------------ +QDRANT_URL=http://localhost:6333 +QDRANT_API_KEY=your_qdrant_key + +# ------------------------------------------------------------------------------ +# URLs & CORS +# IMPORTANT: Configure these for production deployment +# ------------------------------------------------------------------------------ +# Frontend URL (REQUIRED in production for CORS/CSRF) +FRONTEND_URL=http://localhost:3000 +VITE_API_URL=http://localhost:3004 + +# Additional allowed CORS origins (comma-separated) +CORS_ALLOWED_ORIGINS= + +# CDN Configuration (for asset delivery) +CDN_URL=https://your-cdn.example.com +CDN_API_KEY=your_cdn_api_key +CDN_WS_URL=wss://your-cdn.example.com/ws +AUTO_PUBLISH_TO_CDN=true + +# Image server URL (for Meshy AI callbacks) +IMAGE_SERVER_URL=http://localhost:3004 + +# ------------------------------------------------------------------------------ +# Webhook Configuration +# For CDN-to-app communication +# ------------------------------------------------------------------------------ +WEBHOOK_SECRET=your_webhook_secret_32_chars_minimum +CDN_WEBHOOK_ENABLED=false +WEBHOOK_SYSTEM_USER_ID= + +# ------------------------------------------------------------------------------ +# Rate Limiting +# Enabled by default in all environments +# ------------------------------------------------------------------------------ +# Set to "true" to disable rate limiting (NOT recommended) +# DISABLE_RATE_LIMITING=false + +# ------------------------------------------------------------------------------ +# Logging +# ------------------------------------------------------------------------------ +LOG_LEVEL=info # fatal | error | warn | info | debug | trace + +# ------------------------------------------------------------------------------ +# Testing Only +# ------------------------------------------------------------------------------ +# Secret for test JWT signing (only needed when NODE_ENV=test) +TEST_JWT_SECRET=test-secret-for-jwt-signing + +# ------------------------------------------------------------------------------ +# Railway Platform (auto-set by Railway) +# ------------------------------------------------------------------------------ +# RAILWAY_VOLUME_MOUNT_PATH=/data +# RAILWAY_PUBLIC_DOMAIN=your-app.railway.app + +# ------------------------------------------------------------------------------ +# Image Hosting (Legacy) +# ------------------------------------------------------------------------------ +# IMGUR_CLIENT_ID=your_imgur_client_id + +# ============================================================================== +# DEPLOYMENT CHECKLIST +# ============================================================================== +# Before deploying to production, ensure: +# +# 1. Security: +# [ ] NODE_ENV=production +# [ ] FRONTEND_URL is set (required for CORS/CSRF) +# [ ] API_KEY_ENCRYPTION_SECRET is a strong random value +# [ ] WEBHOOK_SECRET is set if using CDN webhooks +# [ ] All secrets are unique and not shared across environments +# +# 2. Database: +# [ ] DATABASE_URL points to production database +# [ ] Database migrations have been applied +# [ ] Connection pool is appropriately sized +# +# 3. Authentication: +# [ ] PRIVY_APP_ID and PRIVY_APP_SECRET are set +# [ ] VITE_PRIVY_APP_ID matches PRIVY_APP_ID +# +# 4. AI Services: +# [ ] At least one AI provider key is configured +# [ ] MESHY_API_KEY is set for 3D generation +# +# 5. URLs: +# [ ] FRONTEND_URL is the production frontend domain +# [ ] IMAGE_SERVER_URL is accessible by Meshy for callbacks +# [ ] CDN_URL is configured if using CDN +# +# 6. Monitoring: +# [ ] LOG_LEVEL=info or LOG_LEVEL=warn for production +# [ ] Error tracking service configured (Sentry, etc.) +# +# ============================================================================== diff --git a/apps/core/__tests__/helpers/api.ts b/apps/core/__tests__/helpers/api.ts index 76be9d2..49741ea 100644 --- a/apps/core/__tests__/helpers/api.ts +++ b/apps/core/__tests__/helpers/api.ts @@ -1,15 +1,78 @@ /** * API Test Helper * November 2025 Best Practices (Elysia): - * - Use app.handle() pattern instead of spinning up servers - * - Request builder utilities - * - Response assertion helpers + * + * RECOMMENDED: Use Eden Treaty for type-safe testing + * - Pass Elysia instance directly to treaty() - zero network overhead + * - Full TypeScript autocomplete for routes + * - Compile-time type checking for request/response + * + * ALTERNATIVE: Use app.handle() for low-level testing + * - Manual Request construction + * - Useful when you need fine-grained control + * + * @see https://elysiajs.com/patterns/unit-test + * @see https://elysiajs.com/eden/treaty/unit-test */ -import type { Elysia } from "elysia"; +import { Elysia } from "elysia"; +import { treaty } from "@elysiajs/eden"; import type { AuthUser } from "../../server/middleware/auth"; import { createAuthHeader } from "./auth"; +/** + * Type alias for any Elysia instance + * Using Elysia base type allows TypeScript to infer specific type parameters + * from the actual app instance passed to the functions below. + */ +type AnyElysiaApp = Elysia; + +/** + * Create a type-safe Eden Treaty client from an Elysia instance + * + * This is the RECOMMENDED approach for testing per Elysia best practices. + * Eden Treaty provides: + * - Full type safety with autocomplete + * - Zero network overhead (calls app.handle() internally) + * - Compile-time validation of requests/responses + * + * @example + * ```typescript + * const app = new Elysia().use(assetRoutes); + * const api = createTestClient(app); + * + * // Type-safe requests with autocomplete + * const { data, error } = await api.api.assets.get(); + * const { data: asset } = await api.api.assets({ id: 'test' }).get(); + * ``` + */ +export function createTestClient(app: T) { + return treaty(app); +} + +/** + * Create a type-safe Eden Treaty client with auth headers + * + * @example + * ```typescript + * const api = createAuthTestClient(app, testUser.authUser); + * const { data } = await api.api.assets.get(); // Authenticated request + * ``` + */ +export function createAuthTestClient( + app: T, + user: AuthUser, +) { + return treaty(app, { + headers: { + Authorization: createAuthHeader( + user.privyUserId, + user.email || undefined, + ), + }, + }); +} + /** * Helper function to create a Request object * Based on Elysia test best practices @@ -36,7 +99,11 @@ export function get(path: string, headers?: HeadersInit): Request { /** * Create a POST request with JSON body */ -export function post(path: string, body: any, headers?: HeadersInit): Request { +export function post( + path: string, + body: unknown, + headers?: HeadersInit, +): Request { return req(path, { method: "POST", headers: { @@ -50,7 +117,11 @@ export function post(path: string, body: any, headers?: HeadersInit): Request { /** * Create a PATCH request with JSON body */ -export function patch(path: string, body: any, headers?: HeadersInit): Request { +export function patch( + path: string, + body: unknown, + headers?: HeadersInit, +): Request { return req(path, { method: "PATCH", headers: { @@ -89,7 +160,7 @@ export function authGet(path: string, user: AuthUser): Request { /** * Create an authenticated POST request */ -export function authPost(path: string, body: any, user: AuthUser): Request { +export function authPost(path: string, body: unknown, user: AuthUser): Request { return req(path, { method: "POST", headers: { @@ -106,7 +177,11 @@ export function authPost(path: string, body: any, user: AuthUser): Request { /** * Create an authenticated PATCH request */ -export function authPatch(path: string, body: any, user: AuthUser): Request { +export function authPatch( + path: string, + body: unknown, + user: AuthUser, +): Request { return req(path, { method: "PATCH", headers: { @@ -149,7 +224,7 @@ export async function testRoute( /** * Test a route and parse JSON response */ -export async function testRouteJSON( +export async function testRouteJSON( app: Elysia, request: Request, ): Promise<{ response: Response; data: T }> { @@ -200,7 +275,7 @@ export function assertHeader( /** * Extract JSON from response with error handling */ -export async function extractJSON(response: Response): Promise { +export async function extractJSON(response: Response): Promise { try { return await response.json(); } catch (error) { diff --git a/apps/core/__tests__/helpers/auth.ts b/apps/core/__tests__/helpers/auth.ts index 7ba1f52..bf4582a 100644 --- a/apps/core/__tests__/helpers/auth.ts +++ b/apps/core/__tests__/helpers/auth.ts @@ -1,16 +1,35 @@ /** * Authentication Test Helper * November 2025 Best Practices: - * - Mock JWT tokens for testing + * - Properly signed JWT tokens for testing * - Privy authentication helpers * - Test authentication contexts */ +import { createHmac } from "crypto"; import type { AuthUser } from "../../server/middleware/auth"; /** - * Generate a mock JWT token for testing - * Note: This is NOT cryptographically valid, just for testing + * Get test JWT secret - MUST match TEST_JWT_SECRET env var in tests + * Set TEST_JWT_SECRET=test-secret-for-jwt-signing in test environment + * + * This function throws if TEST_JWT_SECRET is not set, matching the behavior + * in auth.plugin.ts to ensure consistent security handling. + */ +function getTestJwtSecret(): string { + const secret = process.env.TEST_JWT_SECRET; + if (!secret) { + throw new Error( + "TEST_JWT_SECRET environment variable is required for testing. " + + "Set TEST_JWT_SECRET=test-secret-for-jwt-signing in your test environment.", + ); + } + return secret; +} + +/** + * Generate a properly signed JWT token for testing + * Uses HMAC-SHA256 with TEST_JWT_SECRET for signature verification */ export function createMockJWT(payload: { sub: string; @@ -20,15 +39,20 @@ export function createMockJWT(payload: { }): string { const header = Buffer.from( JSON.stringify({ alg: "HS256", typ: "JWT" }), - ).toString("base64"); + ).toString("base64url"); const body = Buffer.from( JSON.stringify({ ...payload, iat: payload.iat || Math.floor(Date.now() / 1000), exp: payload.exp || Math.floor(Date.now() / 1000) + 3600, // 1 hour }), - ).toString("base64"); - const signature = "mock-signature"; + ).toString("base64url"); + + // Generate proper HMAC signature + const signatureInput = `${header}.${body}`; + const signature = createHmac("sha256", getTestJwtSecret()) + .update(signatureInput) + .digest("base64url"); return `${header}.${body}.${signature}`; } diff --git a/apps/core/__tests__/integration/api/routes/assets.test.ts b/apps/core/__tests__/integration/api/routes/assets.test.ts index 4654c10..61e34c6 100644 --- a/apps/core/__tests__/integration/api/routes/assets.test.ts +++ b/apps/core/__tests__/integration/api/routes/assets.test.ts @@ -1,6 +1,9 @@ /** * Asset Routes Tests * Tests for asset CRUD operations, file serving, and sprite management + * + * NOTE: Uses REAL database with test data isolation (no mocks for internal services) + * Per CLAUDE.md: "NO MOCKS for internal code (database, HTTP handlers, business logic)" */ import { describe, it, expect, beforeAll, afterEach, afterAll } from "bun:test"; @@ -14,72 +17,20 @@ import { cleanDatabase, } from "../../../helpers/db"; import { db } from "../../../../server/db"; -import { users } from "../../../../server/db/schema"; +import { users, assets } from "../../../../server/db/schema"; import { eq } from "drizzle-orm"; -import path from "path"; - -// Mock AssetService with CDN URLs -const mockAssets = [ - { - id: "test-asset-1", - name: "Test Sword", - type: "weapon", - tier: 1, - category: "melee", - cdnUrl: "https://cdn.asset-forge.com/models/test-asset-1/model.glb", - cdnThumbnailUrl: - "https://cdn.asset-forge.com/models/test-asset-1/thumbnail.png", - hasSpriteSheet: false, - createdBy: "user-123", - walletAddress: "0xABC", - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: "test-asset-2", - name: "Admin Asset", - type: "armor", - tier: 2, - category: "heavy", - cdnUrl: "https://cdn.asset-forge.com/models/test-asset-2/model.glb", - createdBy: "admin-456", - isPublic: true, - createdAt: new Date().toISOString(), - }, -]; - -const mockAssetService = { - listAssets: async () => { - return mockAssets; - }, - getModelPath: async (id: string) => { - // Return mock path - return path.join("/tmp", "gdd-assets", id, "model.glb"); - }, - deleteAsset: async ( - id: string, - _includeVariants: boolean, - _userId?: string, - ) => { - // Mock deletion - const index = mockAssets.findIndex((a) => a.id === id); - if (index !== -1) { - mockAssets.splice(index, 1); - } - }, - updateAsset: async (id: string, updates: any, _userId?: string) => { - const asset = mockAssets.find((a) => a.id === id); - if (!asset) return null; - Object.assign(asset, updates, { updatedAt: new Date().toISOString() }); - return asset; - }, -}; describe("Asset Routes", () => { let app: Elysia; const testRootDir = "/tmp/asset-forge-test"; + // Store test data for cleanup and reference + let testUser1: Awaited>; + let testAdmin: Awaited>; + let testUser2: Awaited>; + let testAsset1: Awaited>; + let testAsset2: Awaited>; + beforeAll(async () => { // Clean database before creating test users await cleanDatabase(); @@ -94,29 +45,54 @@ describe("Asset Routes", () => { } // Create test users to match the Privy user IDs used in auth headers - await createTestUser({ + testUser1 = await createTestUser({ privyUserId: "user-123", email: "user-123@test.com", displayName: "Test User 123", + walletAddress: "0xABC", role: "member", }); - await createTestAdmin({ + testAdmin = await createTestAdmin({ privyUserId: "admin-456", email: "admin-456@test.com", displayName: "Admin User 456", }); - await createTestUser({ + testUser2 = await createTestUser({ privyUserId: "other-user", email: "other-user@test.com", displayName: "Other User", role: "member", }); - app = new Elysia().use( - createAssetRoutes(testRootDir, mockAssetService as any), - ); + // Create test assets in the REAL database + testAsset1 = await createTestAsset(testUser1.user.id, { + id: "test-asset-1", + name: "Test Sword", + type: "weapon", + category: "melee", + filePath: "test-asset-1/model.glb", + status: "completed", + visibility: "public", + cdnUrl: "https://cdn.asset-forge.com/models/test-asset-1/model.glb", + cdnThumbnailUrl: + "https://cdn.asset-forge.com/models/test-asset-1/thumbnail.png", + }); + + testAsset2 = await createTestAsset(testAdmin.user.id, { + id: "test-asset-2", + name: "Admin Asset", + type: "armor", + category: "heavy", + filePath: "test-asset-2/model.glb", + status: "completed", + visibility: "public", + cdnUrl: "https://cdn.asset-forge.com/models/test-asset-2/model.glb", + }); + + // Use real routes with real database service - NO MOCK INJECTION + app = new Elysia().use(createAssetRoutes(testRootDir)); }); afterAll(async () => { @@ -125,37 +101,42 @@ describe("Asset Routes", () => { }); afterEach(async () => { - // Reset mock assets with CDN URLs - mockAssets.length = 0; - mockAssets.push( - { + // Re-create test assets if they were deleted during tests + // Check if assets still exist + const asset1Exists = await db.query.assets.findFirst({ + where: eq(assets.id, "test-asset-1"), + }); + const asset2Exists = await db.query.assets.findFirst({ + where: eq(assets.id, "test-asset-2"), + }); + + if (!asset1Exists) { + testAsset1 = await createTestAsset(testUser1.user.id, { id: "test-asset-1", name: "Test Sword", type: "weapon", - tier: 1, category: "melee", + filePath: "test-asset-1/model.glb", + status: "completed", + visibility: "public", cdnUrl: "https://cdn.asset-forge.com/models/test-asset-1/model.glb", cdnThumbnailUrl: "https://cdn.asset-forge.com/models/test-asset-1/thumbnail.png", - hasSpriteSheet: false, - createdBy: "user-123", - walletAddress: "0xABC", - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { + }); + } + + if (!asset2Exists) { + testAsset2 = await createTestAsset(testAdmin.user.id, { id: "test-asset-2", name: "Admin Asset", type: "armor", - tier: 2, category: "heavy", + filePath: "test-asset-2/model.glb", + status: "completed", + visibility: "public", cdnUrl: "https://cdn.asset-forge.com/models/test-asset-2/model.glb", - createdBy: "admin-456", - isPublic: true, - createdAt: new Date().toISOString(), - }, - ); + }); + } }); describe("GET /api/assets", () => { @@ -206,7 +187,8 @@ describe("Asset Routes", () => { }); it("should return empty array when no assets exist", async () => { - mockAssets.length = 0; + // Delete all test assets from real database + await db.delete(assets).execute(); const response = await app.handle( new Request("http://localhost/api/assets"), @@ -377,12 +359,9 @@ describe("Asset Routes", () => { }); it("should return 404 for non-existent asset", async () => { - // Modify mock to return null - const originalUpdate = mockAssetService.updateAsset; - mockAssetService.updateAsset = async () => null; - + // With real database, non-existent asset returns 404 naturally const response = await app.handle( - new Request("http://localhost/api/assets/non-existent", { + new Request("http://localhost/api/assets/non-existent-asset-xyz", { method: "PATCH", headers: { "Content-Type": "application/json", @@ -395,9 +374,6 @@ describe("Asset Routes", () => { ); expect(response.status).toBe(404); - - // Restore - mockAssetService.updateAsset = originalUpdate; }); it("should update individual fields independently", async () => { @@ -655,15 +631,16 @@ describe("Asset Routes", () => { }); }); - describe("CDN URL Merging", () => { - it("should include CDN URLs when asset is published to CDN", async () => { + describe("CDN URL Handling", () => { + it("should include CDN URLs when asset has them in database", async () => { // Create database asset record with CDN URLs const { user } = await createTestUser({ privyUserId: "cdn-user", email: "cdn-user@test.com", }); - await createTestAsset(user.id, { + const cdnAsset = await createTestAsset(user.id, { + id: "cdn-asset-test", name: "CDN Asset", type: "weapon", category: "melee", @@ -682,20 +659,6 @@ describe("Asset Routes", () => { ], }); - // Add filesystem mock asset (without CDN fields) - mockAssets.push({ - id: "cdn-asset", - name: "CDN Asset", - type: "weapon", - tier: 1, - category: "melee", - modelUrl: "/gdd-assets/cdn-asset/model.glb", - createdBy: user.privyUserId, - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - const response = await app.handle( new Request("http://localhost/api/assets"), ); @@ -704,9 +667,9 @@ describe("Asset Routes", () => { const data = await response.json(); // Find our CDN asset - const returnedAsset = data.find((a: any) => a.id === "cdn-asset"); + const returnedAsset = data.find((a: any) => a.id === cdnAsset.id); - // Should include CDN URL fields merged from database + // Should include CDN URL fields from database expect(returnedAsset).toBeDefined(); expect(returnedAsset.cdnUrl).toBe( "https://cdn.example.com/models/cdn-asset/model.glb", @@ -717,38 +680,24 @@ describe("Asset Routes", () => { expect(returnedAsset.cdnConceptArtUrl).toBe( "https://cdn.example.com/models/cdn-asset/concept-art.png", ); - expect(Array.isArray(returnedAsset.cdnFiles)).toBe(true); - expect(returnedAsset.cdnFiles.length).toBe(3); }); - it("should not include CDN URL when asset not on CDN", async () => { + it("should return null cdnUrl when asset has no CDN URL", async () => { // Create database asset without CDN fields const { user } = await createTestUser({ privyUserId: "local-user", email: "local-user@test.com", }); - await createTestAsset(user.id, { + const localAsset = await createTestAsset(user.id, { + id: "local-asset-test", name: "Local Asset", type: "weapon", category: "melee", filePath: "local-asset/local-asset.glb", status: "completed", visibility: "public", - }); - - // Add filesystem mock asset - mockAssets.push({ - id: "local-asset", - name: "Local Asset", - type: "weapon", - tier: 1, - category: "melee", - modelUrl: "/gdd-assets/local-asset/model.glb", - createdBy: user.privyUserId, - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + // No cdnUrl set }); const response = await app.handle( @@ -758,13 +707,14 @@ describe("Asset Routes", () => { expect(response.status).toBe(200); const data = await response.json(); - const returnedAsset = data.find((a: any) => a.id === "local-asset"); + const returnedAsset = data.find((a: any) => a.id === localAsset.id); expect(returnedAsset).toBeDefined(); - expect(returnedAsset.cdnUrl).toBeUndefined(); + // cdnUrl should be null or undefined when not set + expect(returnedAsset.cdnUrl).toBeFalsy(); }); - it("should handle mixed CDN and local assets", async () => { + it("should handle mixed CDN and non-CDN assets", async () => { // Create user const { user } = await createTestUser({ privyUserId: "mixed-user", @@ -772,7 +722,8 @@ describe("Asset Routes", () => { }); // Create CDN asset in database - await createTestAsset(user.id, { + const cdnAsset = await createTestAsset(user.id, { + id: "mixed-cdn-test", name: "CDN Asset", type: "weapon", filePath: "mixed-cdn/mixed-cdn.glb", @@ -781,8 +732,9 @@ describe("Asset Routes", () => { cdnUrl: "https://cdn.example.com/models/mixed-cdn/model.glb", }); - // Create local asset in database - await createTestAsset(user.id, { + // Create local asset in database (no CDN URL) + const localAsset = await createTestAsset(user.id, { + id: "mixed-local-test", name: "Local Asset", type: "armor", filePath: "mixed-local/mixed-local.glb", @@ -790,28 +742,6 @@ describe("Asset Routes", () => { visibility: "public", }); - // Add filesystem mock assets - mockAssets.push( - { - id: "mixed-cdn", - name: "CDN Asset", - type: "weapon", - modelUrl: "/gdd-assets/mixed-cdn/model.glb", - createdBy: user.privyUserId, - isPublic: true, - createdAt: new Date().toISOString(), - }, - { - id: "mixed-local", - name: "Local Asset", - type: "armor", - modelUrl: "/gdd-assets/mixed-local/model.glb", - createdBy: user.privyUserId, - isPublic: true, - createdAt: new Date().toISOString(), - }, - ); - const response = await app.handle( new Request("http://localhost/api/assets"), ); @@ -820,23 +750,26 @@ describe("Asset Routes", () => { const data = await response.json(); // Should have both types - const cdnReturned = data.find((a: any) => a.id === "mixed-cdn"); - const localReturned = data.find((a: any) => a.id === "mixed-local"); + const cdnReturned = data.find((a: any) => a.id === cdnAsset.id); + const localReturned = data.find((a: any) => a.id === localAsset.id); + expect(cdnReturned).toBeDefined(); expect(cdnReturned.cdnUrl).toBeDefined(); - expect(localReturned.cdnUrl).toBeUndefined(); + expect(localReturned).toBeDefined(); + expect(localReturned.cdnUrl).toBeFalsy(); }); - it("should preserve all original asset fields when merging CDN URLs", async () => { + it("should preserve all asset fields from database", async () => { // Create user const { user } = await createTestUser({ privyUserId: "full-user", email: "full-user@test.com", - walletAddress: "0xABC", + walletAddress: "0xFullUser", }); - // Create database asset with CDN fields - await createTestAsset(user.id, { + // Create database asset with all fields + const fullAsset = await createTestAsset(user.id, { + id: "full-asset-test", name: "Full Asset", description: "Test description", type: "weapon", @@ -848,25 +781,6 @@ describe("Asset Routes", () => { cdnUrl: "https://cdn.example.com/models/full-asset/model.glb", }); - // Add filesystem mock asset with all fields - mockAssets.push({ - id: "full-asset", - name: "Full Asset", - description: "Test description", - type: "weapon", - subtype: "sword", - tier: 3, - category: "melee", - modelUrl: "/gdd-assets/full-asset/model.glb", - thumbnailUrl: "/gdd-assets/full-asset/thumbnail.png", - hasSpriteSheet: true, - createdBy: user.privyUserId, - walletAddress: "0xABC", - isPublic: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - const response = await app.handle( new Request("http://localhost/api/assets"), ); @@ -874,16 +788,12 @@ describe("Asset Routes", () => { expect(response.status).toBe(200); const data = await response.json(); - const returnedAsset = data.find((a: any) => a.id === "full-asset"); + const returnedAsset = data.find((a: any) => a.id === fullAsset.id); - // Should have all original fields + // Should have all fields from database + expect(returnedAsset).toBeDefined(); expect(returnedAsset.name).toBe("Full Asset"); - expect(returnedAsset.description).toBe("Test description"); expect(returnedAsset.type).toBe("weapon"); - expect(returnedAsset.tier).toBe(3); - expect(returnedAsset.modelUrl).toBe("/gdd-assets/full-asset/model.glb"); - - // Plus CDN fields merged from database expect(returnedAsset.cdnUrl).toBe( "https://cdn.example.com/models/full-asset/model.glb", ); diff --git a/apps/core/server/api-elysia.ts b/apps/core/server/api-elysia.ts index 41f218f..122e944 100644 --- a/apps/core/server/api-elysia.ts +++ b/apps/core/server/api-elysia.ts @@ -39,6 +39,7 @@ import { fastHealthPlugin } from "./plugins/fast-health.plugin"; import { modelsPlugin } from "./plugins/models.plugin"; import { rateLimitingPlugin } from "./plugins/rate-limiting.plugin"; import { authPlugin } from "./plugins/auth.plugin"; +import { csrfPlugin } from "./plugins/csrf.plugin"; import { staticFilesPlugin } from "./plugins/static-files.plugin"; import { standaloneApiRoutes } from "./plugins/api.plugin"; import { metricsPlugin } from "./plugins/metrics.plugin"; @@ -251,7 +252,41 @@ const app = new Elysia() .use(securityHeaders) .use( cors({ - origin: env.NODE_ENV === "production" ? env.FRONTEND_URL || "*" : true, + origin: (request) => { + // Build allowed origins list + const allowedOrigins: string[] = []; + + // Add FRONTEND_URL if configured (required in production) + if (env.FRONTEND_URL) { + allowedOrigins.push(env.FRONTEND_URL); + } + + // Add any additional CORS_ALLOWED_ORIGINS + if (env.CORS_ALLOWED_ORIGINS && env.CORS_ALLOWED_ORIGINS.length > 0) { + allowedOrigins.push(...env.CORS_ALLOWED_ORIGINS); + } + + // In development, allow localhost origins + if (env.NODE_ENV !== "production") { + allowedOrigins.push( + "http://localhost:3000", + "http://localhost:3004", + "http://127.0.0.1:3000", + "http://127.0.0.1:3004", + ); + } + + // If no origins configured in production, reject (don't fall back to wildcard) + if (allowedOrigins.length === 0) { + logger.warn( + { context: "cors" }, + "No CORS origins configured - rejecting cross-origin requests", + ); + return false; + } + + return allowedOrigins; + }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], }), @@ -265,9 +300,10 @@ const app = new Elysia() // Cache before logging to skip logs for cache hits (20% faster for cached responses) .use(cachingPlugin) - // ==================== AUTH & RATE LIMITING ==================== + // ==================== AUTH, CSRF & RATE LIMITING ==================== .use(rateLimitingPlugin) .use(authPlugin) + .use(csrfPlugin) // ==================== ROUTES (ORDERED BY FREQUENCY) ==================== .use(healthRoutes) // Most frequent (K8s probes, monitoring) @@ -284,15 +320,21 @@ const app = new Elysia() .use(createGenerationRoutes(getGenerationService)) .use(createCDNRoutes(ROOT_DIR, CDN_URL)) - .use(metricsPlugin) // Prometheus scraping + .use(metricsPlugin); // Prometheus scraping - // ==================== DEBUG ENDPOINTS (TEMPORARY) ==================== - .use( +// ==================== DEBUG ENDPOINTS (DEV ONLY) ==================== +// Only register debug plugin in non-production environments +if (env.NODE_ENV !== "production") { + app.use( createDebugPlugin({ rootDir: ROOT_DIR, apiPort: Number(API_PORT), }), - ) + ); +} + +// Continue the chain +app // ==================== ERROR HANDLING & LOGGING ==================== // After routes to catch errors and log responses diff --git a/apps/core/server/db/migrations/0032_fix_schema_drift.sql b/apps/core/server/db/migrations/0032_fix_schema_drift.sql new file mode 100644 index 0000000..4c11feb --- /dev/null +++ b/apps/core/server/db/migrations/0032_fix_schema_drift.sql @@ -0,0 +1,12 @@ +-- Fix Schema Drift Migration +-- Drop unused admin_whitelist table that exists in DB but not in TypeScript schemas +-- This table was created in migration 0000 but was never needed (single-team app) + +-- Drop the foreign key constraint first +ALTER TABLE "admin_whitelist" DROP CONSTRAINT IF EXISTS "admin_whitelist_added_by_users_id_fk"; + +-- Drop the index +DROP INDEX IF EXISTS "idx_admin_whitelist_wallet"; + +-- Drop the table +DROP TABLE IF EXISTS "admin_whitelist"; diff --git a/apps/core/server/db/migrations/0033_add_token_blocklist.sql b/apps/core/server/db/migrations/0033_add_token_blocklist.sql new file mode 100644 index 0000000..df287c0 --- /dev/null +++ b/apps/core/server/db/migrations/0033_add_token_blocklist.sql @@ -0,0 +1,16 @@ +-- Token Blocklist Migration +-- Creates table for JWT invalidation (logout, token revocation) + +CREATE TABLE "token_blocklist" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "token_id" varchar(255) NOT NULL, + "user_id" uuid, + "reason" varchar(100), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + CONSTRAINT "token_blocklist_token_id_unique" UNIQUE("token_id") +); +--> statement-breakpoint +CREATE INDEX "idx_token_blocklist_token_id" ON "token_blocklist" USING btree ("token_id"); +--> statement-breakpoint +CREATE INDEX "idx_token_blocklist_expires_at" ON "token_blocklist" USING btree ("expires_at"); diff --git a/apps/core/server/db/migrations/meta/_journal.json b/apps/core/server/db/migrations/meta/_journal.json index 1277db5..30e8e21 100644 --- a/apps/core/server/db/migrations/meta/_journal.json +++ b/apps/core/server/db/migrations/meta/_journal.json @@ -225,6 +225,20 @@ "when": 1763534616494, "tag": "0031_stormy_black_panther", "breakpoints": true + }, + { + "idx": 32, + "version": "7", + "when": 1763700000000, + "tag": "0032_fix_schema_drift", + "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1763700100000, + "tag": "0033_add_token_blocklist", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/core/server/db/schema/index.ts b/apps/core/server/db/schema/index.ts index 7e43dda..bffe308 100644 --- a/apps/core/server/db/schema/index.ts +++ b/apps/core/server/db/schema/index.ts @@ -42,6 +42,9 @@ export * from "./material-presets.schema"; // Static Assets export * from "./static-assets.schema"; +// Token Blocklist (JWT invalidation) +export * from "./token-blocklist.schema"; + // Re-export everything for drizzle import * as usersSchema from "./users.schema"; import * as assetsSchema from "./assets.schema"; @@ -56,6 +59,7 @@ import * as apiKeysSchema from "./api-keys.schema"; import * as promptsSchema from "./prompts.schema"; import * as materialPresetsSchema from "./material-presets.schema"; import * as staticAssetsSchema from "./static-assets.schema"; +import * as tokenBlocklistSchema from "./token-blocklist.schema"; export const schema = { ...usersSchema, @@ -71,4 +75,5 @@ export const schema = { ...promptsSchema, ...materialPresetsSchema, ...staticAssetsSchema, + ...tokenBlocklistSchema, }; diff --git a/apps/core/server/db/schema/token-blocklist.schema.ts b/apps/core/server/db/schema/token-blocklist.schema.ts new file mode 100644 index 0000000..2fd084e --- /dev/null +++ b/apps/core/server/db/schema/token-blocklist.schema.ts @@ -0,0 +1,39 @@ +/** + * Token Blocklist Schema + * Simple JWT invalidation via blocklist + * Tokens are added when users log out or when tokens are revoked + */ + +import { pgTable, uuid, varchar, timestamp, index } from "drizzle-orm/pg-core"; + +/** + * Token Blocklist Table + * Stores invalidated JWT token identifiers (jti claims) + * Tokens are auto-cleaned after expiration + */ +export const tokenBlocklist = pgTable( + "token_blocklist", + { + id: uuid("id").defaultRandom().primaryKey(), + // Token identifier (jti claim from JWT or hash of token) + tokenId: varchar("token_id", { length: 255 }).notNull().unique(), + // User who owned this token (for audit) + userId: uuid("user_id"), + // Reason for blocklisting + reason: varchar("reason", { length: 100 }), + // When the token was blocklisted + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + // When the original token expires (for cleanup) + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + }, + (table) => [ + // Note: tokenId already has a unique constraint which creates an index automatically + // Only add index for expiresAt which is used for cleanup queries + index("idx_token_blocklist_expires_at").on(table.expiresAt), + ], +); + +export type TokenBlocklistEntry = typeof tokenBlocklist.$inferSelect; +export type NewTokenBlocklistEntry = typeof tokenBlocklist.$inferInsert; diff --git a/apps/core/server/models.ts b/apps/core/server/models.ts index e5d3e06..69a8709 100644 --- a/apps/core/server/models.ts +++ b/apps/core/server/models.ts @@ -82,20 +82,20 @@ export const AssetDimensions = t.Object({ }); export const AssetMetadata = t.Object({ - id: t.Optional(t.String()), - name: t.Optional(t.String()), - description: t.Optional(t.String()), - type: t.Optional(t.String()), - subtype: t.Optional(t.String()), + id: t.Optional(t.String({ maxLength: 255 })), + name: t.Optional(t.String({ maxLength: 255 })), + description: t.Optional(t.String({ maxLength: 5000 })), + type: t.Optional(t.String({ maxLength: 100 })), + subtype: t.Optional(t.String({ maxLength: 100 })), tier: t.Optional(t.Union([t.String(), t.Number()])), - category: t.Optional(t.String()), - thumbnailUrl: t.Optional(t.String()), + category: t.Optional(t.String({ maxLength: 100 })), + thumbnailUrl: t.Optional(t.String({ maxLength: 2000 })), hasSpriteSheet: t.Optional(t.Boolean()), spriteCount: t.Optional(t.Number()), // Generation metadata - detailedPrompt: t.Optional(t.String()), - workflow: t.Optional(t.String()), - meshyTaskId: t.Optional(t.String()), + detailedPrompt: t.Optional(t.String({ maxLength: 10000 })), + workflow: t.Optional(t.String({ maxLength: 100 })), + meshyTaskId: t.Optional(t.String({ maxLength: 255 })), generatedAt: t.Optional(t.String()), generationMethod: t.Optional( t.Union([ @@ -105,21 +105,21 @@ export const AssetMetadata = t.Object({ t.Literal("placeholder"), ]), ), - quality: t.Optional(t.String()), + quality: t.Optional(t.String({ maxLength: 50 })), // File status hasConceptArt: t.Optional(t.Boolean()), hasModel: t.Optional(t.Boolean()), // Variant system isBaseModel: t.Optional(t.Boolean()), isVariant: t.Optional(t.Boolean()), - parentBaseModel: t.Optional(t.String()), - variants: t.Optional(t.Array(t.String())), + parentBaseModel: t.Optional(t.String({ maxLength: 255 })), + variants: t.Optional(t.Array(t.String({ maxLength: 255 }))), variantCount: t.Optional(t.Number()), lastVariantGenerated: t.Optional(t.String()), // Material info for variants materialPreset: t.Optional(MaterialPresetInfo), - baseMaterial: t.Optional(t.String()), - retextureTaskId: t.Optional(t.String()), + baseMaterial: t.Optional(t.String({ maxLength: 100 })), + retextureTaskId: t.Optional(t.String({ maxLength: 255 })), retextureMethod: t.Optional( t.Union([ t.Literal("meshy-retexture"), @@ -135,21 +135,21 @@ export const AssetMetadata = t.Object({ t.Literal("failed"), ]), ), - retextureError: t.Optional(t.String()), - baseModelTaskId: t.Optional(t.String()), + retextureError: t.Optional(t.String({ maxLength: 5000 })), + baseModelTaskId: t.Optional(t.String({ maxLength: 255 })), // Other fields - gameId: t.Optional(t.String()), + gameId: t.Optional(t.String({ maxLength: 255 })), gddCompliant: t.Optional(t.Boolean()), isPlaceholder: t.Optional(t.Boolean()), normalized: t.Optional(t.Boolean()), normalizationDate: t.Optional(t.String()), dimensions: t.Optional(AssetDimensions), - format: t.Optional(t.String()), + format: t.Optional(t.String({ maxLength: 50 })), gripDetected: t.Optional(t.Boolean()), requiresAnimationStrip: t.Optional(t.Boolean()), // Ownership tracking (Phase 1) - createdBy: t.Optional(t.String()), // User ID - walletAddress: t.Optional(t.String()), // User's wallet address + createdBy: t.Optional(t.String({ maxLength: 255 })), // User ID + walletAddress: t.Optional(t.String({ maxLength: 255 })), // User's wallet address isPublic: t.Optional(t.Boolean()), // Default true createdAt: t.Optional(t.String()), updatedAt: t.Optional(t.String()), diff --git a/apps/core/server/plugins/auth.plugin.ts b/apps/core/server/plugins/auth.plugin.ts index dbe3905..ae9bea8 100644 --- a/apps/core/server/plugins/auth.plugin.ts +++ b/apps/core/server/plugins/auth.plugin.ts @@ -33,6 +33,7 @@ import { UnauthorizedError, ForbiddenError } from "../errors"; import type { AuthUser } from "../types/auth"; import { userService } from "../services/UserService"; import { ApiKeyService } from "../services/ApiKeyService"; +import { tokenBlocklistService } from "../services/TokenBlocklistService"; import { logger } from "../utils/logger"; import { env } from "../config/env"; @@ -69,7 +70,28 @@ export async function optionalAuth({ const token = authHeader.replace("Bearer ", ""); - // NEW: Check if this is an API key (starts with "af_") + // Check token blocklist for JWT tokens (not API keys) + if (!token.startsWith("af_")) { + try { + const isBlocked = await tokenBlocklistService.isTokenBlocklisted(token); + if (isBlocked) { + logger.info( + { context: "auth" }, + "Token is blocklisted (revoked/logged out)", + ); + return {}; + } + } catch (error) { + // Fail-closed: If blocklist check fails, reject the token for security. + logger.error( + { err: error, context: "auth" }, + "Failed to check token blocklist. Rejecting token as a security precaution.", + ); + return {}; + } + } + + // Check if this is an API key (starts with "af_") if (token.startsWith("af_")) { const apiKeyService = new ApiKeyService(); const result = await apiKeyService.validateApiKey(token); @@ -136,24 +158,52 @@ export async function optionalAuth({ let privyUserId: string; - // In test mode, decode JWT without verifying signature + // In test mode, use TEST_JWT_SECRET for verification instead of Privy + // This allows tests to create valid tokens without external Privy calls if (env.NODE_ENV === "test") { try { - // Decode JWT payload (part between first and second dot) + // Require TEST_JWT_SECRET in test mode - prevents accidental production bypass + const testSecret = process.env.TEST_JWT_SECRET; + if (!testSecret) { + logger.error( + { context: "auth" }, + "TEST MODE - TEST_JWT_SECRET not configured, rejecting token", + ); + return {}; + } + + // Simple HMAC verification for test tokens const parts = token.split("."); if (parts.length !== 3) { throw new Error("Invalid JWT format"); } + + // Verify signature using TEST_JWT_SECRET + const crypto = await import("crypto"); + const signatureInput = `${parts[0]}.${parts[1]}`; + const expectedSignature = crypto + .createHmac("sha256", testSecret) + .update(signatureInput) + .digest("base64url"); + + if (parts[2] !== expectedSignature) { + logger.warn( + { context: "auth" }, + "TEST MODE - Invalid token signature", + ); + return {}; + } + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()); privyUserId = payload.sub; logger.info( { privyUserId, context: "auth" }, - "TEST MODE - Decoded token for userId", + "TEST MODE - Verified token for userId", ); } catch (error) { logger.error( { err: error, context: "auth" }, - "TEST MODE - Failed to decode token", + "TEST MODE - Failed to verify token", ); return {}; } @@ -390,19 +440,15 @@ export const requireAuthGuard = new Elysia({ }); /** - * Require Admin Guard (DEPRECATED - Single-Team App) - * - * @deprecated This is a single-team app with no role-based access control. - * This guard now behaves identically to requireAuthGuard - it only checks - * authentication, not roles. All authenticated users have full access. + * Require Admin Guard * - * For backwards compatibility, this is kept as an alias to requireAuthGuard. - * Use authPlugin instead for optional auth (recommended for single-team). + * Ensures user is authenticated AND has admin role. + * Use this for admin-only endpoints (user management, system settings, etc.) * - * Injects: { user: AuthUser } (guaranteed to exist) + * Injects: { user: AuthUser } (guaranteed to exist and be admin) */ export const requireAdminGuard = new Elysia({ - name: "require-admin-guard-deprecated", + name: "require-admin-guard", }).derive({ as: "scoped" }, async (context) => { const result = await requireAuth(context); @@ -411,13 +457,25 @@ export const requireAdminGuard = new Elysia({ throw new UnauthorizedError("Authentication required"); } - // SINGLE-TEAM: No role check - all authenticated users have access + // Check admin role + if (result.user.role !== "admin") { + logger.warn( + { + userId: result.user.id, + role: result.user.role, + context: "auth", + }, + "Admin access denied - user is not admin", + ); + throw new ForbiddenError("Admin access required"); + } + logger.info( { userId: result.user.id, context: "auth", }, - "Admin guard (deprecated): Auth only, no role check in single-team app", + "Admin access granted", ); return { user: result.user } as { user: AuthUser }; diff --git a/apps/core/server/plugins/cron.plugin.ts b/apps/core/server/plugins/cron.plugin.ts index d454a3c..ed9476a 100644 --- a/apps/core/server/plugins/cron.plugin.ts +++ b/apps/core/server/plugins/cron.plugin.ts @@ -6,6 +6,8 @@ * - cleanup-expired-jobs: Cleanup expired and old failed generation jobs (hourly) * - aggregate-errors: Aggregate API errors for analytics (hourly at :05) * - cleanup-old-errors: Delete old error logs and aggregations (daily at 2 AM) + * - cleanup-old-activity: Delete old activity logs (daily at 2:30 AM) + * - cleanup-token-blocklist: Remove expired tokens from blocklist (daily at 2:45 AM) * - cleanup-disk-space: Clean up temporary files and caches (daily at 3 AM) * * Uses @elysiajs/cron for scheduling @@ -15,6 +17,7 @@ import { Elysia } from "elysia"; import { cron } from "@elysiajs/cron"; import { logger } from "../utils/logger"; import { generationPipelineService } from "../services/GenerationPipelineService"; +import { tokenBlocklistService } from "../services/TokenBlocklistService"; /** * Cron Jobs Plugin @@ -29,8 +32,10 @@ export const cronPlugin = new Elysia({ name: "cron" }) pattern: "0 * * * *", // Every hour async run() { logger.info({}, "[Cron] Running job cleanup..."); - const expiredCount = await generationPipelineService.cleanupExpiredJobs(); - const failedCount = await generationPipelineService.cleanupOldFailedJobs(); + const expiredCount = + await generationPipelineService.cleanupExpiredJobs(); + const failedCount = + await generationPipelineService.cleanupOldFailedJobs(); logger.info( { expiredCount, failedCount }, "Cleaned up expired and old failed jobs", @@ -91,6 +96,55 @@ export const cronPlugin = new Elysia({ name: "cron" }) }), ) + // Cleanup old activity logs daily at 2:30 AM (retain 90 days) + .use( + cron({ + name: "cleanup-old-activity", + pattern: "30 2 * * *", // Daily at 2:30 AM + async run() { + logger.info({}, "[Cron] Running activity log cleanup..."); + try { + const { db } = await import("../db/db"); + const { activityLog } = await import("../db/schema"); + const { lt } = await import("drizzle-orm"); + + // Delete logs older than 90 days + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 90); + + const result = await db + .delete(activityLog) + .where(lt(activityLog.createdAt, cutoffDate)); + + const deletedCount = result.rowCount || 0; + logger.info( + { deletedCount, cutoffDate: cutoffDate.toISOString() }, + "Activity log cleanup completed", + ); + } catch (error) { + logger.error({ err: error }, "Activity log cleanup failed"); + } + }, + }), + ) + + // Cleanup expired token blocklist entries daily at 2:45 AM + .use( + cron({ + name: "cleanup-token-blocklist", + pattern: "45 2 * * *", // Daily at 2:45 AM + async run() { + logger.info({}, "[Cron] Running token blocklist cleanup..."); + try { + const deletedCount = await tokenBlocklistService.cleanupExpired(); + logger.info({ deletedCount }, "Token blocklist cleanup completed"); + } catch (error) { + logger.error({ err: error }, "Token blocklist cleanup failed"); + } + }, + }), + ) + // Cleanup disk space daily at 3 AM .use( cron({ diff --git a/apps/core/server/plugins/csrf.plugin.ts b/apps/core/server/plugins/csrf.plugin.ts new file mode 100644 index 0000000..f4f89e6 --- /dev/null +++ b/apps/core/server/plugins/csrf.plugin.ts @@ -0,0 +1,150 @@ +/** + * CSRF Protection Plugin for Elysia + * + * Simple Origin-based CSRF protection: + * - Checks Origin/Referer header on state-changing requests (POST, PUT, DELETE, PATCH) + * - Allows requests from configured origins + * - Skips protection for API key authenticated requests (machine-to-machine) + */ + +import { Elysia } from "elysia"; +import { env } from "../config/env"; +import { logger } from "../utils/logger"; + +/** + * Get allowed origins for CSRF check + */ +function getAllowedOrigins(): string[] { + const origins: string[] = []; + + // Add FRONTEND_URL if configured + if (env.FRONTEND_URL) { + origins.push(env.FRONTEND_URL); + } + + // Add CORS_ALLOWED_ORIGINS if configured + if (env.CORS_ALLOWED_ORIGINS && env.CORS_ALLOWED_ORIGINS.length > 0) { + origins.push(...env.CORS_ALLOWED_ORIGINS); + } + + // In non-production environments (development, test), allow localhost + if (env.NODE_ENV !== "production") { + origins.push( + "http://localhost:3000", + "http://localhost:3004", + "http://127.0.0.1:3000", + "http://127.0.0.1:3004", + ); + } + + return origins; +} + +/** + * Check if request origin is allowed + */ +function isOriginAllowed( + origin: string | null, + allowedOrigins: string[], +): boolean { + if (!origin) return false; + + // Exact match + if (allowedOrigins.includes(origin)) return true; + + // Check if origin matches any allowed origin (handle trailing slashes) + const normalizedOrigin = origin.replace(/\/$/, ""); + return allowedOrigins.some( + (allowed) => allowed.replace(/\/$/, "") === normalizedOrigin, + ); +} + +/** + * CSRF Protection Plugin + * Validates Origin header on state-changing requests + */ +export const csrfPlugin = new Elysia({ name: "csrf" }).onBeforeHandle( + ({ request, headers }) => { + const method = request.method.toUpperCase(); + + // Only check state-changing methods + if (!["POST", "PUT", "DELETE", "PATCH"].includes(method)) { + return; + } + + // Skip CSRF check for API key authentication (machine-to-machine) + const authHeader = + headers?.authorization || request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer af_")) { + return; // API key requests don't need CSRF protection + } + + // Get origin from headers + const origin = headers?.origin || request.headers.get("origin"); + const referer = headers?.referer || request.headers.get("referer"); + + // Extract origin from referer if origin header is missing + let effectiveOrigin = origin; + if (!effectiveOrigin && referer) { + try { + const url = new URL(referer); + effectiveOrigin = url.origin; + } catch { + // Invalid referer URL + } + } + + // In production, reject state-changing requests without an origin header. + // This is a stricter CSRF check. + if (!effectiveOrigin) { + if (env.NODE_ENV === "production") { + logger.warn( + { + context: "csrf", + method, + path: new URL(request.url).pathname, + }, + "CSRF validation failed - missing Origin/Referer header in production", + ); + return new Response( + JSON.stringify({ + error: "CSRF_VALIDATION_FAILED", + message: "Missing Origin header", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + // In non-production, allow requests without an origin for easier testing. + return; + } + + // Check if origin is allowed + const allowedOrigins = getAllowedOrigins(); + if (!isOriginAllowed(effectiveOrigin, allowedOrigins)) { + logger.warn( + { + context: "csrf", + origin: effectiveOrigin, + allowedOrigins, + method, + path: new URL(request.url).pathname, + }, + "CSRF validation failed - origin not allowed", + ); + + return new Response( + JSON.stringify({ + error: "CSRF_VALIDATION_FAILED", + message: "Request origin not allowed", + }), + { + status: 403, + headers: { "Content-Type": "application/json" }, + }, + ); + } + }, +); diff --git a/apps/core/server/plugins/rate-limiting.plugin.ts b/apps/core/server/plugins/rate-limiting.plugin.ts index 3dcd7a2..66f91ab 100644 --- a/apps/core/server/plugins/rate-limiting.plugin.ts +++ b/apps/core/server/plugins/rate-limiting.plugin.ts @@ -2,15 +2,11 @@ * Rate Limiting Plugin for Elysia * Consolidates all rate limiting configuration into organized groups * - * Rate Limits (Relaxed for Development): - * - Global: 10000 req/min (all endpoints) - * - Admin: 5000 req/min (/api/admin/*) - * - Generation: 100 req/min (/api/generation/*) - * - Music: 200 req/min (/api/music/*) - * - SFX: 300 req/min (/api/sfx/*) - * - Voice: 200 req/min (/api/voice/*) + * Rate Limits (Environment-aware): + * - Production: Strict limits to prevent abuse + * - Development: Higher limits for testing, but still enforced + * - Test: Very high limits but still enforced (can be disabled with DISABLE_RATE_LIMITING=true) * - * Note: Rate limiting is disabled in development mode * Uses Elysia's .group() pattern for organized route protection */ @@ -41,53 +37,74 @@ function createRateLimitError(message: string) { } /** - * Check if rate limiting should be enabled - * Disabled in development and test environments + * Rate limiting is now ALWAYS enabled by default + * Can only be disabled explicitly with DISABLE_RATE_LIMITING=true (for specific tests) + * + * Note: We check process.env directly here since DISABLE_RATE_LIMITING is a test-only + * flag that may not be in the validated env schema. This is intentional to allow + * test-specific behavior without polluting the production config. + */ +const RATE_LIMITING_ENABLED = process.env.DISABLE_RATE_LIMITING !== "true"; + +/** + * Multiplier for rate limits based on environment + * - Production: 1x (base limits) + * - Development: 10x (relaxed for testing) + * - Test: 100x (very relaxed for automated tests) + */ +const RATE_LIMIT_MULTIPLIER = + env.NODE_ENV === "production" ? 1 : env.NODE_ENV === "test" ? 100 : 10; + +/** + * Base rate limits (production values) + * Multiplied by RATE_LIMIT_MULTIPLIER for dev/test environments */ -const RATE_LIMITING_ENABLED = - env.NODE_ENV === "production" || env.ENABLE_RATE_LIMITING === true; +const BASE_GLOBAL_LIMIT = 1000; // 1000 req/min in production +const BASE_ADMIN_LIMIT = 500; +const BASE_GENERATION_LIMIT = 10; // AI generation is expensive +const BASE_MUSIC_LIMIT = 20; +const BASE_SFX_LIMIT = 30; +const BASE_VOICE_LIMIT = 20; /** * Global rate limit (applied to all routes) - * Very relaxed limits for development and admin usage */ const GLOBAL_RATE_LIMIT: RateLimitConfig = { duration: 60000, // 1 minute - max: 10000, // 10x increase + max: BASE_GLOBAL_LIMIT * RATE_LIMIT_MULTIPLIER, errorMessage: "Rate limit exceeded. Please try again later.", }; /** * Rate limits for specific endpoint groups - * Significantly relaxed for better development experience */ const ENDPOINT_RATE_LIMITS: Record = { admin: { duration: 60000, - max: 5000, // 50x increase - admins need high limits + max: BASE_ADMIN_LIMIT * RATE_LIMIT_MULTIPLIER, errorMessage: "Admin endpoint rate limit exceeded. Please try again later.", }, generation: { duration: 60000, - max: 100, // 10x increase - still controlled for cost + max: BASE_GENERATION_LIMIT * RATE_LIMIT_MULTIPLIER, errorMessage: "Generation rate limit exceeded. Please wait before generating more assets.", }, music: { duration: 60000, - max: 200, // 10x increase + max: BASE_MUSIC_LIMIT * RATE_LIMIT_MULTIPLIER, errorMessage: "Music generation rate limit exceeded. Please try again later.", }, sfx: { duration: 60000, - max: 300, // 10x increase + max: BASE_SFX_LIMIT * RATE_LIMIT_MULTIPLIER, errorMessage: "Sound effect generation rate limit exceeded. Please try again later.", }, voice: { duration: 60000, - max: 200, // 10x increase + max: BASE_VOICE_LIMIT * RATE_LIMIT_MULTIPLIER, errorMessage: "Voice generation rate limit exceeded. Please try again later.", }, diff --git a/apps/core/server/plugins/security-headers.ts b/apps/core/server/plugins/security-headers.ts index fd265d9..093ab47 100644 --- a/apps/core/server/plugins/security-headers.ts +++ b/apps/core/server/plugins/security-headers.ts @@ -4,13 +4,18 @@ * * Headers applied: * - Cross-Origin-Opener-Policy: Required for Privy embedded wallets - * - Cross-Origin-Embedder-Policy: Modern security for embedded content * - X-Content-Type-Options: Prevents MIME sniffing * - X-Frame-Options: Prevents clickjacking + * - X-XSS-Protection: Legacy XSS protection (for older browsers) + * - Referrer-Policy: Controls referrer information + * - Permissions-Policy: Restricts browser features + * - Strict-Transport-Security: Forces HTTPS (production only) */ import { Elysia } from "elysia"; +const isProduction = process.env.NODE_ENV === "production"; + export const securityHeaders = new Elysia({ name: "security-headers", }).onRequest(({ set }) => { @@ -23,7 +28,25 @@ export const securityHeaders = new Elysia({ // Privy's embedded wallet iframe needs to load resources without CORP headers // COEP is only needed for SharedArrayBuffer/high-resolution timers, not for auth - // Additional security headers + // Prevent MIME sniffing set.headers["X-Content-Type-Options"] = "nosniff"; + + // Prevent clickjacking set.headers["X-Frame-Options"] = "DENY"; + + // Legacy XSS protection (for older browsers) + set.headers["X-XSS-Protection"] = "1; mode=block"; + + // Control referrer information + set.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + + // Restrict browser features + set.headers["Permissions-Policy"] = + "geolocation=(), microphone=(), camera=(), payment=()"; + + // HSTS - Force HTTPS in production (1 year, include subdomains) + if (isProduction) { + set.headers["Strict-Transport-Security"] = + "max-age=31536000; includeSubDomains"; + } }); diff --git a/apps/core/server/services/TokenBlocklistService.ts b/apps/core/server/services/TokenBlocklistService.ts new file mode 100644 index 0000000..9dd3401 --- /dev/null +++ b/apps/core/server/services/TokenBlocklistService.ts @@ -0,0 +1,113 @@ +/** + * Token Blocklist Service + * Simple JWT invalidation via blocklist + */ + +import { eq, lt } from "drizzle-orm"; +import { db } from "../db/db"; +import { tokenBlocklist } from "../db/schema"; +import { logger } from "../utils/logger"; +import { createHash } from "crypto"; + +export class TokenBlocklistService { + /** + * Hash a token for storage (don't store raw tokens) + */ + private hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } + + /** + * Check if a token is blocklisted + */ + async isTokenBlocklisted(token: string): Promise { + const tokenId = this.hashToken(token); + + const result = await db + .select({ id: tokenBlocklist.id }) + .from(tokenBlocklist) + .where(eq(tokenBlocklist.tokenId, tokenId)) + .limit(1); + + return result.length > 0; + } + + /** + * Add a token to the blocklist + */ + async blockToken( + token: string, + userId?: string, + reason?: string, + expiresAt?: Date, + ): Promise { + const tokenId = this.hashToken(token); + + // Default expiry to 24 hours if not specified + const expiry = expiresAt || new Date(Date.now() + 24 * 60 * 60 * 1000); + + try { + await db.insert(tokenBlocklist).values({ + tokenId, + userId: userId || null, + reason: reason || "logout", + expiresAt: expiry, + }); + + logger.info( + { tokenId: tokenId.substring(0, 16), reason, context: "auth" }, + "Token added to blocklist", + ); + } catch (error: any) { + // Ignore duplicate key errors (token already blocklisted) + if (error.code === "23505") { + return; + } + throw error; + } + } + + /** + * Remove expired tokens from blocklist (cleanup) + */ + async cleanupExpired(): Promise { + const result = await db + .delete(tokenBlocklist) + .where(lt(tokenBlocklist.expiresAt, new Date())); + + const deletedCount = result.rowCount || 0; + + if (deletedCount > 0) { + logger.info( + { deletedCount, context: "auth" }, + "Cleaned up expired blocklist entries", + ); + } + + return deletedCount; + } + + /** + * Block all tokens for a user (logout everywhere) + * @deprecated This function is a placeholder and does not provide a secure "logout everywhere" implementation. + * A robust solution requires a mechanism like a `tokensValidFrom` timestamp on the user model. + */ + async blockUserTokens(userId: string, reason?: string): Promise { + // This is a critical security operation that is NOT implemented. + // A proper implementation should invalidate all of a user's sessions, + // for example by updating a `tokensValidFrom` timestamp in the user's database record + // and checking it during token verification. + // Leaving this as a no-op to prevent a false sense of security. + logger.error( + { userId, reason, context: "auth" }, + "CRITICAL: blockUserTokens is not implemented. User tokens were NOT invalidated.", + ); + // To prevent accidental use, throw an error in non-production environments. + if (process.env.NODE_ENV !== "production") { + throw new Error("blockUserTokens is not implemented."); + } + } +} + +// Singleton instance +export const tokenBlocklistService = new TokenBlocklistService(); diff --git a/apps/core/src/components/three-viewer/REFACTORING_PLAN.ts b/apps/core/src/components/three-viewer/REFACTORING_PLAN.ts new file mode 100644 index 0000000..57f4a62 --- /dev/null +++ b/apps/core/src/components/three-viewer/REFACTORING_PLAN.ts @@ -0,0 +1,54 @@ +/** + * ThreeViewer Refactoring Plan + * + * Current State: 6,340 lines in a single component + * Target: 5-10 smaller, focused components + * + * REFACTORING STRATEGY (Do incrementally, not all at once): + * + * Phase 1: Extract UI Components (Low Risk) + * ========================================= + * 1. ViewerLoadingOverlay - Loading progress display + * 2. ViewerErrorOverlay - Error display + * 3. ViewerStatsPanel - Vertices/faces/materials info + * 4. ViewerControlsPanel - Grid/bounds/stats/auto-rotate buttons + * 5. ViewerCameraControls - Front/Side/Top camera buttons + * 6. KeyboardShortcutsPanel - Shortcut help modal + * 7. HandControlsPanel - Hand bone testing UI + * + * Phase 2: Extract Logic Hooks (Medium Risk) + * ========================================== + * 1. useThreeScene - Scene/camera/renderer/controls setup + * 2. useModelLoader - GLTF loading with progress + * 3. useAnimationController - Animation loading/playback + * 4. useSkeletonViewer - Skeleton helper management + * + * Phase 3: Extract Complex Features (High Risk) + * ============================================= + * 1. BoneEditor - TransformControls and bone editing + * 2. SkeletonRetargeter - Retargeting workflow + * 3. CaptureTools - Screenshot/export functionality + * + * SHARED STATE: Use ThreeViewerContext to share: + * - scene, camera, renderer refs + * - model, mixer refs + * - loading state + * - animation state + * + * IMPORTANT NOTES: + * - Keep useImperativeHandle at top level + * - Test thoroughly after each extraction + * - Maintain backwards compatibility + * - Don't change the public API (ThreeViewerRef) + * + * CURRENT STRUCTURE (for reference): + * - 43 useRef declarations + * - 25 useState declarations + * - 13 useEffect hooks + * - 6 useCallback functions + * + * Start with Phase 1 UI components - they're the lowest risk + * and provide immediate code organization benefits. + */ + +export {}; // Make this a module diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index b99615c..6b93da3 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -115,7 +115,8 @@ export default defineConfig({ }, // Increase chunk size warning limit for Three.js bundles chunkSizeWarningLimit: 1000, - // Source maps for production debugging - sourcemap: true, + // Disable source maps in production (security - don't expose source code) + // Enable in development for debugging + sourcemap: process.env.NODE_ENV !== "production", }, });