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