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:
Claude 2026-01-30 00:34:53 +00:00
parent 699784dbee
commit 727d2bf1f9
No known key found for this signature in database
23 changed files with 3101 additions and 0 deletions

53
saas/.env.example Normal file
View 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
View 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
View 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"]

View 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
View 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"
}
}

View 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
View 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;
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}