feat(saas): add Sprint 1 core infrastructure for SaaS platform
- Add PostgreSQL database schema (users, subscriptions, tenants, sessions) - Implement authentication service with Argon2id password hashing - Add JWT-based session management with access/refresh tokens - Create REST API routes (auth, agent, usage, billing) - Add encryption utilities (AES-256-GCM) and Vault integration - Set up Docker Compose development environment - Add Hono-based API server with middleware This establishes the foundation for transforming moltbot into a multi-tenant SaaS platform with secure authentication and per-tenant encryption. https://claude.ai/code/session_01UzVUSnxfEecZE8Yes3Zqw9
This commit is contained in:
parent
699784dbee
commit
727d2bf1f9
53
saas/.env.example
Normal file
53
saas/.env.example
Normal file
@ -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
|
||||||
31
saas/.gitignore
vendored
Normal file
31
saas/.gitignore
vendored
Normal file
@ -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/
|
||||||
56
saas/Dockerfile.api
Normal file
56
saas/Dockerfile.api
Normal file
@ -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"]
|
||||||
112
saas/docker-compose.dev.yaml
Normal file
112
saas/docker-compose.dev.yaml
Normal file
@ -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
|
||||||
35
saas/package.json
Normal file
35
saas/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
saas/src/api/routes/agent.ts
Normal file
226
saas/src/api/routes/agent.ts
Normal file
@ -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<TenantRow>(
|
||||||
|
`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<TenantRow>(
|
||||||
|
"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<TenantRow>(
|
||||||
|
`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<TenantRow>(
|
||||||
|
"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<TenantRow>(
|
||||||
|
"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<TenantRow>(
|
||||||
|
"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<TenantRow>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
192
saas/src/api/routes/auth.ts
Normal file
192
saas/src/api/routes/auth.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
221
saas/src/api/routes/billing.ts
Normal file
221
saas/src/api/routes/billing.ts
Normal file
@ -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<SubscriptionRow>(
|
||||||
|
"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<TierLimitsRow>(
|
||||||
|
"SELECT * FROM tier_limits ORDER BY tier"
|
||||||
|
);
|
||||||
|
|
||||||
|
const pricing: Record<string, { monthly: number; yearly: number }> = {
|
||||||
|
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<SubscriptionRow>(
|
||||||
|
"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<SubscriptionRow>(
|
||||||
|
"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<SubscriptionRow>(
|
||||||
|
"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;
|
||||||
|
}
|
||||||
137
saas/src/api/routes/usage.ts
Normal file
137
saas/src/api/routes/usage.ts
Normal file
@ -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<UsageRecordRow>(
|
||||||
|
`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<SubscriptionRow>(
|
||||||
|
"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<TierLimitsRow>(
|
||||||
|
"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<UsageRecordRow>(
|
||||||
|
`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;
|
||||||
|
}
|
||||||
17
saas/src/auth/index.ts
Normal file
17
saas/src/auth/index.ts
Normal file
@ -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";
|
||||||
158
saas/src/auth/jwt.ts
Normal file
158
saas/src/auth/jwt.ts
Normal file
@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<AccessTokenPayload | null> {
|
||||||
|
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<RefreshTokenPayload | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
111
saas/src/auth/middleware.ts
Normal file
111
saas/src/auth/middleware.ts
Normal file
@ -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<Response | void> {
|
||||||
|
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<Response | void> {
|
||||||
|
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<Response | void> => {
|
||||||
|
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<Response | void> => {
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
84
saas/src/auth/password.ts
Normal file
84
saas/src/auth/password.ts
Normal file
@ -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<string> {
|
||||||
|
return argon2.hash(password, ARGON2_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a password against a hash
|
||||||
|
*/
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
569
saas/src/auth/service.ts
Normal file
569
saas/src/auth/service.ts
Normal file
@ -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<AuthResult> {
|
||||||
|
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<UserRow>(
|
||||||
|
"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<UserRow>(
|
||||||
|
`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<AuthResult> {
|
||||||
|
const email = input.email.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Fetch user
|
||||||
|
const userResult = await this.db.query<UserRow>(
|
||||||
|
`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<SubscriptionRow>(
|
||||||
|
"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<AuthResult> {
|
||||||
|
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<UserSessionRow>(
|
||||||
|
`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<UserRow>(
|
||||||
|
"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<SubscriptionRow>(
|
||||||
|
"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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<UserRow>(
|
||||||
|
"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<UserRow | null> {
|
||||||
|
const result = await this.db.query<UserRow>(
|
||||||
|
"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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
saas/src/config/env.ts
Normal file
74
saas/src/config/env.ts
Normal file
@ -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<typeof envSchema>;
|
||||||
175
saas/src/crypto/encryption.ts
Normal file
175
saas/src/crypto/encryption.ts
Normal file
@ -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<T>(obj: T, masterKey?: string): string {
|
||||||
|
const json = JSON.stringify(obj);
|
||||||
|
return encryptToBase64(json, masterKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an object from encrypted JSON
|
||||||
|
*/
|
||||||
|
export function decryptObject<T>(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");
|
||||||
|
}
|
||||||
15
saas/src/crypto/index.ts
Normal file
15
saas/src/crypto/index.ts
Normal file
@ -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";
|
||||||
193
saas/src/crypto/vault.ts
Normal file
193
saas/src/crypto/vault.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
interface VaultResponse<T> {
|
||||||
|
data: T;
|
||||||
|
lease_id?: string;
|
||||||
|
renewable?: boolean;
|
||||||
|
lease_duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultTransitEncryptResponse {
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultTransitDecryptResponse {
|
||||||
|
plaintext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultKVData {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
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<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Vault is available and authenticated
|
||||||
|
*/
|
||||||
|
async isHealthy(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
await this.request("POST", `/transit/keys/${name}`, {
|
||||||
|
type: "aes256-gcm96",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data using a named transit key
|
||||||
|
*/
|
||||||
|
async transitEncrypt(keyName: string, plaintext: string): Promise<string> {
|
||||||
|
const base64Plaintext = Buffer.from(plaintext).toString("base64");
|
||||||
|
|
||||||
|
const response = await this.request<VaultResponse<VaultTransitEncryptResponse>>(
|
||||||
|
"POST",
|
||||||
|
`/transit/encrypt/${keyName}`,
|
||||||
|
{ plaintext: base64Plaintext }
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data using a named transit key
|
||||||
|
*/
|
||||||
|
async transitDecrypt(keyName: string, ciphertext: string): Promise<string> {
|
||||||
|
const response = await this.request<VaultResponse<VaultTransitDecryptResponse>>(
|
||||||
|
"POST",
|
||||||
|
`/transit/decrypt/${keyName}`,
|
||||||
|
{ ciphertext }
|
||||||
|
);
|
||||||
|
|
||||||
|
return Buffer.from(response.data.plaintext, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate a transit key
|
||||||
|
*/
|
||||||
|
async rotateTransitKey(keyName: string): Promise<void> {
|
||||||
|
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<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<VaultResponse<VaultKVData>>(
|
||||||
|
"GET",
|
||||||
|
`/secret/data/${path}`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a secret to KV v2 engine
|
||||||
|
*/
|
||||||
|
async kvPut(path: string, data: Record<string, unknown>): Promise<void> {
|
||||||
|
await this.request("POST", `/secret/data/${path}`, { data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a secret from KV v2 engine
|
||||||
|
*/
|
||||||
|
async kvDelete(path: string): Promise<void> {
|
||||||
|
await this.request("DELETE", `/secret/data/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List secrets at a path
|
||||||
|
*/
|
||||||
|
async kvList(path: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<VaultResponse<{ keys: string[] }>>(
|
||||||
|
"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}`;
|
||||||
|
}
|
||||||
147
saas/src/db/client.ts
Normal file
147
saas/src/db/client.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
export interface DbClient {
|
||||||
|
query: <T extends pg.QueryResultRow = Record<string, unknown>>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
) => Promise<pg.QueryResult<T>>;
|
||||||
|
getClient: () => Promise<pg.PoolClient>;
|
||||||
|
end: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: <T extends pg.QueryResultRow = Record<string, unknown>>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
) => p.query<T>(text, params),
|
||||||
|
|
||||||
|
getClient: () => p.connect(),
|
||||||
|
|
||||||
|
end: () => p.end(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction helper
|
||||||
|
export async function withTransaction<T>(
|
||||||
|
db: DbClient,
|
||||||
|
fn: (client: pg.PoolClient) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
31
saas/src/db/migrate.ts
Normal file
31
saas/src/db/migrate.ts
Normal file
@ -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();
|
||||||
327
saas/src/db/schema.sql
Normal file
327
saas/src/db/schema.sql
Normal file
@ -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;
|
||||||
114
saas/src/server.ts
Normal file
114
saas/src/server.ts
Normal file
@ -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 };
|
||||||
23
saas/tsconfig.json
Normal file
23
saas/tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user