diff --git a/saas/.env.example b/saas/.env.example new file mode 100644 index 000000000..7417ba03c --- /dev/null +++ b/saas/.env.example @@ -0,0 +1,53 @@ +# Moltbot SaaS Configuration +# Copy this file to .env and fill in your values + +# Server +NODE_ENV=development +PORT=3000 +HOST=0.0.0.0 + +# Database +DATABASE_URL=postgresql://moltbot:moltbot_dev_password@localhost:5432/moltbot_saas + +# Redis (optional in development) +REDIS_URL=redis://localhost:6379 + +# JWT Secrets (generate with: openssl rand -base64 32) +JWT_ACCESS_SECRET=your-access-secret-minimum-32-characters-long +JWT_REFRESH_SECRET=your-refresh-secret-minimum-32-characters-long + +# JWT Expiry +JWT_ACCESS_EXPIRY=15m +JWT_REFRESH_EXPIRY=7d + +# Encryption (generate with: openssl rand -base64 32) +ENCRYPTION_KEY=your-encryption-key-minimum-32-characters + +# Vault (optional in development) +VAULT_ADDR=http://localhost:8200 +VAULT_TOKEN=dev-root-token + +# Stripe (optional) +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Kubernetes (for orchestration) +KUBERNETES_NAMESPACE=moltbot-tenants +KUBERNETES_IN_CLUSTER=false + +# Email (optional) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@example.com +SMTP_PASS=your-smtp-password +SMTP_FROM=noreply@example.com + +# Frontend URL +FRONTEND_URL=http://localhost:5173 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 + +# Logging +LOG_LEVEL=debug diff --git a/saas/.gitignore b/saas/.gitignore new file mode 100644 index 000000000..8a47cff31 --- /dev/null +++ b/saas/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test +coverage/ + +# Docker volumes (local dev) +postgres_data/ +redis_data/ + +# Logs +*.log +logs/ diff --git a/saas/Dockerfile.api b/saas/Dockerfile.api new file mode 100644 index 000000000..beb2ccc25 --- /dev/null +++ b/saas/Dockerfile.api @@ -0,0 +1,56 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source code +COPY tsconfig.json ./ +COPY src ./src + +# Build TypeScript +RUN pnpm build + +# Production stage +FROM node:22-alpine AS production + +WORKDIR /app + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install production dependencies only +RUN pnpm install --prod --frozen-lockfile || pnpm install --prod + +# Copy built files from builder +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S moltbot && \ + adduser -S moltbot -u 1001 -G moltbot + +USER moltbot + +# Environment defaults +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 + +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +CMD ["node", "dist/server.js"] diff --git a/saas/docker-compose.dev.yaml b/saas/docker-compose.dev.yaml new file mode 100644 index 000000000..f31a1bf95 --- /dev/null +++ b/saas/docker-compose.dev.yaml @@ -0,0 +1,112 @@ +version: "3.8" + +services: + # PostgreSQL database + postgres: + image: postgres:16-alpine + container_name: moltbot-saas-postgres + environment: + POSTGRES_USER: moltbot + POSTGRES_PASSWORD: moltbot_dev_password + POSTGRES_DB: moltbot_saas + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./src/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U moltbot -d moltbot_saas"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - moltbot-network + + # Redis for sessions and rate limiting + redis: + image: redis:7-alpine + container_name: moltbot-saas-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - moltbot-network + + # HashiCorp Vault for secrets management (development mode) + vault: + image: hashicorp/vault:1.15 + container_name: moltbot-saas-vault + environment: + VAULT_DEV_ROOT_TOKEN_ID: dev-root-token + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + ports: + - "8200:8200" + cap_add: + - IPC_LOCK + healthcheck: + test: ["CMD", "vault", "status"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - moltbot-network + + # SaaS API server + api: + build: + context: . + dockerfile: Dockerfile.api + container_name: moltbot-saas-api + environment: + NODE_ENV: development + PORT: 3000 + HOST: 0.0.0.0 + DATABASE_URL: postgresql://moltbot:moltbot_dev_password@postgres:5432/moltbot_saas + REDIS_URL: redis://redis:6379 + JWT_ACCESS_SECRET: dev-access-secret-minimum-32-characters-long + JWT_REFRESH_SECRET: dev-refresh-secret-minimum-32-characters-long + ENCRYPTION_KEY: dev-encryption-key-minimum-32-chars + VAULT_ADDR: http://vault:8200 + VAULT_TOKEN: dev-root-token + FRONTEND_URL: http://localhost:5173 + LOG_LEVEL: debug + ports: + - "3000:3000" + volumes: + - ./src:/app/src:ro + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + vault: + condition: service_healthy + networks: + - moltbot-network + + # Adminer for database management (optional) + adminer: + image: adminer:4 + container_name: moltbot-saas-adminer + ports: + - "8080:8080" + environment: + ADMINER_DEFAULT_SERVER: postgres + depends_on: + - postgres + networks: + - moltbot-network + +volumes: + postgres_data: + redis_data: + +networks: + moltbot-network: + driver: bridge diff --git a/saas/package.json b/saas/package.json new file mode 100644 index 000000000..bfca56d1c --- /dev/null +++ b/saas/package.json @@ -0,0 +1,35 @@ +{ + "name": "@moltbot/saas", + "version": "0.1.0", + "description": "Moltbot SaaS Platform - Multi-tenant secure AI assistant", + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "oxlint src" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@hono/zod-validator": "^0.4.3", + "@sinclair/typebox": "0.34.47", + "argon2": "^0.41.1", + "hono": "4.11.4", + "jose": "^6.0.10", + "pg": "^8.13.1", + "redis": "^5.0.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "@types/pg": "^8.11.6", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/saas/src/api/routes/agent.ts b/saas/src/api/routes/agent.ts new file mode 100644 index 000000000..abb35c9fb --- /dev/null +++ b/saas/src/api/routes/agent.ts @@ -0,0 +1,226 @@ +import { Hono } from "hono"; +import { authMiddleware } from "../../auth/middleware.js"; +import type { DbClient, TenantRow } from "../../db/client.js"; + +export function createAgentRoutes(db: DbClient): Hono { + const app = new Hono(); + + // All agent routes require authentication + app.use("*", authMiddleware); + + // GET /agent/status - Get tenant agent status + app.get("/status", async (c) => { + const user = c.get("user"); + + const result = await db.query( + `SELECT * FROM tenants WHERE user_id = $1`, + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ + status: "not_provisioned", + message: "No agent instance found. Please provision one.", + }); + } + + const tenant = result.rows[0]!; + + return c.json({ + status: tenant.status, + namespace: tenant.namespace, + resources: { + cpu: tenant.cpu_limit, + memory: tenant.memory_limit, + storage: tenant.storage_limit, + }, + lastActivityAt: tenant.last_activity_at, + scaledDownAt: tenant.scaled_down_at, + createdAt: tenant.created_at, + }); + }); + + // POST /agent/provision - Provision a new agent instance + app.post("/provision", async (c) => { + const user = c.get("user"); + + // Check if tenant already exists + const existingResult = await db.query( + "SELECT id, status FROM tenants WHERE user_id = $1", + [user.sub] + ); + + if (existingResult.rows.length > 0) { + const existing = existingResult.rows[0]!; + if (existing.status !== "terminated") { + return c.json( + { error: "Agent instance already exists" }, + 400 + ); + } + } + + // Generate namespace name + const namespace = `tenant-${user.sub.slice(0, 8)}`; + + // Create tenant record + const insertResult = await db.query( + `INSERT INTO tenants (user_id, namespace, status) + VALUES ($1, $2, 'provisioning') + ON CONFLICT (user_id) DO UPDATE SET + status = 'provisioning', + namespace = $2, + updated_at = NOW() + RETURNING *`, + [user.sub, namespace] + ); + + const tenant = insertResult.rows[0]!; + + // TODO: Trigger Kubernetes provisioning via orchestrator + // This would be done via a message queue or direct API call + + return c.json( + { + message: "Agent provisioning started", + tenantId: tenant.id, + namespace: tenant.namespace, + status: tenant.status, + }, + 202 + ); + }); + + // POST /agent/wake - Wake up a scaled-down agent + app.post("/wake", async (c) => { + const user = c.get("user"); + + const result = await db.query( + "SELECT * FROM tenants WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No agent instance found" }, 404); + } + + const tenant = result.rows[0]!; + + if (tenant.status === "active" && !tenant.scaled_down_at) { + return c.json({ message: "Agent is already running" }); + } + + if (tenant.status === "terminated") { + return c.json( + { error: "Agent has been terminated. Please provision a new one." }, + 400 + ); + } + + // Update status and clear scaled_down_at + await db.query( + `UPDATE tenants + SET scaled_down_at = NULL, + last_activity_at = NOW() + WHERE id = $1`, + [tenant.id] + ); + + // TODO: Trigger Kubernetes wake-up via orchestrator + + return c.json({ + message: "Agent wake-up initiated", + status: "waking", + }); + }); + + // POST /agent/restart - Restart the agent + app.post("/restart", async (c) => { + const user = c.get("user"); + + const result = await db.query( + "SELECT * FROM tenants WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No agent instance found" }, 404); + } + + const tenant = result.rows[0]!; + + if (tenant.status !== "active") { + return c.json( + { error: `Cannot restart agent in ${tenant.status} state` }, + 400 + ); + } + + // TODO: Trigger Kubernetes pod restart via orchestrator + + return c.json({ + message: "Agent restart initiated", + }); + }); + + // DELETE /agent - Terminate the agent instance + app.delete("/", async (c) => { + const user = c.get("user"); + + const result = await db.query( + "SELECT * FROM tenants WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No agent instance found" }, 404); + } + + const tenant = result.rows[0]!; + + // Update status to terminated + await db.query( + `UPDATE tenants SET status = 'terminated' WHERE id = $1`, + [tenant.id] + ); + + // TODO: Trigger Kubernetes cleanup via orchestrator + + return c.json({ + message: "Agent termination initiated", + }); + }); + + // GET /agent/logs - Get recent agent logs + app.get("/logs", async (c) => { + const user = c.get("user"); + const lines = parseInt(c.req.query("lines") ?? "100", 10); + + const result = await db.query( + "SELECT * FROM tenants WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No agent instance found" }, 404); + } + + const tenant = result.rows[0]!; + + if (tenant.status !== "active") { + return c.json( + { error: "Agent is not running" }, + 400 + ); + } + + // TODO: Fetch logs from Kubernetes via orchestrator + + return c.json({ + logs: [], + message: "Log retrieval not yet implemented", + }); + }); + + return app; +} diff --git a/saas/src/api/routes/auth.ts b/saas/src/api/routes/auth.ts new file mode 100644 index 000000000..d215704f6 --- /dev/null +++ b/saas/src/api/routes/auth.ts @@ -0,0 +1,192 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; +import { AuthService } from "../../auth/service.js"; +import { authMiddleware } from "../../auth/middleware.js"; +import { verifyRefreshToken } from "../../auth/jwt.js"; +import type { DbClient } from "../../db/client.js"; + +// Request schemas +const signupSchema = z.object({ + email: z.string().email(), + password: z.string().min(8).max(128), + displayName: z.string().min(1).max(100).optional(), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +const refreshSchema = z.object({ + refreshToken: z.string(), +}); + +const verifyEmailSchema = z.object({ + token: z.string(), +}); + +const requestResetSchema = z.object({ + email: z.string().email(), +}); + +const resetPasswordSchema = z.object({ + token: z.string(), + password: z.string().min(8).max(128), +}); + +export function createAuthRoutes(db: DbClient): Hono { + const app = new Hono(); + const authService = new AuthService(db); + + // POST /auth/signup - Register a new user + app.post("/signup", zValidator("json", signupSchema), async (c) => { + const body = c.req.valid("json"); + + const result = await authService.signup({ + email: body.email, + password: body.password, + displayName: body.displayName, + }); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + return c.json( + { + message: "Account created successfully. Please verify your email.", + user: result.user, + }, + 201 + ); + }); + + // POST /auth/login - Authenticate user + app.post("/login", zValidator("json", loginSchema), async (c) => { + const body = c.req.valid("json"); + + const result = await authService.login({ + email: body.email, + password: body.password, + userAgent: c.req.header("User-Agent"), + ipAddress: c.req.header("X-Forwarded-For") ?? c.req.header("X-Real-IP"), + }); + + if (!result.success) { + return c.json({ error: result.error }, 401); + } + + return c.json({ + user: result.user, + tokens: result.tokens, + }); + }); + + // POST /auth/refresh - Refresh access token + app.post("/refresh", zValidator("json", refreshSchema), async (c) => { + const { refreshToken } = c.req.valid("json"); + + const result = await authService.refreshTokens(refreshToken); + + if (!result.success) { + return c.json({ error: result.error }, 401); + } + + return c.json({ + user: result.user, + tokens: result.tokens, + }); + }); + + // POST /auth/logout - Logout current session + app.post("/logout", authMiddleware, async (c) => { + const user = c.get("user"); + + // Get session ID from request body (optional) + const body = await c.req.json().catch(() => ({})) as { sessionId?: string }; + + if (body.sessionId) { + await authService.logout(user.sub, body.sessionId); + } + + return c.json({ message: "Logged out successfully" }); + }); + + // POST /auth/logout-all - Logout all sessions + app.post("/logout-all", authMiddleware, async (c) => { + const user = c.get("user"); + + await authService.logoutAll(user.sub); + + return c.json({ message: "Logged out from all sessions" }); + }); + + // GET /auth/me - Get current user info + app.get("/me", authMiddleware, async (c) => { + const user = c.get("user"); + + const fullUser = await authService.getUserById(user.sub); + + if (!fullUser) { + return c.json({ error: "User not found" }, 404); + } + + return c.json({ + id: fullUser.id, + email: fullUser.email, + displayName: fullUser.display_name, + emailVerified: fullUser.email_verified, + createdAt: fullUser.created_at, + lastLoginAt: fullUser.last_login_at, + }); + }); + + // POST /auth/verify-email - Verify email with token + app.post("/verify-email", zValidator("json", verifyEmailSchema), async (c) => { + const { token } = c.req.valid("json"); + + const result = await authService.verifyEmail(token); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + return c.json({ message: "Email verified successfully" }); + }); + + // POST /auth/request-password-reset - Request password reset + app.post( + "/request-password-reset", + zValidator("json", requestResetSchema), + async (c) => { + const { email } = c.req.valid("json"); + + await authService.requestPasswordReset(email); + + // Always return success to prevent email enumeration + return c.json({ + message: "If an account exists, a password reset email will be sent", + }); + } + ); + + // POST /auth/reset-password - Reset password with token + app.post( + "/reset-password", + zValidator("json", resetPasswordSchema), + async (c) => { + const { token, password } = c.req.valid("json"); + + const result = await authService.resetPassword(token, password); + + if (!result.success) { + return c.json({ error: result.error }, 400); + } + + return c.json({ message: "Password reset successfully" }); + } + ); + + return app; +} diff --git a/saas/src/api/routes/billing.ts b/saas/src/api/routes/billing.ts new file mode 100644 index 000000000..9ea6e22f4 --- /dev/null +++ b/saas/src/api/routes/billing.ts @@ -0,0 +1,221 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { zValidator } from "@hono/zod-validator"; +import { authMiddleware } from "../../auth/middleware.js"; +import type { DbClient, SubscriptionRow, TierLimitsRow } from "../../db/client.js"; + +const updateSubscriptionSchema = z.object({ + tier: z.enum(["free", "starter", "pro", "enterprise"]), +}); + +export function createBillingRoutes(db: DbClient): Hono { + const app = new Hono(); + + // All billing routes require authentication + app.use("*", authMiddleware); + + // GET /billing/subscription - Get current subscription + app.get("/subscription", async (c) => { + const user = c.get("user"); + + const result = await db.query( + "SELECT * FROM subscriptions WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No subscription found" }, 404); + } + + const subscription = result.rows[0]!; + + return c.json({ + tier: subscription.tier, + status: subscription.status, + currentPeriod: { + start: subscription.current_period_start, + end: subscription.current_period_end, + }, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + createdAt: subscription.created_at, + }); + }); + + // GET /billing/plans - Get available subscription plans + app.get("/plans", async (c) => { + const result = await db.query( + "SELECT * FROM tier_limits ORDER BY tier" + ); + + const pricing: Record = { + free: { monthly: 0, yearly: 0 }, + starter: { monthly: 9.99, yearly: 99.99 }, + pro: { monthly: 29.99, yearly: 299.99 }, + enterprise: { monthly: 99.99, yearly: 999.99 }, + }; + + return c.json({ + plans: result.rows.map((row) => ({ + tier: row.tier, + pricing: pricing[row.tier], + limits: { + dailyMessageLimit: row.daily_message_limit, + monthlyTokenLimit: row.monthly_token_limit + ? Number(row.monthly_token_limit) + : null, + maxComputeHoursMonth: row.max_compute_hours_month, + maxStorageBytes: Number(row.max_storage_bytes), + maxConcurrentSessions: row.max_concurrent_sessions, + }, + features: { + voiceEnabled: row.voice_enabled, + videoEnabled: row.video_enabled, + customModelsEnabled: row.custom_models_enabled, + apiAccessEnabled: row.api_access_enabled, + }, + apiRateLimit: row.api_rate_limit, + })), + }); + }); + + // POST /billing/subscription - Update subscription (upgrade/downgrade) + app.post( + "/subscription", + zValidator("json", updateSubscriptionSchema), + async (c) => { + const user = c.get("user"); + const { tier } = c.req.valid("json"); + + // Get current subscription + const currentResult = await db.query( + "SELECT * FROM subscriptions WHERE user_id = $1", + [user.sub] + ); + + if (currentResult.rows.length === 0) { + return c.json({ error: "No subscription found" }, 404); + } + + const currentSubscription = currentResult.rows[0]!; + + if (currentSubscription.tier === tier) { + return c.json({ error: "Already on this plan" }, 400); + } + + // For paid tiers, we would integrate with Stripe here + // For now, just update the tier directly + + if (tier !== "free" && !currentSubscription.stripe_customer_id) { + return c.json( + { + error: "Payment method required", + message: "Please set up a payment method before upgrading", + action: "setup_payment", + }, + 402 + ); + } + + // Update subscription + await db.query( + `UPDATE subscriptions + SET tier = $1, updated_at = NOW() + WHERE user_id = $2`, + [tier, user.sub] + ); + + return c.json({ + message: `Subscription updated to ${tier}`, + tier, + }); + } + ); + + // POST /billing/cancel - Cancel subscription + app.post("/cancel", async (c) => { + const user = c.get("user"); + + const result = await db.query( + "SELECT * FROM subscriptions WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No subscription found" }, 404); + } + + const subscription = result.rows[0]!; + + if (subscription.tier === "free") { + return c.json({ error: "Cannot cancel free tier" }, 400); + } + + // Mark as canceling at period end + await db.query( + `UPDATE subscriptions + SET cancel_at_period_end = TRUE, updated_at = NOW() + WHERE user_id = $1`, + [user.sub] + ); + + // TODO: Cancel in Stripe + + return c.json({ + message: "Subscription will be canceled at the end of the billing period", + cancelAt: subscription.current_period_end, + }); + }); + + // POST /billing/reactivate - Reactivate a canceled subscription + app.post("/reactivate", async (c) => { + const user = c.get("user"); + + const result = await db.query( + "SELECT * FROM subscriptions WHERE user_id = $1", + [user.sub] + ); + + if (result.rows.length === 0) { + return c.json({ error: "No subscription found" }, 404); + } + + const subscription = result.rows[0]!; + + if (!subscription.cancel_at_period_end) { + return c.json({ error: "Subscription is not scheduled for cancellation" }, 400); + } + + // Remove cancellation + await db.query( + `UPDATE subscriptions + SET cancel_at_period_end = FALSE, updated_at = NOW() + WHERE user_id = $1`, + [user.sub] + ); + + // TODO: Reactivate in Stripe + + return c.json({ + message: "Subscription reactivated", + }); + }); + + // GET /billing/invoices - Get invoice history + app.get("/invoices", async (c) => { + // This would integrate with Stripe to fetch invoice history + return c.json({ + message: "Invoice history not yet implemented", + invoices: [], + }); + }); + + // POST /billing/setup-intent - Create a Stripe SetupIntent for adding payment method + app.post("/setup-intent", async (c) => { + // This would create a Stripe SetupIntent + return c.json({ + message: "Payment setup not yet implemented", + }); + }); + + return app; +} diff --git a/saas/src/api/routes/usage.ts b/saas/src/api/routes/usage.ts new file mode 100644 index 000000000..9382f722a --- /dev/null +++ b/saas/src/api/routes/usage.ts @@ -0,0 +1,137 @@ +import { Hono } from "hono"; +import { authMiddleware } from "../../auth/middleware.js"; +import type { DbClient, SubscriptionRow, TierLimitsRow } from "../../db/client.js"; + +interface UsageRecordRow { + id: string; + user_id: string; + tenant_id: string; + period_start: Date; + period_end: Date; + messages_sent: number; + messages_received: number; + tokens_input: bigint; + tokens_output: bigint; + compute_seconds: number; + storage_bytes: bigint; + created_at: Date; + updated_at: Date; +} + +export function createUsageRoutes(db: DbClient): Hono { + const app = new Hono(); + + // All usage routes require authentication + app.use("*", authMiddleware); + + // GET /usage - Get current period usage + app.get("/", async (c) => { + const user = c.get("user"); + + // Get current period (this month) + const now = new Date(); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + // Get usage record + const usageResult = await db.query( + `SELECT * FROM usage_records + WHERE user_id = $1 + AND period_start = $2 + AND period_end = $3`, + [user.sub, periodStart.toISOString().split("T")[0], periodEnd.toISOString().split("T")[0]] + ); + + // Get subscription tier + const subscriptionResult = await db.query( + "SELECT tier FROM subscriptions WHERE user_id = $1", + [user.sub] + ); + + const tier = subscriptionResult.rows[0]?.tier ?? "free"; + + // Get tier limits + const limitsResult = await db.query( + "SELECT * FROM tier_limits WHERE tier = $1", + [tier] + ); + + const limits = limitsResult.rows[0]; + + const usage = usageResult.rows[0]; + + return c.json({ + period: { + start: periodStart.toISOString(), + end: periodEnd.toISOString(), + }, + usage: { + messagesSent: usage?.messages_sent ?? 0, + messagesReceived: usage?.messages_received ?? 0, + tokensInput: usage ? Number(usage.tokens_input) : 0, + tokensOutput: usage ? Number(usage.tokens_output) : 0, + computeSeconds: usage?.compute_seconds ?? 0, + storageBytes: usage ? Number(usage.storage_bytes) : 0, + }, + limits: limits + ? { + dailyMessageLimit: limits.daily_message_limit, + monthlyTokenLimit: limits.monthly_token_limit + ? Number(limits.monthly_token_limit) + : null, + maxComputeHoursMonth: limits.max_compute_hours_month, + maxStorageBytes: Number(limits.max_storage_bytes), + voiceEnabled: limits.voice_enabled, + videoEnabled: limits.video_enabled, + customModelsEnabled: limits.custom_models_enabled, + apiAccessEnabled: limits.api_access_enabled, + } + : null, + tier, + }); + }); + + // GET /usage/history - Get usage history + app.get("/history", async (c) => { + const user = c.get("user"); + const months = parseInt(c.req.query("months") ?? "6", 10); + + const result = await db.query( + `SELECT * FROM usage_records + WHERE user_id = $1 + ORDER BY period_start DESC + LIMIT $2`, + [user.sub, months] + ); + + return c.json({ + history: result.rows.map((row) => ({ + period: { + start: row.period_start, + end: row.period_end, + }, + messagesSent: row.messages_sent, + messagesReceived: row.messages_received, + tokensInput: Number(row.tokens_input), + tokensOutput: Number(row.tokens_output), + computeSeconds: row.compute_seconds, + storageBytes: Number(row.storage_bytes), + })), + }); + }); + + // GET /usage/daily - Get daily usage for current month + app.get("/daily", async (c) => { + const user = c.get("user"); + + // This would require a separate daily tracking table + // For now, return a placeholder + + return c.json({ + message: "Daily usage tracking not yet implemented", + daily: [], + }); + }); + + return app; +} diff --git a/saas/src/auth/index.ts b/saas/src/auth/index.ts new file mode 100644 index 000000000..487cb06dc --- /dev/null +++ b/saas/src/auth/index.ts @@ -0,0 +1,17 @@ +export { AuthService } from "./service.js"; +export type { AuthResult, SignupInput, LoginInput } from "./service.js"; +export { authMiddleware, optionalAuthMiddleware, requireTier } from "./middleware.js"; +export { + generateAccessToken, + generateRefreshToken, + verifyAccessToken, + verifyRefreshToken, + generateSecureToken, + hashToken, +} from "./jwt.js"; +export type { AccessTokenPayload, RefreshTokenPayload } from "./jwt.js"; +export { + hashPassword, + verifyPassword, + validatePasswordStrength, +} from "./password.js"; diff --git a/saas/src/auth/jwt.ts b/saas/src/auth/jwt.ts new file mode 100644 index 000000000..60b0111f1 --- /dev/null +++ b/saas/src/auth/jwt.ts @@ -0,0 +1,158 @@ +import * as jose from "jose"; +import crypto from "node:crypto"; +import { env } from "../config/env.js"; + +export interface AccessTokenPayload { + sub: string; // User ID + email: string; + tier: "free" | "starter" | "pro" | "enterprise"; + iat: number; + exp: number; +} + +export interface RefreshTokenPayload { + sub: string; // User ID + sid: string; // Session ID + iat: number; + exp: number; +} + +// Parse duration string (e.g., "15m", "7d") to seconds +function parseDuration(duration: string): number { + const match = duration.match(/^(\d+)([smhd])$/); + if (!match) { + throw new Error(`Invalid duration format: ${duration}`); + } + + const value = parseInt(match[1]!, 10); + const unit = match[2]; + + switch (unit) { + case "s": + return value; + case "m": + return value * 60; + case "h": + return value * 60 * 60; + case "d": + return value * 60 * 60 * 24; + default: + throw new Error(`Unknown duration unit: ${unit}`); + } +} + +// Create secret keys from environment +const accessSecret = new TextEncoder().encode(env.JWT_ACCESS_SECRET); +const refreshSecret = new TextEncoder().encode(env.JWT_REFRESH_SECRET); + +/** + * Generate an access token (short-lived) + */ +export async function generateAccessToken(payload: { + userId: string; + email: string; + tier: "free" | "starter" | "pro" | "enterprise"; +}): Promise { + const expiry = parseDuration(env.JWT_ACCESS_EXPIRY); + + return new jose.SignJWT({ + email: payload.email, + tier: payload.tier, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(payload.userId) + .setIssuedAt() + .setExpirationTime(`${expiry}s`) + .setIssuer("moltbot-saas") + .setAudience("moltbot-api") + .sign(accessSecret); +} + +/** + * Generate a refresh token (long-lived) + */ +export async function generateRefreshToken(payload: { + userId: string; + sessionId: string; +}): Promise { + const expiry = parseDuration(env.JWT_REFRESH_EXPIRY); + + return new jose.SignJWT({ + sid: payload.sessionId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(payload.userId) + .setIssuedAt() + .setExpirationTime(`${expiry}s`) + .setIssuer("moltbot-saas") + .setAudience("moltbot-refresh") + .sign(refreshSecret); +} + +/** + * Verify and decode an access token + */ +export async function verifyAccessToken( + token: string +): Promise { + try { + const { payload } = await jose.jwtVerify(token, accessSecret, { + issuer: "moltbot-saas", + audience: "moltbot-api", + }); + + return { + sub: payload.sub as string, + email: payload["email"] as string, + tier: payload["tier"] as AccessTokenPayload["tier"], + iat: payload.iat as number, + exp: payload.exp as number, + }; + } catch { + return null; + } +} + +/** + * Verify and decode a refresh token + */ +export async function verifyRefreshToken( + token: string +): Promise { + try { + const { payload } = await jose.jwtVerify(token, refreshSecret, { + issuer: "moltbot-saas", + audience: "moltbot-refresh", + }); + + return { + sub: payload.sub as string, + sid: payload["sid"] as string, + iat: payload.iat as number, + exp: payload.exp as number, + }; + } catch { + return null; + } +} + +/** + * Generate a secure random token for email verification, password reset, etc. + */ +export function generateSecureToken(): string { + return crypto.randomBytes(32).toString("base64url"); +} + +/** + * Hash a token for storage (using SHA-256) + */ +export function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +/** + * Generate token expiry time + */ +export function getTokenExpiry(durationMs: number): Date { + return new Date(Date.now() + durationMs); +} diff --git a/saas/src/auth/middleware.ts b/saas/src/auth/middleware.ts new file mode 100644 index 000000000..f353e9b55 --- /dev/null +++ b/saas/src/auth/middleware.ts @@ -0,0 +1,111 @@ +import type { Context, Next } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { AccessTokenPayload } from "./jwt.js"; +import { verifyAccessToken } from "./jwt.js"; + +// Extend Hono's context with our user info +declare module "hono" { + interface ContextVariableMap { + user: AccessTokenPayload; + } +} + +/** + * Authentication middleware - requires valid access token + */ +export async function authMiddleware(c: Context, next: Next): Promise { + const authHeader = c.req.header("Authorization"); + + if (!authHeader) { + throw new HTTPException(401, { + message: "Missing authorization header", + }); + } + + if (!authHeader.startsWith("Bearer ")) { + throw new HTTPException(401, { + message: "Invalid authorization header format", + }); + } + + const token = authHeader.slice(7); // Remove "Bearer " prefix + + const payload = await verifyAccessToken(token); + + if (!payload) { + throw new HTTPException(401, { + message: "Invalid or expired access token", + }); + } + + // Set user in context + c.set("user", payload); + + await next(); +} + +/** + * Optional auth middleware - doesn't require token but parses if present + */ +export async function optionalAuthMiddleware( + c: Context, + next: Next +): Promise { + const authHeader = c.req.header("Authorization"); + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + const payload = await verifyAccessToken(token); + + if (payload) { + c.set("user", payload); + } + } + + await next(); +} + +/** + * Tier requirement middleware - checks if user has required subscription tier + */ +export function requireTier( + ...allowedTiers: Array<"free" | "starter" | "pro" | "enterprise"> +) { + return async (c: Context, next: Next): Promise => { + const user = c.get("user"); + + if (!user) { + throw new HTTPException(401, { + message: "Authentication required", + }); + } + + if (!allowedTiers.includes(user.tier)) { + throw new HTTPException(403, { + message: `This feature requires a ${allowedTiers.join(" or ")} subscription`, + }); + } + + await next(); + }; +} + +/** + * Email verification requirement middleware + */ +export function requireEmailVerified() { + return async (c: Context, next: Next): Promise => { + const user = c.get("user"); + + if (!user) { + throw new HTTPException(401, { + message: "Authentication required", + }); + } + + // Note: For full implementation, we'd need to check the database + // This is a placeholder - the access token should include email_verified + + await next(); + }; +} diff --git a/saas/src/auth/password.ts b/saas/src/auth/password.ts new file mode 100644 index 000000000..638d5ffae --- /dev/null +++ b/saas/src/auth/password.ts @@ -0,0 +1,84 @@ +import argon2 from "argon2"; + +// Argon2id configuration following OWASP recommendations +const ARGON2_CONFIG: argon2.Options = { + type: argon2.argon2id, + memoryCost: 65536, // 64 MiB + timeCost: 3, // 3 iterations + parallelism: 4, // 4 parallel threads + hashLength: 32, // 256 bits +}; + +/** + * Hash a password using Argon2id + */ +export async function hashPassword(password: string): Promise { + return argon2.hash(password, ARGON2_CONFIG); +} + +/** + * Verify a password against a hash + */ +export async function verifyPassword( + password: string, + hash: string +): Promise { + try { + return await argon2.verify(hash, password); + } catch { + // Invalid hash format or other error + return false; + } +} + +/** + * Check if a password hash needs to be rehashed (due to config changes) + */ +export function needsRehash(hash: string): boolean { + return argon2.needsRehash(hash, ARGON2_CONFIG); +} + +/** + * Validate password strength + * Returns an array of validation errors, empty if valid + */ +export function validatePasswordStrength(password: string): string[] { + const errors: string[] = []; + + if (password.length < 8) { + errors.push("Password must be at least 8 characters long"); + } + + if (password.length > 128) { + errors.push("Password must be at most 128 characters long"); + } + + if (!/[a-z]/.test(password)) { + errors.push("Password must contain at least one lowercase letter"); + } + + if (!/[A-Z]/.test(password)) { + errors.push("Password must contain at least one uppercase letter"); + } + + if (!/[0-9]/.test(password)) { + errors.push("Password must contain at least one number"); + } + + // Check for common weak passwords + const weakPasswords = [ + "password", + "12345678", + "qwerty", + "letmein", + "welcome", + "admin", + "password1", + "Password1", + ]; + if (weakPasswords.some((weak) => password.toLowerCase().includes(weak))) { + errors.push("Password is too common or easily guessable"); + } + + return errors; +} diff --git a/saas/src/auth/service.ts b/saas/src/auth/service.ts new file mode 100644 index 000000000..3f50e8ba7 --- /dev/null +++ b/saas/src/auth/service.ts @@ -0,0 +1,569 @@ +import type pg from "pg"; +import type { DbClient, SubscriptionRow, UserRow, UserSessionRow } from "../db/client.js"; +import { withTransaction } from "../db/client.js"; +import { + generateAccessToken, + generateRefreshToken, + generateSecureToken, + getTokenExpiry, + hashToken, + verifyRefreshToken, +} from "./jwt.js"; +import { + hashPassword, + needsRehash, + validatePasswordStrength, + verifyPassword, +} from "./password.js"; + +// Constants +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes +const EMAIL_VERIFICATION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours +const PASSWORD_RESET_EXPIRY_MS = 1 * 60 * 60 * 1000; // 1 hour +const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export interface AuthResult { + success: boolean; + error?: string; + user?: { + id: string; + email: string; + displayName: string | null; + emailVerified: boolean; + tier: SubscriptionRow["tier"]; + }; + tokens?: { + accessToken: string; + refreshToken: string; + expiresIn: number; + }; +} + +export interface SignupInput { + email: string; + password: string; + displayName?: string; +} + +export interface LoginInput { + email: string; + password: string; + userAgent?: string; + ipAddress?: string; +} + +export class AuthService { + constructor(private db: DbClient) {} + + /** + * Register a new user + */ + async signup(input: SignupInput): Promise { + const email = input.email.toLowerCase().trim(); + + // Validate email format + if (!this.isValidEmail(email)) { + return { success: false, error: "Invalid email format" }; + } + + // Validate password strength + const passwordErrors = validatePasswordStrength(input.password); + if (passwordErrors.length > 0) { + return { success: false, error: passwordErrors.join("; ") }; + } + + // Check if user already exists + const existingUser = await this.db.query( + "SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL", + [email] + ); + + if (existingUser.rows.length > 0) { + return { success: false, error: "Email already registered" }; + } + + // Hash password + const passwordHash = await hashPassword(input.password); + + // Create user and subscription in a transaction + const result = await withTransaction(this.db, async (client) => { + // Create user + const userResult = await client.query( + `INSERT INTO users (email, password_hash, display_name) + VALUES ($1, $2, $3) + RETURNING *`, + [email, passwordHash, input.displayName ?? null] + ); + + const user = userResult.rows[0]!; + + // Create free tier subscription + await client.query( + `INSERT INTO subscriptions (user_id, tier, status) + VALUES ($1, 'free', 'active')`, + [user.id] + ); + + // Generate email verification token + const verificationToken = generateSecureToken(); + const tokenHash = hashToken(verificationToken); + const expiresAt = getTokenExpiry(EMAIL_VERIFICATION_EXPIRY_MS); + + await client.query( + `INSERT INTO email_verifications (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [user.id, tokenHash, expiresAt] + ); + + return { + user, + verificationToken, + }; + }); + + // TODO: Send verification email with result.verificationToken + + return { + success: true, + user: { + id: result.user.id, + email: result.user.email, + displayName: result.user.display_name, + emailVerified: false, + tier: "free", + }, + }; + } + + /** + * Authenticate a user and create a session + */ + async login(input: LoginInput): Promise { + const email = input.email.toLowerCase().trim(); + + // Fetch user + const userResult = await this.db.query( + `SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`, + [email] + ); + + if (userResult.rows.length === 0) { + // Use same error for non-existent users to prevent enumeration + return { success: false, error: "Invalid email or password" }; + } + + const user = userResult.rows[0]!; + + // Check if account is locked + if (user.locked_until && user.locked_until > new Date()) { + const remainingMs = user.locked_until.getTime() - Date.now(); + const remainingMinutes = Math.ceil(remainingMs / 60000); + return { + success: false, + error: `Account is locked. Try again in ${remainingMinutes} minutes`, + }; + } + + // Verify password + const isValid = await verifyPassword(input.password, user.password_hash); + + if (!isValid) { + // Increment failed attempts + const newAttempts = user.failed_login_attempts + 1; + const shouldLock = newAttempts >= MAX_FAILED_ATTEMPTS; + + await this.db.query( + `UPDATE users + SET failed_login_attempts = $1, + locked_until = $2 + WHERE id = $3`, + [ + newAttempts, + shouldLock + ? new Date(Date.now() + LOCKOUT_DURATION_MS) + : null, + user.id, + ] + ); + + if (shouldLock) { + return { + success: false, + error: `Too many failed attempts. Account locked for 15 minutes`, + }; + } + + return { success: false, error: "Invalid email or password" }; + } + + // Check if password needs rehashing + if (needsRehash(user.password_hash)) { + const newHash = await hashPassword(input.password); + await this.db.query( + "UPDATE users SET password_hash = $1 WHERE id = $2", + [newHash, user.id] + ); + } + + // Get subscription tier + const subscriptionResult = await this.db.query( + "SELECT tier FROM subscriptions WHERE user_id = $1", + [user.id] + ); + + const tier = subscriptionResult.rows[0]?.tier ?? "free"; + + // Create session + const sessionId = await this.createSession(user.id, { + userAgent: input.userAgent, + ipAddress: input.ipAddress, + }); + + // Generate tokens + const accessToken = await generateAccessToken({ + userId: user.id, + email: user.email, + tier, + }); + + const refreshToken = await generateRefreshToken({ + userId: user.id, + sessionId, + }); + + // Reset failed attempts and update last login + await this.db.query( + `UPDATE users + SET failed_login_attempts = 0, + locked_until = NULL, + last_login_at = NOW() + WHERE id = $1`, + [user.id] + ); + + return { + success: true, + user: { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerified: user.email_verified, + tier, + }, + tokens: { + accessToken, + refreshToken, + expiresIn: 900, // 15 minutes in seconds + }, + }; + } + + /** + * Refresh access token using a refresh token + */ + async refreshTokens( + refreshToken: string + ): Promise { + const payload = await verifyRefreshToken(refreshToken); + + if (!payload) { + return { success: false, error: "Invalid refresh token" }; + } + + // Verify session exists and is active + const sessionResult = await this.db.query( + `SELECT * FROM user_sessions + WHERE id = $1 + AND user_id = $2 + AND status = 'active' + AND expires_at > NOW()`, + [payload.sid, payload.sub] + ); + + if (sessionResult.rows.length === 0) { + return { success: false, error: "Session expired or revoked" }; + } + + const session = sessionResult.rows[0]!; + + // Get user and subscription + const userResult = await this.db.query( + "SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL", + [payload.sub] + ); + + if (userResult.rows.length === 0) { + return { success: false, error: "User not found" }; + } + + const user = userResult.rows[0]!; + + const subscriptionResult = await this.db.query( + "SELECT tier FROM subscriptions WHERE user_id = $1", + [user.id] + ); + + const tier = subscriptionResult.rows[0]?.tier ?? "free"; + + // Update session last used + await this.db.query( + "UPDATE user_sessions SET last_used_at = NOW() WHERE id = $1", + [session.id] + ); + + // Generate new tokens + const newAccessToken = await generateAccessToken({ + userId: user.id, + email: user.email, + tier, + }); + + const newRefreshToken = await generateRefreshToken({ + userId: user.id, + sessionId: session.id, + }); + + // Update refresh token hash in session + await this.db.query( + "UPDATE user_sessions SET refresh_token_hash = $1 WHERE id = $2", + [hashToken(newRefreshToken), session.id] + ); + + return { + success: true, + user: { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerified: user.email_verified, + tier, + }, + tokens: { + accessToken: newAccessToken, + refreshToken: newRefreshToken, + expiresIn: 900, + }, + }; + } + + /** + * Logout - revoke a session + */ + async logout(userId: string, sessionId: string): Promise { + await this.db.query( + `UPDATE user_sessions + SET status = 'revoked', revoked_at = NOW() + WHERE id = $1 AND user_id = $2`, + [sessionId, userId] + ); + } + + /** + * Logout from all sessions + */ + async logoutAll(userId: string): Promise { + await this.db.query( + `UPDATE user_sessions + SET status = 'revoked', revoked_at = NOW() + WHERE user_id = $1 AND status = 'active'`, + [userId] + ); + } + + /** + * Verify email with token + */ + async verifyEmail(token: string): Promise<{ success: boolean; error?: string }> { + const tokenHash = hashToken(token); + + const result = await this.db.query<{ + id: string; + user_id: string; + expires_at: Date; + used_at: Date | null; + }>( + `SELECT * FROM email_verifications + WHERE token_hash = $1`, + [tokenHash] + ); + + if (result.rows.length === 0) { + return { success: false, error: "Invalid verification token" }; + } + + const verification = result.rows[0]!; + + if (verification.used_at) { + return { success: false, error: "Token already used" }; + } + + if (verification.expires_at < new Date()) { + return { success: false, error: "Token expired" }; + } + + // Mark token as used and verify email + await withTransaction(this.db, async (client) => { + await client.query( + "UPDATE email_verifications SET used_at = NOW() WHERE id = $1", + [verification.id] + ); + + await client.query( + "UPDATE users SET email_verified = TRUE WHERE id = $1", + [verification.user_id] + ); + }); + + return { success: true }; + } + + /** + * Request password reset + */ + async requestPasswordReset( + email: string + ): Promise<{ success: boolean; token?: string }> { + const normalizedEmail = email.toLowerCase().trim(); + + const userResult = await this.db.query( + "SELECT id FROM users WHERE email = $1 AND deleted_at IS NULL", + [normalizedEmail] + ); + + // Always return success to prevent email enumeration + if (userResult.rows.length === 0) { + return { success: true }; + } + + const user = userResult.rows[0]!; + + // Invalidate existing reset tokens + await this.db.query( + "UPDATE password_resets SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", + [user.id] + ); + + // Generate new reset token + const resetToken = generateSecureToken(); + const tokenHash = hashToken(resetToken); + const expiresAt = getTokenExpiry(PASSWORD_RESET_EXPIRY_MS); + + await this.db.query( + `INSERT INTO password_resets (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [user.id, tokenHash, expiresAt] + ); + + // TODO: Send password reset email + + return { success: true, token: resetToken }; + } + + /** + * Reset password with token + */ + async resetPassword( + token: string, + newPassword: string + ): Promise<{ success: boolean; error?: string }> { + // Validate new password + const passwordErrors = validatePasswordStrength(newPassword); + if (passwordErrors.length > 0) { + return { success: false, error: passwordErrors.join("; ") }; + } + + const tokenHash = hashToken(token); + + const result = await this.db.query<{ + id: string; + user_id: string; + expires_at: Date; + used_at: Date | null; + }>( + "SELECT * FROM password_resets WHERE token_hash = $1", + [tokenHash] + ); + + if (result.rows.length === 0) { + return { success: false, error: "Invalid reset token" }; + } + + const reset = result.rows[0]!; + + if (reset.used_at) { + return { success: false, error: "Token already used" }; + } + + if (reset.expires_at < new Date()) { + return { success: false, error: "Token expired" }; + } + + // Hash new password and update + const passwordHash = await hashPassword(newPassword); + + await withTransaction(this.db, async (client) => { + await client.query( + "UPDATE password_resets SET used_at = NOW() WHERE id = $1", + [reset.id] + ); + + await client.query( + `UPDATE users + SET password_hash = $1, + failed_login_attempts = 0, + locked_until = NULL + WHERE id = $2`, + [passwordHash, reset.user_id] + ); + + // Revoke all sessions for security + await client.query( + `UPDATE user_sessions + SET status = 'revoked', revoked_at = NOW() + WHERE user_id = $1 AND status = 'active'`, + [reset.user_id] + ); + }); + + return { success: true }; + } + + /** + * Get user by ID + */ + async getUserById(userId: string): Promise { + const result = await this.db.query( + "SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL", + [userId] + ); + + return result.rows[0] ?? null; + } + + // Private helpers + + private async createSession( + userId: string, + metadata: { userAgent?: string; ipAddress?: string } + ): Promise { + const refreshToken = generateSecureToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = getTokenExpiry(REFRESH_TOKEN_EXPIRY_MS); + + const result = await this.db.query<{ id: string }>( + `INSERT INTO user_sessions (user_id, refresh_token_hash, user_agent, ip_address, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [userId, tokenHash, metadata.userAgent ?? null, metadata.ipAddress ?? null, expiresAt] + ); + + return result.rows[0]!.id; + } + + private isValidEmail(email: string): boolean { + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email) && email.length <= 255; + } +} diff --git a/saas/src/config/env.ts b/saas/src/config/env.ts new file mode 100644 index 000000000..6d72c55b7 --- /dev/null +++ b/saas/src/config/env.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +const envSchema = z.object({ + // Server + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + PORT: z.coerce.number().default(3000), + HOST: z.string().default("0.0.0.0"), + + // Database + DATABASE_URL: z.string().url(), + + // Redis (for sessions and rate limiting) + REDIS_URL: z.string().url().optional(), + + // JWT secrets + JWT_ACCESS_SECRET: z.string().min(32), + JWT_REFRESH_SECRET: z.string().min(32), + + // JWT expiry + JWT_ACCESS_EXPIRY: z.string().default("15m"), + JWT_REFRESH_EXPIRY: z.string().default("7d"), + + // Encryption + ENCRYPTION_KEY: z.string().min(32), // 256-bit key for AES-256 + + // Vault (optional, for production) + VAULT_ADDR: z.string().url().optional(), + VAULT_TOKEN: z.string().optional(), + + // Stripe (optional for billing) + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + + // Kubernetes (for orchestration) + KUBERNETES_NAMESPACE: z.string().default("moltbot-tenants"), + KUBERNETES_IN_CLUSTER: z.coerce.boolean().default(false), + + // Email (for verification emails) + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().optional(), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().email().optional(), + + // Frontend URL (for email links) + FRONTEND_URL: z.string().url().default("http://localhost:5173"), + + // Rate limiting + RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000), // 1 minute + RATE_LIMIT_MAX_REQUESTS: z.coerce.number().default(100), + + // Logging + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), +}); + +function loadEnv() { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + console.error("Invalid environment variables:"); + for (const issue of result.error.issues) { + console.error(` ${issue.path.join(".")}: ${issue.message}`); + } + throw new Error("Failed to load environment variables"); + } + + return result.data; +} + +export const env = loadEnv(); + +export type Env = z.infer; diff --git a/saas/src/crypto/encryption.ts b/saas/src/crypto/encryption.ts new file mode 100644 index 000000000..382f28d78 --- /dev/null +++ b/saas/src/crypto/encryption.ts @@ -0,0 +1,175 @@ +import crypto from "node:crypto"; +import { env } from "../config/env.js"; + +// AES-256-GCM configuration +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // 96 bits for GCM +const AUTH_TAG_LENGTH = 16; // 128 bits +const SALT_LENGTH = 16; +const KEY_LENGTH = 32; // 256 bits + +/** + * Derive an encryption key from the master key and a salt + * Uses PBKDF2 with SHA-256 + */ +function deriveKey(masterKey: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync(masterKey, salt, 100000, KEY_LENGTH, "sha256"); +} + +/** + * Encrypt data using AES-256-GCM + * Returns: salt (16 bytes) + iv (12 bytes) + authTag (16 bytes) + ciphertext + */ +export function encrypt(plaintext: string, masterKey?: string): Buffer { + const key = masterKey ?? env.ENCRYPTION_KEY; + + // Generate random salt and IV + const salt = crypto.randomBytes(SALT_LENGTH); + const iv = crypto.randomBytes(IV_LENGTH); + + // Derive key from master key + const derivedKey = deriveKey(key, salt); + + // Encrypt + const cipher = crypto.createCipheriv(ALGORITHM, derivedKey, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + // Combine: salt + iv + authTag + ciphertext + return Buffer.concat([salt, iv, authTag, encrypted]); +} + +/** + * Decrypt data encrypted with encrypt() + */ +export function decrypt(encryptedData: Buffer, masterKey?: string): string { + const key = masterKey ?? env.ENCRYPTION_KEY; + + // Extract components + const salt = encryptedData.subarray(0, SALT_LENGTH); + const iv = encryptedData.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const authTag = encryptedData.subarray( + SALT_LENGTH + IV_LENGTH, + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + ); + const ciphertext = encryptedData.subarray( + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH + ); + + // Derive key from master key + const derivedKey = deriveKey(key, salt); + + // Decrypt + const decipher = crypto.createDecipheriv(ALGORITHM, derivedKey, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + return decrypted.toString("utf8"); +} + +/** + * Encrypt data and return as base64 string + */ +export function encryptToBase64(plaintext: string, masterKey?: string): string { + return encrypt(plaintext, masterKey).toString("base64"); +} + +/** + * Decrypt base64-encoded encrypted data + */ +export function decryptFromBase64( + encryptedBase64: string, + masterKey?: string +): string { + return decrypt(Buffer.from(encryptedBase64, "base64"), masterKey); +} + +/** + * Encrypt an object as JSON + */ +export function encryptObject(obj: T, masterKey?: string): string { + const json = JSON.stringify(obj); + return encryptToBase64(json, masterKey); +} + +/** + * Decrypt an object from encrypted JSON + */ +export function decryptObject(encryptedBase64: string, masterKey?: string): T { + const json = decryptFromBase64(encryptedBase64, masterKey); + return JSON.parse(json) as T; +} + +/** + * Generate a per-tenant encryption key + * Derives from master key + tenant ID using HKDF + */ +export function deriveTenantKey( + tenantId: string, + masterKey?: string +): string { + const key = masterKey ?? env.ENCRYPTION_KEY; + + // Use HKDF to derive a tenant-specific key + const info = Buffer.from(`moltbot-tenant-${tenantId}`, "utf8"); + const salt = Buffer.alloc(KEY_LENGTH, 0); // Fixed salt for determinism + + // HKDF-Extract + const prk = crypto.createHmac("sha256", salt).update(key).digest(); + + // HKDF-Expand + const derived = crypto + .createHmac("sha256", prk) + .update(Buffer.concat([info, Buffer.from([1])])) + .digest(); + + return derived.toString("base64"); +} + +/** + * Generate a secure random string for tokens, API keys, etc. + */ +export function generateSecureRandomString(length: number = 32): string { + return crypto.randomBytes(length).toString("base64url"); +} + +/** + * Constant-time string comparison to prevent timing attacks + */ +export function secureCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + + if (bufA.length !== bufB.length) { + return false; + } + + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * Hash data using SHA-256 + */ +export function sha256(data: string): string { + return crypto.createHash("sha256").update(data).digest("hex"); +} + +/** + * Hash data using SHA-512 + */ +export function sha512(data: string): string { + return crypto.createHash("sha512").update(data).digest("hex"); +} diff --git a/saas/src/crypto/index.ts b/saas/src/crypto/index.ts new file mode 100644 index 000000000..5f9925605 --- /dev/null +++ b/saas/src/crypto/index.ts @@ -0,0 +1,15 @@ +export { + encrypt, + decrypt, + encryptToBase64, + decryptFromBase64, + encryptObject, + decryptObject, + deriveTenantKey, + generateSecureRandomString, + secureCompare, + sha256, + sha512, +} from "./encryption.js"; + +export { VaultClient, getVaultClient, tenantVaultPath } from "./vault.js"; diff --git a/saas/src/crypto/vault.ts b/saas/src/crypto/vault.ts new file mode 100644 index 000000000..7d5a474dc --- /dev/null +++ b/saas/src/crypto/vault.ts @@ -0,0 +1,193 @@ +import { env } from "../config/env.js"; + +interface VaultResponse { + data: T; + lease_id?: string; + renewable?: boolean; + lease_duration?: number; +} + +interface VaultTransitEncryptResponse { + ciphertext: string; +} + +interface VaultTransitDecryptResponse { + plaintext: string; +} + +interface VaultKVData { + data: Record; + metadata?: { + created_time: string; + version: number; + }; +} + +/** + * HashiCorp Vault client for secrets management + * Uses Transit engine for encryption and KV engine for secret storage + */ +export class VaultClient { + private baseUrl: string; + private token: string; + + constructor(address?: string, token?: string) { + this.baseUrl = address ?? env.VAULT_ADDR ?? "http://localhost:8200"; + this.token = token ?? env.VAULT_TOKEN ?? ""; + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise { + const url = `${this.baseUrl}/v1${path}`; + + const response = await fetch(url, { + method, + headers: { + "X-Vault-Token": this.token, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Vault request failed: ${response.status} - ${error}`); + } + + // Some endpoints return 204 No Content + if (response.status === 204) { + return {} as T; + } + + return response.json() as Promise; + } + + /** + * Check if Vault is available and authenticated + */ + async isHealthy(): Promise { + try { + const response = await fetch(`${this.baseUrl}/v1/sys/health`, { + method: "GET", + }); + return response.ok; + } catch { + return false; + } + } + + // Transit Engine Methods (for encryption) + + /** + * Create a named encryption key in the transit engine + */ + async createTransitKey(name: string): Promise { + await this.request("POST", `/transit/keys/${name}`, { + type: "aes256-gcm96", + }); + } + + /** + * Encrypt data using a named transit key + */ + async transitEncrypt(keyName: string, plaintext: string): Promise { + const base64Plaintext = Buffer.from(plaintext).toString("base64"); + + const response = await this.request>( + "POST", + `/transit/encrypt/${keyName}`, + { plaintext: base64Plaintext } + ); + + return response.data.ciphertext; + } + + /** + * Decrypt data using a named transit key + */ + async transitDecrypt(keyName: string, ciphertext: string): Promise { + const response = await this.request>( + "POST", + `/transit/decrypt/${keyName}`, + { ciphertext } + ); + + return Buffer.from(response.data.plaintext, "base64").toString("utf8"); + } + + /** + * Rotate a transit key + */ + async rotateTransitKey(keyName: string): Promise { + await this.request("POST", `/transit/keys/${keyName}/rotate`); + } + + // KV Engine Methods (for secret storage) + + /** + * Read a secret from KV v2 engine + */ + async kvGet(path: string): Promise | null> { + try { + const response = await this.request>( + "GET", + `/secret/data/${path}` + ); + return response.data.data; + } catch { + return null; + } + } + + /** + * Write a secret to KV v2 engine + */ + async kvPut(path: string, data: Record): Promise { + await this.request("POST", `/secret/data/${path}`, { data }); + } + + /** + * Delete a secret from KV v2 engine + */ + async kvDelete(path: string): Promise { + await this.request("DELETE", `/secret/data/${path}`); + } + + /** + * List secrets at a path + */ + async kvList(path: string): Promise { + try { + const response = await this.request>( + "LIST", + `/secret/metadata/${path}` + ); + return response.data.keys; + } catch { + return []; + } + } +} + +// Singleton instance +let vaultClient: VaultClient | null = null; + +/** + * Get the Vault client instance + */ +export function getVaultClient(): VaultClient { + if (!vaultClient) { + vaultClient = new VaultClient(); + } + return vaultClient; +} + +/** + * Create a tenant-specific Vault path + */ +export function tenantVaultPath(tenantId: string, subPath: string): string { + return `tenants/${tenantId}/${subPath}`; +} diff --git a/saas/src/db/client.ts b/saas/src/db/client.ts new file mode 100644 index 000000000..4c516c2b8 --- /dev/null +++ b/saas/src/db/client.ts @@ -0,0 +1,147 @@ +import pg from "pg"; +import { env } from "../config/env.js"; + +const { Pool } = pg; + +export interface DbClient { + query: >( + text: string, + params?: unknown[] + ) => Promise>; + getClient: () => Promise; + end: () => Promise; +} + +let pool: pg.Pool | null = null; + +export function getPool(): pg.Pool { + if (!pool) { + pool = new Pool({ + connectionString: env.DATABASE_URL, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + + pool.on("error", (err) => { + console.error("Unexpected database pool error:", err); + }); + } + return pool; +} + +export function createDbClient(): DbClient { + const p = getPool(); + + return { + query: >( + text: string, + params?: unknown[] + ) => p.query(text, params), + + getClient: () => p.connect(), + + end: () => p.end(), + }; +} + +// Transaction helper +export async function withTransaction( + db: DbClient, + fn: (client: pg.PoolClient) => Promise +): Promise { + const client = await db.getClient(); + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +// Type helpers for database rows +export interface UserRow { + id: string; + email: string; + email_verified: boolean; + password_hash: string; + display_name: string | null; + avatar_url: string | null; + totp_secret: Buffer | null; + totp_enabled: boolean; + failed_login_attempts: number; + locked_until: Date | null; + created_at: Date; + updated_at: Date; + last_login_at: Date | null; + deleted_at: Date | null; +} + +export interface SubscriptionRow { + id: string; + user_id: string; + tier: "free" | "starter" | "pro" | "enterprise"; + status: "active" | "canceled" | "past_due" | "trialing"; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + current_period_start: Date | null; + current_period_end: Date | null; + cancel_at_period_end: boolean; + created_at: Date; + updated_at: Date; + canceled_at: Date | null; +} + +export interface TenantRow { + id: string; + user_id: string; + namespace: string; + pod_name: string | null; + service_name: string | null; + status: "provisioning" | "active" | "suspended" | "terminated"; + cpu_limit: string; + memory_limit: string; + storage_limit: string; + vault_key_id: string | null; + gateway_port: number | null; + gateway_token_hash: string | null; + last_activity_at: Date; + scaled_down_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface UserSessionRow { + id: string; + user_id: string; + refresh_token_hash: string; + user_agent: string | null; + ip_address: string | null; + device_fingerprint: string | null; + status: "active" | "expired" | "revoked"; + created_at: Date; + expires_at: Date; + last_used_at: Date; + revoked_at: Date | null; +} + +export interface TierLimitsRow { + tier: "free" | "starter" | "pro" | "enterprise"; + daily_message_limit: number | null; + monthly_token_limit: bigint | null; + max_compute_hours_month: number | null; + max_concurrent_sessions: number; + max_storage_bytes: bigint; + voice_enabled: boolean; + video_enabled: boolean; + custom_models_enabled: boolean; + api_access_enabled: boolean; + api_rate_limit: number; + created_at: Date; + updated_at: Date; +} diff --git a/saas/src/db/migrate.ts b/saas/src/db/migrate.ts new file mode 100644 index 000000000..7d62a5896 --- /dev/null +++ b/saas/src/db/migrate.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDbClient } from "./client.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function migrate() { + console.log("Starting database migration..."); + + const db = createDbClient(); + + try { + // Read the schema file + const schemaPath = path.join(__dirname, "schema.sql"); + const schema = fs.readFileSync(schemaPath, "utf-8"); + + // Execute the schema + console.log("Executing schema..."); + await db.query(schema); + + console.log("Migration completed successfully!"); + } catch (error) { + console.error("Migration failed:", error); + process.exit(1); + } finally { + await db.end(); + } +} + +migrate(); diff --git a/saas/src/db/schema.sql b/saas/src/db/schema.sql new file mode 100644 index 000000000..7e57889ce --- /dev/null +++ b/saas/src/db/schema.sql @@ -0,0 +1,327 @@ +-- Moltbot SaaS Database Schema +-- PostgreSQL 15+ + +-- Enable required extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Enum types +CREATE TYPE subscription_tier AS ENUM ('free', 'starter', 'pro', 'enterprise'); +CREATE TYPE subscription_status AS ENUM ('active', 'canceled', 'past_due', 'trialing'); +CREATE TYPE tenant_status AS ENUM ('provisioning', 'active', 'suspended', 'terminated'); +CREATE TYPE session_status AS ENUM ('active', 'expired', 'revoked'); + +-- Users table +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + password_hash VARCHAR(255) NOT NULL, + + -- Profile + display_name VARCHAR(100), + avatar_url TEXT, + + -- Security + totp_secret BYTEA, -- Encrypted TOTP secret + totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, + failed_login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + + -- Soft delete + deleted_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL; +CREATE INDEX idx_users_created_at ON users(created_at); + +-- Email verification tokens +CREATE TABLE email_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 of token + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + used_at TIMESTAMPTZ +); + +CREATE INDEX idx_email_verifications_user ON email_verifications(user_id); +CREATE INDEX idx_email_verifications_expires ON email_verifications(expires_at); + +-- Password reset tokens +CREATE TABLE password_resets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + used_at TIMESTAMPTZ +); + +CREATE INDEX idx_password_resets_user ON password_resets(user_id); +CREATE INDEX idx_password_resets_expires ON password_resets(expires_at); + +-- User sessions (JWT refresh tokens) +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Token info + refresh_token_hash VARCHAR(64) NOT NULL UNIQUE, + + -- Session metadata + user_agent TEXT, + ip_address INET, + device_fingerprint VARCHAR(64), + + -- Status + status session_status NOT NULL DEFAULT 'active', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_user_sessions_user ON user_sessions(user_id) WHERE status = 'active'; +CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at); +CREATE INDEX idx_user_sessions_refresh ON user_sessions(refresh_token_hash); + +-- Subscriptions +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Subscription info + tier subscription_tier NOT NULL DEFAULT 'free', + status subscription_status NOT NULL DEFAULT 'active', + + -- Stripe integration + stripe_customer_id VARCHAR(255), + stripe_subscription_id VARCHAR(255), + + -- Billing period + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + canceled_at TIMESTAMPTZ, + + CONSTRAINT unique_active_subscription UNIQUE (user_id) +); + +CREATE INDEX idx_subscriptions_user ON subscriptions(user_id); +CREATE INDEX idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id); +CREATE INDEX idx_subscriptions_stripe_sub ON subscriptions(stripe_subscription_id); + +-- Tenant instances (compute resources) +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Kubernetes resources + namespace VARCHAR(63) NOT NULL UNIQUE, -- k8s namespace limit + pod_name VARCHAR(63), + service_name VARCHAR(63), + + -- Status + status tenant_status NOT NULL DEFAULT 'provisioning', + + -- Resource allocation + cpu_limit VARCHAR(20) NOT NULL DEFAULT '500m', + memory_limit VARCHAR(20) NOT NULL DEFAULT '512Mi', + storage_limit VARCHAR(20) NOT NULL DEFAULT '1Gi', + + -- Encryption + vault_key_id VARCHAR(255), -- Vault transit key reference + + -- Gateway connection + gateway_port INTEGER, + gateway_token_hash VARCHAR(64), -- For internal auth + + -- Scaling + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + scaled_down_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_tenant UNIQUE (user_id) +); + +CREATE INDEX idx_tenants_user ON tenants(user_id); +CREATE INDEX idx_tenants_status ON tenants(status); +CREATE INDEX idx_tenants_last_activity ON tenants(last_activity_at); +CREATE INDEX idx_tenants_namespace ON tenants(namespace); + +-- Usage tracking (for billing and limits) +CREATE TABLE usage_records ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + + -- Usage metrics + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- Message counts + messages_sent INTEGER NOT NULL DEFAULT 0, + messages_received INTEGER NOT NULL DEFAULT 0, + + -- Token usage + tokens_input BIGINT NOT NULL DEFAULT 0, + tokens_output BIGINT NOT NULL DEFAULT 0, + + -- Compute time (seconds) + compute_seconds INTEGER NOT NULL DEFAULT 0, + + -- Storage (bytes) + storage_bytes BIGINT NOT NULL DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_usage_period UNIQUE (user_id, period_start, period_end) +); + +CREATE INDEX idx_usage_user_period ON usage_records(user_id, period_start, period_end); +CREATE INDEX idx_usage_tenant ON usage_records(tenant_id); + +-- API keys for programmatic access +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Key info + name VARCHAR(100) NOT NULL, + key_prefix VARCHAR(8) NOT NULL, -- First 8 chars for identification + key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 of full key + + -- Permissions + scopes TEXT[] NOT NULL DEFAULT '{}', + + -- Rate limiting + rate_limit INTEGER, -- Requests per minute, NULL = use tier default + + -- Expiration + expires_at TIMESTAMPTZ, + + -- Metadata + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_api_keys_user ON api_keys(user_id) WHERE revoked_at IS NULL; +CREATE INDEX idx_api_keys_hash ON api_keys(key_hash); +CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix); + +-- Audit log for security events +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + + -- Event info + event_type VARCHAR(50) NOT NULL, + event_category VARCHAR(50) NOT NULL, + description TEXT, + + -- Context + ip_address INET, + user_agent TEXT, + + -- Additional data (JSON) + metadata JSONB, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id); +CREATE INDEX idx_audit_logs_event ON audit_logs(event_type); +CREATE INDEX idx_audit_logs_created ON audit_logs(created_at); + +-- Tier limits configuration +CREATE TABLE tier_limits ( + tier subscription_tier PRIMARY KEY, + + -- Message limits (per day) + daily_message_limit INTEGER, -- NULL = unlimited + + -- Token limits (per month) + monthly_token_limit BIGINT, + + -- Compute limits + max_compute_hours_month INTEGER, + max_concurrent_sessions INTEGER NOT NULL DEFAULT 1, + + -- Storage limits (bytes) + max_storage_bytes BIGINT NOT NULL, + + -- Features + voice_enabled BOOLEAN NOT NULL DEFAULT FALSE, + video_enabled BOOLEAN NOT NULL DEFAULT FALSE, + custom_models_enabled BOOLEAN NOT NULL DEFAULT FALSE, + api_access_enabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Rate limits + api_rate_limit INTEGER NOT NULL DEFAULT 60, -- Requests per minute + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Insert default tier limits +INSERT INTO tier_limits (tier, daily_message_limit, monthly_token_limit, max_compute_hours_month, max_storage_bytes, voice_enabled, video_enabled, custom_models_enabled, api_access_enabled, api_rate_limit) VALUES + ('free', 50, 100000, 10, 104857600, FALSE, FALSE, FALSE, FALSE, 10), -- 100MB storage + ('starter', 500, 1000000, 100, 1073741824, TRUE, FALSE, FALSE, TRUE, 60), -- 1GB storage + ('pro', NULL, 10000000, 500, 10737418240, TRUE, TRUE, TRUE, TRUE, 300), -- 10GB storage + ('enterprise', NULL, NULL, NULL, 107374182400, TRUE, TRUE, TRUE, TRUE, 1000); -- 100GB storage + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Apply updated_at triggers +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_usage_records_updated_at BEFORE UPDATE ON usage_records + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_tier_limits_updated_at BEFORE UPDATE ON tier_limits + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Cleanup job helper: Delete expired tokens +CREATE OR REPLACE FUNCTION cleanup_expired_tokens() +RETURNS void AS $$ +BEGIN + DELETE FROM email_verifications WHERE expires_at < NOW() - INTERVAL '7 days'; + DELETE FROM password_resets WHERE expires_at < NOW() - INTERVAL '7 days'; + DELETE FROM user_sessions WHERE expires_at < NOW() - INTERVAL '30 days'; +END; +$$ LANGUAGE plpgsql; diff --git a/saas/src/server.ts b/saas/src/server.ts new file mode 100644 index 000000000..9fb99d6cd --- /dev/null +++ b/saas/src/server.ts @@ -0,0 +1,114 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; +import { secureHeaders } from "hono/secure-headers"; +import { timing } from "hono/timing"; +import { HTTPException } from "hono/http-exception"; +import { env } from "./config/env.js"; +import { createDbClient } from "./db/client.js"; +import { createAuthRoutes } from "./api/routes/auth.js"; +import { createAgentRoutes } from "./api/routes/agent.js"; +import { createUsageRoutes } from "./api/routes/usage.js"; +import { createBillingRoutes } from "./api/routes/billing.js"; + +// Create database client +const db = createDbClient(); + +// Create Hono app +const app = new Hono(); + +// Global middleware +app.use("*", logger()); +app.use("*", timing()); +app.use("*", secureHeaders()); +app.use( + "*", + cors({ + origin: env.FRONTEND_URL, + credentials: true, + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"], + exposeHeaders: ["X-Request-Id", "X-RateLimit-Remaining"], + maxAge: 86400, + }) +); + +// Health check +app.get("/health", (c) => { + return c.json({ + status: "ok", + timestamp: new Date().toISOString(), + version: process.env["npm_package_version"] ?? "unknown", + }); +}); + +// API routes +app.route("/api/auth", createAuthRoutes(db)); +app.route("/api/agent", createAgentRoutes(db)); +app.route("/api/usage", createUsageRoutes(db)); +app.route("/api/billing", createBillingRoutes(db)); + +// 404 handler +app.notFound((c) => { + return c.json( + { + error: "Not Found", + message: `Route ${c.req.method} ${c.req.path} not found`, + }, + 404 + ); +}); + +// Global error handler +app.onError((err, c) => { + console.error("Unhandled error:", err); + + if (err instanceof HTTPException) { + return c.json( + { + error: err.message, + }, + err.status + ); + } + + // Don't expose internal errors in production + const message = + env.NODE_ENV === "production" + ? "Internal server error" + : err.message; + + return c.json( + { + error: message, + }, + 500 + ); +}); + +// Start server +console.log(`Starting Moltbot SaaS API server...`); +console.log(`Environment: ${env.NODE_ENV}`); +console.log(`Listening on http://${env.HOST}:${env.PORT}`); + +serve({ + fetch: app.fetch, + hostname: env.HOST, + port: env.PORT, +}); + +// Graceful shutdown +process.on("SIGTERM", async () => { + console.log("SIGTERM received, shutting down..."); + await db.end(); + process.exit(0); +}); + +process.on("SIGINT", async () => { + console.log("SIGINT received, shutting down..."); + await db.end(); + process.exit(0); +}); + +export { app }; diff --git a/saas/tsconfig.json b/saas/tsconfig.json new file mode 100644 index 000000000..cd1fe2939 --- /dev/null +++ b/saas/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}