diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 679c5c9c..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebSearch", - "WebFetch(domain:getstream.io)", - "WebFetch(domain:ably.com)", - "WebFetch(domain:pusher.com)", - "WebFetch(domain:getstream.github.io)", - "WebFetch(domain:www.producthunt.com)", - "WebFetch(domain:www.capterra.com)", - "WebFetch(domain:agentrelay.tech)", - "WebFetch(domain:agentcommunicationprotocol.dev)", - "WebFetch(domain:a2a-protocol.org)", - "WebFetch(domain:support.getstream.io)", - "WebFetch(domain:www.cometchat.com)", - "WebFetch(domain:github.com)", - "WebFetch(domain:microsoft.github.io)", - "WebFetch(domain:developers.cloudflare.com)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:clawhub.ai)", - "WebFetch(domain:docs.openclaw.ai)", - "WebFetch(domain:missioncontrolhq.ai)", - "WebFetch(domain:usemissioncontrol.com)", - "WebFetch(domain:www.dan-malone.com)", - "WebFetch(domain:aiagentslist.com)", - "WebFetch(domain:docs.missionsquad.ai)", - "WebFetch(domain:missionsquad.ai)", - "Bash(grep:*)", - "Bash(npm install:*)", - "Bash(npx turbo build:*)", - "Bash(npx turbo lint)", - "Bash(npm run prebuild:*)", - "Bash(npx vitest run:*)", - "Bash(npx turbo test:*)", - "Bash(ls:*)", - "Bash(gh pr view:*)", - "Bash(gh api:*)", - "WebFetch(domain:sst.dev)", - "Bash(npm uninstall:*)", - "Bash(npx tsc:*)", - "Bash(gh pr checks:*)", - "Bash(gh run view:*)", - "Bash(gh run list:*)", - "Bash(gh run watch:*)", - "Bash(git rm:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(chmod:*)", - "Bash(npx drizzle-kit generate:*)", - "Bash(npm ls:*)", - "Bash(npx --workspace=packages/server drizzle-kit generate:*)", - "Bash(npx next build)", - "Bash(npx tsx:*)" - ] - }, - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "relaycast" - ] -} diff --git a/.gitignore b/.gitignore index 1fa6ae6b..055fc92a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ coverage/ # MCP configs (contain API keys) .claude/mcp.json +.claude/settings.local.json .codex/mcp.json .gemini/ diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 6bdf68d9..b2630c19 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -31,12 +31,21 @@ export const TEST_API_KEY_HASH = _apiKeyHash; export const TEST_AGENT_TOKEN = _agentToken; export const TEST_AGENT_TOKEN_HASH = _agentTokenHash; +export const FAKE_ORGANIZATION = { + id: 'org_123', + name: 'test-org', + plan: 'free', + orgApiKeyHash: null, + createdAt: new Date(), +}; + export const FAKE_WORKSPACE = { id: 'ws_123', name: 'test-workspace', apiKeyHash: TEST_API_KEY_HASH, systemPrompt: null, plan: 'free' as const, + organizationId: 'org_123', createdAt: new Date(), metadata: {}, }; @@ -72,10 +81,16 @@ export function createMockKV(): KVNamespace { * Create a mock DB that returns the fake workspace for workspace-key auth. */ export function mockDbForWorkspaceAuth() { + let callCount = 0; return { select: () => ({ from: () => ({ - where: vi.fn().mockResolvedValue([FAKE_WORKSPACE]), + where: vi.fn().mockImplementation(() => { + callCount++; + // Auth middleware queries: 1=workspace, 2=organization + if (callCount % 2 === 1) return Promise.resolve([FAKE_WORKSPACE]); + return Promise.resolve([FAKE_ORGANIZATION]); + }), }), }), insert: () => ({ @@ -110,8 +125,11 @@ export function mockDbForAgentAuth() { from: () => ({ where: vi.fn().mockImplementation(() => { callCount++; - if (callCount % 2 === 1) return Promise.resolve([FAKE_AGENT]); - return Promise.resolve([FAKE_WORKSPACE]); + // Auth middleware queries: 1=agent, 2=workspace, 3=organization, then repeats + const phase = ((callCount - 1) % 3) + 1; + if (phase === 1) return Promise.resolve([FAKE_AGENT]); + if (phase === 2) return Promise.resolve([FAKE_WORKSPACE]); + return Promise.resolve([FAKE_ORGANIZATION]); }), }), }), diff --git a/packages/server/src/db/migrations/0002_organizations.sql b/packages/server/src/db/migrations/0002_organizations.sql new file mode 100644 index 00000000..995837ef --- /dev/null +++ b/packages/server/src/db/migrations/0002_organizations.sql @@ -0,0 +1,63 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + email_verified INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Create organizations table +CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + plan TEXT NOT NULL DEFAULT 'free', + org_api_key_hash TEXT UNIQUE, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Create org_memberships table +CREATE TABLE IF NOT EXISTS org_memberships ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (user_id, organization_id) +); +CREATE INDEX IF NOT EXISTS idx_org_memberships_org ON org_memberships(organization_id); +CREATE INDEX IF NOT EXISTS idx_org_memberships_user ON org_memberships(user_id); + +-- Create sessions table +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + active_org_id TEXT REFERENCES organizations(id), + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- Create email_verifications table +CREATE TABLE IF NOT EXISTS email_verifications ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + code TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications(user_id); + +-- Add new columns to workspaces +ALTER TABLE workspaces ADD COLUMN organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE workspaces ADD COLUMN last_activity_at INTEGER; +ALTER TABLE workspaces ADD COLUMN deleted_at INTEGER; + +-- Backfill: create a shadow org for each existing workspace and link them. +-- Uses the workspace id as the org id for simplicity in a one-time migration. +INSERT INTO organizations (id, name, created_at) +SELECT id, 'shadow-' || name, created_at FROM workspaces WHERE organization_id IS NULL; + +UPDATE workspaces SET organization_id = id WHERE organization_id IS NULL; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 218817f5..7821c7ef 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -9,15 +9,106 @@ import { import { sql } from 'drizzle-orm'; import type { AnySQLiteColumn } from 'drizzle-orm/sqlite-core'; +// ============================================ +// Users +// ============================================ +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), + passwordHash: text('password_hash').notNull(), + name: text('name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), +}); + +// ============================================ +// Organizations +// ============================================ +export const organizations = sqliteTable('organizations', { + id: text('id').primaryKey(), + name: text('name').notNull(), + plan: text('plan').notNull().default('free'), + orgApiKeyHash: text('org_api_key_hash').unique(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), +}); + +// ============================================ +// Org Memberships +// ============================================ +export const orgMemberships = sqliteTable( + 'org_memberships', + { + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + role: text('role').notNull().default('member'), // 'owner' | 'admin' | 'member' + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + }, + (table) => [ + primaryKey({ columns: [table.userId, table.organizationId] }), + index('idx_org_memberships_org').on(table.organizationId), + index('idx_org_memberships_user').on(table.userId), + ], +); + +// ============================================ +// Sessions (user auth) +// ============================================ +export const sessions = sqliteTable( + 'sessions', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + activeOrgId: text('active_org_id') + .references(() => organizations.id), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + }, + (table) => [ + index('idx_sessions_user').on(table.userId), + index('idx_sessions_expires').on(table.expiresAt), + ], +); + +// ============================================ +// Email Verifications +// ============================================ +export const emailVerifications = sqliteTable( + 'email_verifications', + { + id: text('id').primaryKey(), + email: text('email').notNull(), + code: text('code').notNull(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), + }, + (table) => [ + index('idx_email_verifications_user').on(table.userId), + ], +); + // ============================================ // Workspaces // ============================================ export const workspaces = sqliteTable('workspaces', { id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), name: text('name').notNull().unique(), apiKeyHash: text('api_key_hash').notNull().unique(), systemPrompt: text('system_prompt'), - plan: text('plan').notNull().default('free'), + plan: text('plan').notNull().default('free'), // deprecated: read from org + lastActivityAt: integer('last_activity_at', { mode: 'timestamp' }), + deletedAt: integer('deleted_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), metadata: text('metadata', { mode: 'json' }).default('{}'), }); diff --git a/packages/server/src/engine/file.ts b/packages/server/src/engine/file.ts index 8882700c..08fb0b8c 100644 --- a/packages/server/src/engine/file.ts +++ b/packages/server/src/engine/file.ts @@ -8,6 +8,7 @@ import { eq, and, ne, sql } from 'drizzle-orm'; import { files, agents } from '../db/schema.js'; import { generateId } from './snowflake.js'; import type { getDb } from '../db/index.js'; +import { touchLastActivity } from './workspace.js'; type Db = ReturnType; @@ -54,6 +55,8 @@ export async function createUpload( }); const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 }); + await touchLastActivity(db, workspaceId); + return { id, upload_url: uploadUrl, diff --git a/packages/server/src/engine/message.ts b/packages/server/src/engine/message.ts index 4a96ad16..7ed38508 100644 --- a/packages/server/src/engine/message.ts +++ b/packages/server/src/engine/message.ts @@ -2,6 +2,7 @@ import { eq, and, sql, isNull, lt, gt, inArray } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; import { messages, channels, agents, reactions, readReceipts, messageAttachments, files } from '../db/schema.js'; import { generateId } from './snowflake.js'; +import { touchLastActivity } from './workspace.js'; type Db = ReturnType; @@ -75,10 +76,11 @@ export async function postMessage( await db.insert(messageAttachments).values(attachmentValues); } - // Fetch attachment details and agent name + // Fetch attachment details and agent name; track activity const [attachmentMap, [agent]] = await Promise.all([ hasAttachments ? fetchAttachmentsBatch(db, [messageId]) : Promise.resolve(new Map()), db.select({ name: agents.name }).from(agents).where(eq(agents.id, agentId)), + touchLastActivity(db, workspaceId), ]); const attachments = attachmentMap.get(messageId) || []; diff --git a/packages/server/src/engine/organization.ts b/packages/server/src/engine/organization.ts new file mode 100644 index 00000000..a55eb96c --- /dev/null +++ b/packages/server/src/engine/organization.ts @@ -0,0 +1,297 @@ +import crypto from 'node:crypto'; +import { eq, and } from 'drizzle-orm'; +import type { getDb } from '../db/index.js'; +import { organizations, workspaces, orgMemberships, users } from '../db/schema.js'; +import { generateId } from './snowflake.js'; +import { hashToken } from '../middleware/auth.js'; + +type Db = ReturnType; + +function generateOrgApiKey(): { key: string; hash: string } { + const key = `rk_org_${crypto.randomBytes(16).toString('hex')}`; + const hash = hashToken(key); + return { key, hash }; +} + +/** + * Create a new organization with the given user as owner. + */ +export async function createOrg( + db: Db, + userId: string, + input: { name: string }, +) { + const orgId = generateId(); + const { key: orgApiKey, hash: orgApiKeyHash } = generateOrgApiKey(); + + const [org] = await db + .insert(organizations) + .values({ + id: orgId, + name: input.name, + orgApiKeyHash, + }) + .returning(); + + // Add the creating user as owner + await db.insert(orgMemberships).values({ + userId, + organizationId: orgId, + role: 'owner', + }); + + return { + organization_id: orgId, + org_api_key: orgApiKey, + created_at: org.createdAt.toISOString(), + }; +} + +/** + * Create a shadow org for anonymous workspace creation (no user, no API key). + */ +export async function createShadowOrg(db: Db, name: string) { + const orgId = generateId(); + await db.insert(organizations).values({ + id: orgId, + name: `shadow-${name}`, + }); + return orgId; +} + +export async function getOrg(db: Db, orgId: string) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, orgId)); + if (!org) return null; + + return { + id: org.id, + name: org.name, + plan: org.plan, + created_at: org.createdAt.toISOString(), + }; +} + +export async function getOrgByApiKeyHash(db: Db, hash: string) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.orgApiKeyHash, hash)); + return org || null; +} + +export async function updateOrg( + db: Db, + orgId: string, + updates: { name?: string }, +) { + const setClause: Record = {}; + if (updates.name !== undefined) setClause.name = updates.name; + + if (Object.keys(setClause).length === 0) { + return getOrg(db, orgId); + } + + await db + .update(organizations) + .set(setClause) + .where(eq(organizations.id, orgId)); + + return getOrg(db, orgId); +} + +export async function claimWorkspace( + db: Db, + orgId: string, + workspaceApiKey: string, +) { + const hash = hashToken(workspaceApiKey); + const [workspace] = await db + .select() + .from(workspaces) + .where(eq(workspaces.apiKeyHash, hash)); + + if (!workspace) { + const err = new Error('Invalid workspace API key'); + Object.assign(err, { code: 'invalid_workspace_key', status: 400 }); + throw err; + } + + // Check the workspace's current org is a shadow org (no API key = shadow) + const [currentOrg] = await db + .select() + .from(organizations) + .where(eq(organizations.id, workspace.organizationId)); + + if (currentOrg && currentOrg.orgApiKeyHash) { + const err = new Error('This workspace already belongs to a claimed organization'); + Object.assign(err, { code: 'workspace_already_claimed', status: 409 }); + throw err; + } + + // Move workspace to the new org + await db + .update(workspaces) + .set({ organizationId: orgId }) + .where(eq(workspaces.id, workspace.id)); + + // Delete the old shadow org if it has no remaining workspaces + if (currentOrg) { + const remaining = await db + .select() + .from(workspaces) + .where(eq(workspaces.organizationId, currentOrg.id)); + if (remaining.length === 0) { + await db.delete(organizations).where(eq(organizations.id, currentOrg.id)); + } + } + + return { workspace_id: workspace.id, organization_id: orgId }; +} + +export async function getOrgWorkspaces(db: Db, orgId: string) { + const rows = await db + .select() + .from(workspaces) + .where(eq(workspaces.organizationId, orgId)); + + return rows + .filter((w) => !w.deletedAt) + .map((w) => ({ + id: w.id, + name: w.name, + created_at: w.createdAt.toISOString(), + last_activity_at: w.lastActivityAt?.toISOString() ?? null, + })); +} + +export async function setOrgPlan( + db: Db, + orgId: string, + plan: string, +) { + await db + .update(organizations) + .set({ plan }) + .where(eq(organizations.id, orgId)); + + return getOrg(db, orgId); +} + +// ── Membership management ── + +export async function getOrgMembers(db: Db, orgId: string) { + const memberships = await db + .select() + .from(orgMemberships) + .where(eq(orgMemberships.organizationId, orgId)); + + const result: { + user_id: string; + organization_id: string; + role: string; + user_email: string; + user_name: string; + created_at: string; + }[] = []; + + for (const m of memberships) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, m.userId)); + if (user) { + result.push({ + user_id: user.id, + organization_id: orgId, + role: m.role, + user_email: user.email, + user_name: user.name, + created_at: m.createdAt.toISOString(), + }); + } + } + + return result; +} + +export async function inviteMember( + db: Db, + orgId: string, + email: string, + role: string = 'member', +) { + // Find user by email + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)); + + if (!user) { + const err = new Error('No user found with this email. They must sign up first.'); + Object.assign(err, { code: 'user_not_found', status: 404 }); + throw err; + } + + // Check if already a member + const [existing] = await db + .select() + .from(orgMemberships) + .where( + and( + eq(orgMemberships.userId, user.id), + eq(orgMemberships.organizationId, orgId), + ), + ); + + if (existing) { + const err = new Error('User is already a member of this organization'); + Object.assign(err, { code: 'already_member', status: 409 }); + throw err; + } + + await db.insert(orgMemberships).values({ + userId: user.id, + organizationId: orgId, + role, + }); + + return { + user_id: user.id, + organization_id: orgId, + role, + user_email: user.email, + user_name: user.name, + }; +} + +export async function removeMember( + db: Db, + orgId: string, + userId: string, +) { + // Don't allow removing the last owner + const members = await db + .select() + .from(orgMemberships) + .where(eq(orgMemberships.organizationId, orgId)); + + const owners = members.filter((m) => m.role === 'owner'); + const isOwner = owners.some((m) => m.userId === userId); + if (isOwner && owners.length === 1) { + const err = new Error('Cannot remove the last owner of an organization'); + Object.assign(err, { code: 'last_owner', status: 400 }); + throw err; + } + + await db + .delete(orgMemberships) + .where( + and( + eq(orgMemberships.userId, userId), + eq(orgMemberships.organizationId, orgId), + ), + ); +} diff --git a/packages/server/src/engine/reaction.ts b/packages/server/src/engine/reaction.ts index 6de57477..f7cf31fb 100644 --- a/packages/server/src/engine/reaction.ts +++ b/packages/server/src/engine/reaction.ts @@ -2,6 +2,7 @@ import { eq, and, sql } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; import { reactions, messages, agents, channels } from '../db/schema.js'; import { generateId } from './snowflake.js'; +import { touchLastActivity } from './workspace.js'; type Db = ReturnType; @@ -39,6 +40,8 @@ export async function addReaction( .values({ id, messageId, agentId, emoji }) .returning(); + await touchLastActivity(db, workspaceId); + return { id: reaction.id, message_id: reaction.messageId, diff --git a/packages/server/src/engine/ttl.ts b/packages/server/src/engine/ttl.ts new file mode 100644 index 00000000..7d46847a --- /dev/null +++ b/packages/server/src/engine/ttl.ts @@ -0,0 +1,114 @@ +import { eq, and, lt, or, isNull, isNotNull, sql } from 'drizzle-orm'; +import type { getDb } from '../db/index.js'; +import { organizations, workspaces, messages, sessions, emailVerifications } from '../db/schema.js'; +import type { Logger } from '../lib/logger.js'; + +type Db = ReturnType; + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; +const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000; + +/** + * Daily cron job: + * 1. Trim messages older than 30 days for free-plan orgs + * 2. Soft-delete workspaces inactive for 60 days (free orgs) + * 3. Hard-delete workspaces 30 days after soft-delete + * 4. Clean up expired sessions and email verifications + */ +export async function runTtlCleanup(db: Db, logger: Logger) { + const now = Date.now(); + + // 1. Trim old messages for free orgs + const freeOrgs = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.plan, 'free')); + + let trimmedMessages = 0; + const thirtyDaysAgo = new Date(now - THIRTY_DAYS_MS); + + for (const org of freeOrgs) { + // Get workspace IDs for this org + const orgWorkspaces = await db + .select({ id: workspaces.id }) + .from(workspaces) + .where( + and( + eq(workspaces.organizationId, org.id), + isNull(workspaces.deletedAt), + ), + ); + + for (const ws of orgWorkspaces) { + const result = await db + .delete(messages) + .where( + and( + eq(messages.workspaceId, ws.id), + lt(messages.createdAt, thirtyDaysAgo), + ), + ); + trimmedMessages += (result as any).changes ?? 0; + } + } + + if (trimmedMessages > 0) { + logger.info('Trimmed old messages for free orgs', { trimmed: trimmedMessages }); + } + + // 2. Soft-delete workspaces inactive for 60+ days (free orgs only) + const sixtyDaysAgo = new Date(now - SIXTY_DAYS_MS); + let softDeleted = 0; + + for (const org of freeOrgs) { + const staleWorkspaces = await db + .select({ id: workspaces.id, name: workspaces.name }) + .from(workspaces) + .where( + and( + eq(workspaces.organizationId, org.id), + isNull(workspaces.deletedAt), + or( + lt(workspaces.lastActivityAt, sixtyDaysAgo), + and(isNull(workspaces.lastActivityAt), lt(workspaces.createdAt, sixtyDaysAgo)), + ), + ), + ); + + for (const ws of staleWorkspaces) { + await db + .update(workspaces) + .set({ deletedAt: new Date() }) + .where(eq(workspaces.id, ws.id)); + softDeleted++; + } + } + + if (softDeleted > 0) { + logger.info('Soft-deleted stale workspaces', { count: softDeleted }); + } + + // 3. Hard-delete workspaces 30 days after soft-delete + const softDeleteCutoff = new Date(now - THIRTY_DAYS_MS); + const toHardDelete = await db + .select({ id: workspaces.id }) + .from(workspaces) + .where( + and( + isNotNull(workspaces.deletedAt), + lt(workspaces.deletedAt, softDeleteCutoff), + ), + ); + + for (const ws of toHardDelete) { + await db.delete(workspaces).where(eq(workspaces.id, ws.id)); + } + + if (toHardDelete.length > 0) { + logger.info('Hard-deleted expired workspaces', { count: toHardDelete.length }); + } + + // 4. Clean up expired sessions and verification codes + await db.delete(sessions).where(lt(sessions.expiresAt, new Date())); + await db.delete(emailVerifications).where(lt(emailVerifications.expiresAt, new Date())); +} diff --git a/packages/server/src/engine/user.ts b/packages/server/src/engine/user.ts new file mode 100644 index 00000000..812dd81d --- /dev/null +++ b/packages/server/src/engine/user.ts @@ -0,0 +1,298 @@ +import crypto from 'node:crypto'; +import { eq, and } from 'drizzle-orm'; +import type { getDb } from '../db/index.js'; +import { users, organizations, orgMemberships, emailVerifications, sessions } from '../db/schema.js'; +import { generateId } from './snowflake.js'; +import { hashToken } from '../middleware/auth.js'; + +type Db = ReturnType; + +async function hashPassword(password: string): Promise { + const salt = crypto.randomBytes(16); + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits'], + ); + const derived = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, + key, + 256, + ); + const hash = Buffer.from(derived); + return `pbkdf2:${salt.toString('hex')}:${hash.toString('hex')}`; +} + +async function verifyPassword(password: string, stored: string): Promise { + const [, saltHex, hashHex] = stored.split(':'); + if (!saltHex || !hashHex) return false; + const salt = Buffer.from(saltHex, 'hex'); + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits'], + ); + const derived = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, + key, + 256, + ); + const hash = Buffer.from(derived); + const storedHash = Buffer.from(hashHex, 'hex'); + if (storedHash.length !== hash.length) return false; + return crypto.timingSafeEqual(hash, storedHash); +} + +function generateVerificationCode(): string { + return String(crypto.randomInt(100000, 999999)); +} + +function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +export async function signup( + db: Db, + input: { name: string; email: string; password: string }, +) { + const [existing] = await db + .select() + .from(users) + .where(eq(users.email, input.email)); + if (existing) { + const err = new Error('An account with this email already exists'); + Object.assign(err, { code: 'email_already_exists', status: 409 }); + throw err; + } + + const userId = generateId(); + const passwordHash = await hashPassword(input.password); + + const [user] = await db + .insert(users) + .values({ + id: userId, + email: input.email, + name: input.name, + passwordHash, + }) + .returning(); + + // Create verification code + const code = generateVerificationCode(); + const verificationId = generateId(); + await db.insert(emailVerifications).values({ + id: verificationId, + email: input.email, + code, + userId, + expiresAt: new Date(Date.now() + 15 * 60 * 1000), + }); + + return { + user_id: userId, + verification_code: code, + email: input.email, + created_at: user.createdAt.toISOString(), + }; +} + +export async function verifyEmail( + db: Db, + input: { user_id: string; code: string }, +) { + const [verification] = await db + .select() + .from(emailVerifications) + .where(eq(emailVerifications.userId, input.user_id)); + + if (!verification) { + const err = new Error('No pending verification found'); + Object.assign(err, { code: 'verification_not_found', status: 404 }); + throw err; + } + + if (verification.expiresAt < new Date()) { + const err = new Error('Verification code has expired'); + Object.assign(err, { code: 'verification_expired', status: 410 }); + throw err; + } + + if (verification.code !== input.code) { + const err = new Error('Invalid verification code'); + Object.assign(err, { code: 'invalid_code', status: 400 }); + throw err; + } + + await db + .update(users) + .set({ emailVerified: true }) + .where(eq(users.id, input.user_id)); + + await db + .delete(emailVerifications) + .where(eq(emailVerifications.userId, input.user_id)); + + return { verified: true }; +} + +export async function login( + db: Db, + input: { email: string; password: string }, +) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, input.email)); + + if (!user) { + const err = new Error('Invalid email or password'); + Object.assign(err, { code: 'invalid_credentials', status: 401 }); + throw err; + } + + const valid = await verifyPassword(input.password, user.passwordHash); + if (!valid) { + const err = new Error('Invalid email or password'); + Object.assign(err, { code: 'invalid_credentials', status: 401 }); + throw err; + } + + // Get user's orgs + const memberships = await db + .select() + .from(orgMemberships) + .where(eq(orgMemberships.userId, user.id)); + + let activeOrgId: string | null = null; + const orgs: { id: string; name: string; role: string }[] = []; + + for (const m of memberships) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, m.organizationId)); + if (org) { + orgs.push({ id: org.id, name: org.name, role: m.role }); + if (!activeOrgId) activeOrgId = org.id; + } + } + + // Create session + const sessionId = generateSessionToken(); + await db.insert(sessions).values({ + id: sessionId, + userId: user.id, + activeOrgId, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }); + + return { + user_id: user.id, + session_token: sessionId, + organizations: orgs, + }; +} + +export async function getSessionUser(db: Db, sessionId: string) { + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)); + + if (!session || session.expiresAt < new Date()) { + return null; + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.userId)); + + if (!user) return null; + + let activeOrg = null; + if (session.activeOrgId) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, session.activeOrgId)); + activeOrg = org || null; + } + + return { user, activeOrg, sessionId: session.id }; +} + +export async function switchOrg(db: Db, sessionId: string, userId: string, orgId: string) { + // Verify user is a member of this org + const [membership] = await db + .select() + .from(orgMemberships) + .where( + and( + eq(orgMemberships.userId, userId), + eq(orgMemberships.organizationId, orgId), + ), + ); + + if (!membership) { + const err = new Error('You are not a member of this organization'); + Object.assign(err, { code: 'not_a_member', status: 403 }); + throw err; + } + + await db + .update(sessions) + .set({ activeOrgId: orgId }) + .where(eq(sessions.id, sessionId)); + + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, orgId)); + + return org; +} + +export async function deleteSession(db: Db, sessionId: string) { + await db.delete(sessions).where(eq(sessions.id, sessionId)); +} + +export async function getUser(db: Db, userId: string) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)); + if (!user) return null; + + return { + id: user.id, + email: user.email, + email_verified: user.emailVerified, + name: user.name, + created_at: user.createdAt.toISOString(), + }; +} + +export async function getUserOrgs(db: Db, userId: string) { + const memberships = await db + .select() + .from(orgMemberships) + .where(eq(orgMemberships.userId, userId)); + + const result: { id: string; name: string; plan: string; role: string }[] = []; + for (const m of memberships) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, m.organizationId)); + if (org) { + result.push({ id: org.id, name: org.name, plan: org.plan, role: m.role }); + } + } + return result; +} diff --git a/packages/server/src/engine/workspace.ts b/packages/server/src/engine/workspace.ts index fe318cc6..ac98d0eb 100644 --- a/packages/server/src/engine/workspace.ts +++ b/packages/server/src/engine/workspace.ts @@ -1,12 +1,13 @@ import crypto from 'node:crypto'; import { eq } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; -import { workspaces, channels } from '../db/schema.js'; +import { workspaces, channels, organizations } from '../db/schema.js'; import { generateId } from './snowflake.js'; +import { createShadowOrg } from './organization.js'; type Db = ReturnType; -export async function createWorkspace(db: Db, name: string) { +export async function createWorkspace(db: Db, name: string, organizationId?: string) { // Check for duplicate name const [existing] = await db .select() @@ -18,6 +19,9 @@ export async function createWorkspace(db: Db, name: string) { throw err; } + // If no org provided, create a shadow org + const orgId = organizationId ?? await createShadowOrg(db, name); + const workspaceId = generateId(); const apiKey = `rk_live_${crypto.randomBytes(16).toString('hex')}`; const apiKeyHash = crypto @@ -29,8 +33,10 @@ export async function createWorkspace(db: Db, name: string) { .insert(workspaces) .values({ id: workspaceId, + organizationId: orgId, name, apiKeyHash, + lastActivityAt: new Date(), }) .returning(); @@ -57,10 +63,17 @@ export async function getWorkspace(db: Db, workspaceId: string) { .where(eq(workspaces.id, workspaceId)); if (!workspace) return null; + // Get plan from org + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, workspace.organizationId)); + return { id: workspace.id, + organization_id: workspace.organizationId, name: workspace.name, - plan: workspace.plan, + plan: (org?.plan ?? 'free') as string, system_prompt: workspace.systemPrompt, created_at: workspace.createdAt.toISOString(), metadata: workspace.metadata, @@ -81,24 +94,21 @@ export async function updateWorkspace( return getWorkspace(db, workspaceId); } - const [updated] = await db + await db .update(workspaces) .set(setClause) - .where(eq(workspaces.id, workspaceId)) - .returning(); - - if (!updated) return null; + .where(eq(workspaces.id, workspaceId)); - return { - id: updated.id, - name: updated.name, - plan: updated.plan, - system_prompt: updated.systemPrompt, - created_at: updated.createdAt.toISOString(), - metadata: updated.metadata, - }; + return getWorkspace(db, workspaceId); } export async function deleteWorkspace(db: Db, workspaceId: string) { await db.delete(workspaces).where(eq(workspaces.id, workspaceId)); } + +export async function touchLastActivity(db: Db, workspaceId: string) { + await db + .update(workspaces) + .set({ lastActivityAt: new Date() }) + .where(eq(workspaces.id, workspaceId)); +} diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index 2132f09b..9fe4b488 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -1,4 +1,4 @@ -import type { workspaces, agents } from './db/schema.js'; +import type { workspaces, agents, organizations, users } from './db/schema.js'; import type { Logger } from './lib/logger.js'; /** Cloudflare Worker bindings */ @@ -25,11 +25,14 @@ export interface CloudflareBindings { RELAYCAST_TELEMETRY_DISABLED?: string; POSTHOG_API_KEY?: string; POSTHOG_HOST?: string; + ADMIN_SECRET?: string; } /** Hono context variables set by middleware */ export interface AppVariables { workspace: typeof workspaces.$inferSelect; + organization: typeof organizations.$inferSelect; + user: typeof users.$inferSelect | undefined; agent: typeof agents.$inferSelect | undefined; db: ReturnType; logger: Logger; diff --git a/packages/server/src/middleware/__tests__/planLimits.test.ts b/packages/server/src/middleware/__tests__/planLimits.test.ts index f99127e7..f55c28ed 100644 --- a/packages/server/src/middleware/__tests__/planLimits.test.ts +++ b/packages/server/src/middleware/__tests__/planLimits.test.ts @@ -21,6 +21,10 @@ function makeApp(options: { name: 'test-workspace', plan: options.plan || 'free', } as any); + c.set('organization', { + id: 'org_123', + plan: options.plan || 'free', + } as any); } await next(); }); diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 4887f966..160dc967 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import { createMiddleware } from 'hono/factory'; import { eq } from 'drizzle-orm'; -import { workspaces, agents } from '../db/schema.js'; +import { workspaces, agents, organizations, sessions, users } from '../db/schema.js'; import { touchLastSeen } from '../engine/agent.js'; import type { AppEnv } from '../env.js'; @@ -16,6 +16,17 @@ function extractToken(authHeader: string | undefined): string | null { return authHeader.slice(7); } +function getCookie(cookieHeader: string | undefined, name: string): string | null { + if (!cookieHeader) return null; + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match ? match[1] : null; +} + +/** Check if a workspace is soft-deleted */ +function isWorkspaceDeleted(workspace: typeof workspaces.$inferSelect) { + return workspace.deletedAt !== null && workspace.deletedAt !== undefined; +} + export const requireWorkspaceKey = createMiddleware(async (c, next) => { const token = extractToken(c.req.header('Authorization')); if (!token) { @@ -43,7 +54,16 @@ export const requireWorkspaceKey = createMiddleware(async (c, next) => { ); } + if (isWorkspaceDeleted(workspace)) { + return c.json( + { ok: false, error: { code: 'workspace_expired', message: 'This workspace has been deactivated due to inactivity' } }, + 410, + ); + } + c.set('workspace', workspace); + const [org] = await db.select().from(organizations).where(eq(organizations.id, workspace.organizationId)); + if (org) c.set('organization', org); await next(); }); @@ -70,7 +90,15 @@ export const requireAuth = createMiddleware(async (c, next) => { 401, ); } + if (isWorkspaceDeleted(workspace)) { + return c.json( + { ok: false, error: { code: 'workspace_expired', message: 'This workspace has been deactivated due to inactivity' } }, + 410, + ); + } c.set('workspace', workspace); + const [org1] = await db.select().from(organizations).where(eq(organizations.id, workspace.organizationId)); + if (org1) c.set('organization', org1); } else if (token.startsWith('at_live_')) { const [agent] = await db.select().from(agents).where(eq(agents.tokenHash, hash)); if (!agent) { @@ -96,7 +124,15 @@ export const requireAuth = createMiddleware(async (c, next) => { 401, ); } + if (isWorkspaceDeleted(workspace)) { + return c.json( + { ok: false, error: { code: 'workspace_expired', message: 'This workspace has been deactivated due to inactivity' } }, + 410, + ); + } c.set('workspace', workspace); + const [org2] = await db.select().from(organizations).where(eq(organizations.id, workspace.organizationId)); + if (org2) c.set('organization', org2); } else { return c.json( { ok: false, error: { code: 'unauthorized', message: 'Invalid token format' } }, @@ -151,6 +187,156 @@ export const requireAgentToken = createMiddleware(async (c, next) => { 401, ); } + if (isWorkspaceDeleted(workspace)) { + return c.json( + { ok: false, error: { code: 'workspace_expired', message: 'This workspace has been deactivated due to inactivity' } }, + 410, + ); + } c.set('workspace', workspace); + const [org] = await db.select().from(organizations).where(eq(organizations.id, workspace.organizationId)); + if (org) c.set('organization', org); + await next(); +}); + +/** + * Authenticate via org API key (rk_org_*) or session cookie. + * Sets c.var.organization on success. For session auth, also sets c.var.user. + */ +export const requireOrgAuth = createMiddleware(async (c, next) => { + const db = c.get('db'); + + // Try org API key first + const token = extractToken(c.req.header('Authorization')); + if (token && token.startsWith('rk_org_')) { + const hash = hashToken(token); + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.orgApiKeyHash, hash)); + + if (!org) { + return c.json( + { ok: false, error: { code: 'unauthorized', message: 'Invalid org API key' } }, + 401, + ); + } + c.set('organization', org); + await next(); + return; + } + + // Try session cookie — resolves user + active org + const sessionId = getCookie(c.req.header('Cookie'), 'relaycast_session'); + if (sessionId) { + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)); + + if (session && session.expiresAt > new Date()) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.userId)); + + if (user) { + c.set('user', user); + + if (session.activeOrgId) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, session.activeOrgId)); + + if (org) { + c.set('organization', org); + await next(); + return; + } + } + + // User has a session but no active org — they need to select one + return c.json( + { ok: false, error: { code: 'no_active_org', message: 'No active organization. Use POST /v1/user/orgs/switch to select one.' } }, + 400, + ); + } + } + } + + return c.json( + { ok: false, error: { code: 'unauthorized', message: 'Org API key (rk_org_*) or session cookie required' } }, + 401, + ); +}); + +/** + * Authenticate via session cookie only — for user-level endpoints. + * Sets c.var.user on success. + */ +export const requireUserAuth = createMiddleware(async (c, next) => { + const db = c.get('db'); + + const sessionId = getCookie(c.req.header('Cookie'), 'relaycast_session'); + if (!sessionId) { + return c.json( + { ok: false, error: { code: 'unauthorized', message: 'Session cookie required. Log in first.' } }, + 401, + ); + } + + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)); + + if (!session || session.expiresAt < new Date()) { + return c.json( + { ok: false, error: { code: 'session_expired', message: 'Session expired. Please log in again.' } }, + 401, + ); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.userId)); + + if (!user) { + return c.json( + { ok: false, error: { code: 'unauthorized', message: 'User not found' } }, + 401, + ); + } + + c.set('user', user); + + // Also load active org if set + if (session.activeOrgId) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, session.activeOrgId)); + if (org) c.set('organization', org); + } + + await next(); +}); + +/** + * Require X-Admin-Secret header matching ADMIN_SECRET binding. + */ +export const requireAdminSecret = createMiddleware(async (c, next) => { + const secret = c.req.header('X-Admin-Secret'); + const expected = c.env.ADMIN_SECRET; + + if (!expected || !secret || secret !== expected) { + return c.json( + { ok: false, error: { code: 'unauthorized', message: 'Invalid or missing admin secret' } }, + 401, + ); + } + await next(); }); diff --git a/packages/server/src/middleware/planLimits.ts b/packages/server/src/middleware/planLimits.ts index 61128167..794f859d 100644 --- a/packages/server/src/middleware/planLimits.ts +++ b/packages/server/src/middleware/planLimits.ts @@ -12,7 +12,9 @@ export function checkPlanLimit(metric: 'messages' | 'agents' | 'file_bytes') { const workspace = c.get('workspace'); if (!workspace) { await next(); return; } - const plan = workspace.plan || 'free'; + // Read plan from org (set by auth middleware) + const org = c.get('organization'); + const plan = org?.plan || 'free'; const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free; const limit = limits[metric]; if (limit === Infinity) { await next(); return; } diff --git a/packages/server/src/middleware/rateLimit.ts b/packages/server/src/middleware/rateLimit.ts index 3f1bdc72..1ff2cd0f 100644 --- a/packages/server/src/middleware/rateLimit.ts +++ b/packages/server/src/middleware/rateLimit.ts @@ -66,7 +66,10 @@ export const rateLimit = createMiddleware(async (c, next) => { return; } - const globalLimit = RATE_LIMITS[workspace.plan] || RATE_LIMITS.free; + // Read plan from org (set by auth middleware), fall back to workspace.plan for compat + const org = c.get('organization'); + const plan = org?.plan || workspace.plan || 'free'; + const globalLimit = RATE_LIMITS[plan] || RATE_LIMITS.free; // Apply route-specific multiplier if applicable const routeKey = getRouteKey(c.req.method, c.req.path); @@ -116,7 +119,7 @@ export const rateLimit = createMiddleware(async (c, next) => { ok: false, error: { code: 'rate_limit_exceeded', - message: `Rate limit exceeded. ${limit} requests per minute allowed for ${workspace.plan} plan.`, + message: `Rate limit exceeded. ${limit} requests per minute allowed for ${plan} plan.`, }, }, 429, @@ -134,7 +137,7 @@ export const rateLimit = createMiddleware(async (c, next) => { ok: false, error: { code: 'rate_limit_exceeded', - message: `Rate limit exceeded. ${limit} requests per minute allowed for ${workspace.plan} plan.`, + message: `Rate limit exceeded. ${limit} requests per minute allowed for ${plan} plan.`, }, }, 429, diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts new file mode 100644 index 00000000..28155deb --- /dev/null +++ b/packages/server/src/routes/admin.ts @@ -0,0 +1,35 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { AppEnv } from '../env.js'; +import { requireAdminSecret } from '../middleware/auth.js'; +import * as orgEngine from '../engine/organization.js'; + +export const adminRoutes = new Hono(); + +const setPlanSchema = z.object({ + plan: z.enum(['free', 'pro']), +}); + +// PUT /admin/orgs/:id/plan - set org plan +adminRoutes.put('/admin/orgs/:id/plan', requireAdminSecret, async (c) => { + try { + const parsed = setPlanSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'plan ("free" or "pro") is required' } }, 400); + } + + const db = c.get('db'); + const orgId = c.req.param('id'); + + const result = await orgEngine.setOrgPlan(db, orgId, parsed.data.plan); + + if (!result) { + return c.json({ ok: false, error: { code: 'org_not_found', message: 'Organization not found' } }, 404); + } + + return c.json({ ok: true, data: result }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); diff --git a/packages/server/src/routes/organization.ts b/packages/server/src/routes/organization.ts new file mode 100644 index 00000000..e21ecaa8 --- /dev/null +++ b/packages/server/src/routes/organization.ts @@ -0,0 +1,182 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { AppEnv } from '../env.js'; +import { requireOrgAuth } from '../middleware/auth.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import * as orgEngine from '../engine/organization.js'; +import * as workspaceEngine from '../engine/workspace.js'; +import { emitServerEvent } from '../lib/serverTelemetry.js'; + +export const organizationRoutes = new Hono(); + +const createOrgSchema = z.object({ + name: z.string().min(1), +}); + +const updateOrgSchema = z.object({ + name: z.string().optional(), +}); + +const claimWorkspaceSchema = z.object({ + workspace_api_key: z.string(), +}); + +const createWorkspaceSchema = z.object({ + name: z.string().min(1), +}); + +const inviteMemberSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member']).optional(), +}); + +// POST /orgs - create a new organization (requires user session) +organizationRoutes.post('/orgs', requireOrgAuth, rateLimit, async (c) => { + try { + const parsed = createOrgSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'name is required' } }, 400); + } + + const user = c.get('user'); + if (!user) { + return c.json({ ok: false, error: { code: 'unauthorized', message: 'User session required to create an organization' } }, 401); + } + + const db = c.get('db'); + const result = await orgEngine.createOrg(db, user.id, parsed.data); + return c.json({ ok: true, data: result }, 201); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// GET /org - get current org +organizationRoutes.get('/org', requireOrgAuth, rateLimit, async (c) => { + try { + const db = c.get('db'); + const org = await orgEngine.getOrg(db, c.get('organization').id); + if (!org) { + return c.json({ ok: false, error: { code: 'org_not_found', message: 'Organization not found' } }, 404); + } + return c.json({ ok: true, data: org }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// PATCH /org - update org +organizationRoutes.patch('/org', requireOrgAuth, rateLimit, async (c) => { + try { + const parsed = updateOrgSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'invalid update body' } }, 400); + } + + const db = c.get('db'); + const updated = await orgEngine.updateOrg(db, c.get('organization').id, parsed.data); + return c.json({ ok: true, data: updated }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// POST /org/claim - claim a free workspace +organizationRoutes.post('/org/claim', requireOrgAuth, rateLimit, async (c) => { + try { + const parsed = claimWorkspaceSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'workspace_api_key is required' } }, 400); + } + + const db = c.get('db'); + const result = await orgEngine.claimWorkspace(db, c.get('organization').id, parsed.data.workspace_api_key); + return c.json({ ok: true, data: result }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// POST /org/workspaces - create workspace under org +organizationRoutes.post('/org/workspaces', requireOrgAuth, rateLimit, async (c) => { + try { + const parsed = createWorkspaceSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'name is required' } }, 400); + } + + const db = c.get('db'); + const org = c.get('organization'); + const result = await workspaceEngine.createWorkspace(db, parsed.data.name, org.id); + emitServerEvent(c, result.workspace_id, 'relaycast_server_workspace_created', { + created_via: 'org_api', + organization_id: org.id, + }); + return c.json({ ok: true, data: result }, 201); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// GET /org/workspaces - list org workspaces +organizationRoutes.get('/org/workspaces', requireOrgAuth, rateLimit, async (c) => { + try { + const db = c.get('db'); + const list = await orgEngine.getOrgWorkspaces(db, c.get('organization').id); + return c.json({ ok: true, data: list }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// GET /org/members - list org members +organizationRoutes.get('/org/members', requireOrgAuth, rateLimit, async (c) => { + try { + const db = c.get('db'); + const members = await orgEngine.getOrgMembers(db, c.get('organization').id); + return c.json({ ok: true, data: members }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// POST /org/members/invite - invite a user to the org +organizationRoutes.post('/org/members/invite', requireOrgAuth, rateLimit, async (c) => { + try { + const parsed = inviteMemberSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'email is required' } }, 400); + } + + const db = c.get('db'); + const result = await orgEngine.inviteMember( + db, + c.get('organization').id, + parsed.data.email, + parsed.data.role, + ); + return c.json({ ok: true, data: result }, 201); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// DELETE /org/members/:userId - remove a member from the org +organizationRoutes.delete('/org/members/:userId', requireOrgAuth, rateLimit, async (c) => { + try { + const db = c.get('db'); + await orgEngine.removeMember(db, c.get('organization').id, c.req.param('userId')); + return c.json({ ok: true, data: { removed: true } }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); diff --git a/packages/server/src/routes/user.ts b/packages/server/src/routes/user.ts new file mode 100644 index 00000000..85675a24 --- /dev/null +++ b/packages/server/src/routes/user.ts @@ -0,0 +1,187 @@ +import { Hono } from 'hono'; +import { createMiddleware } from 'hono/factory'; +import { z } from 'zod'; +import type { AppEnv } from '../env.js'; +import { requireUserAuth } from '../middleware/auth.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import * as userEngine from '../engine/user.js'; + +export const userRoutes = new Hono(); + +// IP-based rate limiter for unauthenticated auth endpoints +const authBuckets = new Map(); + +function authRateLimit(maxRequests: number, windowMs: number = 60_000) { + return createMiddleware(async (c, next) => { + const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown'; + const key = `${ip}:${c.req.path}`; + const now = Date.now(); + + let bucket = authBuckets.get(key); + if (!bucket || now - bucket.windowStart > windowMs) { + bucket = { count: 0, windowStart: now }; + authBuckets.set(key, bucket); + } + + bucket.count++; + if (bucket.count > maxRequests) { + return c.json({ + ok: false, + error: { code: 'rate_limit_exceeded', message: 'Too many requests. Please try again later.' }, + }, 429); + } + + await next(); + }); +} + +const signupSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); + +const verifyEmailSchema = z.object({ + user_id: z.string(), + code: z.string().length(6), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +const switchOrgSchema = z.object({ + organization_id: z.string(), +}); + +// POST /user/signup +userRoutes.post('/user/signup', authRateLimit(5), async (c) => { + try { + const parsed = signupSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'name, email, and password (min 8 chars) are required' } }, 400); + } + + const db = c.get('db'); + const result = await userEngine.signup(db, parsed.data); + + return c.json({ + ok: true, + data: { + user_id: result.user_id, + verification_code: result.verification_code, + created_at: result.created_at, + }, + }, 201); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// POST /user/verify +userRoutes.post('/user/verify', authRateLimit(10), async (c) => { + try { + const parsed = verifyEmailSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'user_id and 6-digit code are required' } }, 400); + } + + const db = c.get('db'); + const result = await userEngine.verifyEmail(db, parsed.data); + return c.json({ ok: true, data: result }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// POST /user/login +userRoutes.post('/user/login', authRateLimit(5), async (c) => { + try { + const parsed = loginSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'email and password are required' } }, 400); + } + + const db = c.get('db'); + const result = await userEngine.login(db, parsed.data); + + c.header('Set-Cookie', `relaycast_session=${result.session_token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${30 * 24 * 60 * 60}`); + + return c.json({ + ok: true, + data: { + user_id: result.user_id, + organizations: result.organizations, + }, + }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); + +// POST /user/logout +userRoutes.post('/user/logout', requireUserAuth, async (c) => { + const cookieHeader = c.req.header('Cookie'); + const match = cookieHeader?.match(/(?:^|;\s*)relaycast_session=([^;]*)/); + if (match?.[1]) { + const db = c.get('db'); + await userEngine.deleteSession(db, match[1]); + } + c.header('Set-Cookie', 'relaycast_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0'); + return c.json({ ok: true, data: { logged_out: true } }); +}); + +// GET /user - get current user +userRoutes.get('/user', requireUserAuth, rateLimit, async (c) => { + const db = c.get('db'); + const user = c.get('user'); + if (!user) { + return c.json({ ok: false, error: { code: 'unauthorized', message: 'Not authenticated' } }, 401); + } + const profile = await userEngine.getUser(db, user.id); + return c.json({ ok: true, data: profile }); +}); + +// GET /user/orgs - list user's organizations +userRoutes.get('/user/orgs', requireUserAuth, rateLimit, async (c) => { + const db = c.get('db'); + const user = c.get('user'); + if (!user) { + return c.json({ ok: false, error: { code: 'unauthorized', message: 'Not authenticated' } }, 401); + } + const orgs = await userEngine.getUserOrgs(db, user.id); + return c.json({ ok: true, data: orgs }); +}); + +// POST /user/orgs/switch - switch active org +userRoutes.post('/user/orgs/switch', requireUserAuth, rateLimit, async (c) => { + try { + const parsed = switchOrgSchema.safeParse(await c.req.json()); + if (!parsed.success) { + return c.json({ ok: false, error: { code: 'invalid_request', message: 'organization_id is required' } }, 400); + } + + const db = c.get('db'); + const user = c.get('user'); + if (!user) { + return c.json({ ok: false, error: { code: 'unauthorized', message: 'Not authenticated' } }, 401); + } + + // Get session ID from cookie + const cookieHeader = c.req.header('Cookie'); + const match = cookieHeader?.match(/(?:^|;\s*)relaycast_session=([^;]*)/); + if (!match?.[1]) { + return c.json({ ok: false, error: { code: 'unauthorized', message: 'Session not found' } }, 401); + } + + const org = await userEngine.switchOrg(db, match[1], user.id, parsed.data.organization_id); + return c.json({ ok: true, data: { id: org.id, name: org.name, plan: org.plan } }); + } catch (err: unknown) { + const error = err as Error & { code?: string; status?: number }; + return c.json({ ok: false, error: { code: error.code || 'internal_error', message: error.message } }, (error.status || 500) as any); + } +}); diff --git a/packages/server/src/worker.ts b/packages/server/src/worker.ts index 05db4491..96bdacac 100644 --- a/packages/server/src/worker.ts +++ b/packages/server/src/worker.ts @@ -25,6 +25,9 @@ import { systemPromptRoutes } from './routes/systemPrompt.js'; import { inboundWebhookRoutes } from './routes/inboundWebhook.js'; import { eventSubscriptionRoutes } from './routes/eventSubscription.js'; import { commandRoutes } from './routes/command.js'; +import { userRoutes } from './routes/user.js'; +import { organizationRoutes } from './routes/organization.js'; +import { adminRoutes } from './routes/admin.js'; import { isWorkspaceStreamEnabled } from './lib/workspaceStream.js'; import { createLogger, getRequestLogger, toErrorDetails } from './lib/logger.js'; import { requiredOriginInfo } from './lib/origin.js'; @@ -240,8 +243,13 @@ app.get('/v1/ws', async (c) => { return c.json({ ok: false, error: { code: 'invalid_token', message: 'Invalid token format' } }, 401); }); +// Admin routes — outside v1 prefix +app.route('/v1', adminRoutes); + // API v1 routes — specific routes before parameterized routes const v1 = new Hono(); +v1.route('/', userRoutes); +v1.route('/', organizationRoutes); v1.route('/', presenceRoutes); v1.route('/', systemPromptRoutes); v1.route('/', workspaceRoutes); @@ -322,7 +330,25 @@ async function handleQueue(batch: MessageBatch, env: AppEnv['Bindings']) { await logger.flush(); } +// Scheduled cron handler for TTL cleanup +async function handleScheduled(event: ScheduledEvent, env: AppEnv['Bindings'], ctx: ExecutionContext) { + const { getDb } = await import('./db/index.js'); + const { runTtlCleanup } = await import('./engine/ttl.js'); + const db = getDb(env.DB); + const logger = createLogger(env, { source: 'worker.scheduled' }); + + try { + await runTtlCleanup(db, logger); + logger.info('TTL cleanup completed'); + } catch (error) { + logger.error('TTL cleanup failed', toErrorDetails(error)); + } + + await logger.flush(); +} + export default { fetch: app.fetch, queue: handleQueue, + scheduled: handleScheduled, }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7090fb6c..294701e5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,4 +1,5 @@ export * from './workspace.js'; +export * from './organization.js'; export * from './agent.js'; export * from './channel.js'; export * from './message.js'; diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts new file mode 100644 index 00000000..15c02911 --- /dev/null +++ b/packages/types/src/organization.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; + +// ── Users ── + +export const UserSchema = z.object({ + id: z.string(), + email: z.string(), + email_verified: z.boolean(), + name: z.string(), + created_at: z.string(), +}); +export type User = z.infer; + +export const SignupRequestSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + password: z.string().min(8), +}); +export type SignupRequest = z.infer; + +export const SignupResponseSchema = z.object({ + user_id: z.string(), + created_at: z.string(), +}); +export type SignupResponse = z.infer; + +export const VerifyEmailRequestSchema = z.object({ + user_id: z.string(), + code: z.string().length(6), +}); +export type VerifyEmailRequest = z.infer; + +export const LoginRequestSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); +export type LoginRequest = z.infer; + +export const LoginResponseSchema = z.object({ + user_id: z.string(), + organizations: z.array(z.object({ + id: z.string(), + name: z.string(), + role: z.string(), + })), +}); +export type LoginResponse = z.infer; + +// ── Organizations ── + +export const OrganizationSchema = z.object({ + id: z.string(), + name: z.string(), + plan: z.enum(['free', 'pro']), + created_at: z.string(), +}); +export type Organization = z.infer; + +export const CreateOrgRequestSchema = z.object({ + name: z.string().min(1), +}); +export type CreateOrgRequest = z.infer; + +export const CreateOrgResponseSchema = z.object({ + organization_id: z.string(), + org_api_key: z.string(), + created_at: z.string(), +}); +export type CreateOrgResponse = z.infer; + +// ── Memberships ── + +export const OrgMembershipSchema = z.object({ + user_id: z.string(), + organization_id: z.string(), + role: z.enum(['owner', 'admin', 'member']), + user_email: z.string(), + user_name: z.string(), + created_at: z.string(), +}); +export type OrgMembership = z.infer; + +export const InviteMemberRequestSchema = z.object({ + email: z.string().email(), + role: z.enum(['admin', 'member']).optional(), +}); +export type InviteMemberRequest = z.infer; + +// ── Billing ── + +export const ClaimWorkspaceRequestSchema = z.object({ + workspace_api_key: z.string(), +}); +export type ClaimWorkspaceRequest = z.infer; + +export const AdminSetPlanRequestSchema = z.object({ + plan: z.enum(['free', 'pro']), +}); +export type AdminSetPlanRequest = z.infer; diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index ed31938f..427891a8 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -2,9 +2,10 @@ import { z } from 'zod'; export const WorkspaceSchema = z.object({ id: z.string(), + organization_id: z.string(), name: z.string(), system_prompt: z.string().nullable(), - plan: z.enum(['free', 'pro', 'enterprise']), + plan: z.enum(['free', 'pro']), created_at: z.string(), metadata: z.record(z.string(), z.unknown()), }); diff --git a/site/index.html b/site/index.html index a86e70b5..ff191905 100644 --- a/site/index.html +++ b/site/index.html @@ -25,7 +25,6 @@ OpenClaw Docs GitHub - Get Started @@ -373,7 +372,7 @@

Simple, predictable pricing

  • MCP server included
  • Community support
  • - Get Started + Get Started Free
    Enterprise
    diff --git a/wrangler.toml b/wrangler.toml index 808aa06f..dbcf2a21 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -75,7 +75,7 @@ id = "PLACEHOLDER_KV_ID" # Cron triggers [triggers] -crons = [] +crons = ["0 4 * * *"] # Daily at 4am UTC — TTL cleanup # Staging environment overrides [env.staging] @@ -178,3 +178,7 @@ crons = [] # R2_SECRET_ACCESS_KEY # CF_ACCOUNT_ID # POSTHOG_API_KEY +# RESEND_API_KEY +# STRIPE_SECRET_KEY +# STRIPE_WEBHOOK_SECRET +# ADMIN_SECRET