From d1bde5384aa2d4978a835a90b0bde31641df5bc3 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sat, 21 Feb 2026 11:42:34 -0500 Subject: [PATCH 1/4] Add organizations, billing & workspace lifecycle Introduce organization and user/billing primitives and workspace lifecycle management. Adds new DB migration and schema for users, organizations, sessions, email_verifications, and org_memberships; backfills shadow orgs and links workspaces. Implements organization, user, and TTL engines (signup/login, org management, claims, cron cleanup/TTL trimming), updates workspace engine to create/link shadow orgs and track last activity, and updates auth middleware to support org API keys, sessions, and soft-deleted workspaces. Adds Resend email helper, new routes/pages/CSS for signup/login/dashboard/billing, test updates, env bindings (Resend/Stripe/Admin) and docs (billing-plan.md). --- .claude/settings.local.json | 3 +- billing-plan.md | 276 ++++++++++++++++ packages/server/src/__tests__/test-helpers.ts | 27 +- .../src/db/migrations/0002_organizations.sql | 66 ++++ packages/server/src/db/schema.ts | 96 +++++- packages/server/src/engine/organization.ts | 309 ++++++++++++++++++ packages/server/src/engine/ttl.ts | 116 +++++++ packages/server/src/engine/user.ts | 296 +++++++++++++++++ packages/server/src/engine/workspace.ts | 42 ++- packages/server/src/env.ts | 8 +- packages/server/src/lib/email.ts | 69 ++++ .../middleware/__tests__/planLimits.test.ts | 4 + packages/server/src/middleware/auth.ts | 188 ++++++++++- packages/server/src/middleware/planLimits.ts | 4 +- packages/server/src/middleware/rateLimit.ts | 10 +- packages/server/src/routes/admin.ts | 41 +++ packages/server/src/routes/billing.ts | 108 ++++++ packages/server/src/routes/organization.ts | 182 +++++++++++ packages/server/src/routes/stripeWebhook.ts | 156 +++++++++ packages/server/src/routes/user.ts | 163 +++++++++ packages/server/src/worker.ts | 32 ++ packages/types/src/index.ts | 1 + packages/types/src/organization.ts | 110 +++++++ packages/types/src/workspace.ts | 3 +- site/auth.css | 126 +++++++ site/dashboard.css | 169 ++++++++++ site/dashboard.html | 242 ++++++++++++++ site/index.html | 7 +- site/login.html | 90 +++++ site/signup.html | 91 ++++++ site/verify.html | 93 ++++++ wrangler.toml | 6 +- 32 files changed, 3101 insertions(+), 33 deletions(-) create mode 100644 billing-plan.md create mode 100644 packages/server/src/db/migrations/0002_organizations.sql create mode 100644 packages/server/src/engine/organization.ts create mode 100644 packages/server/src/engine/ttl.ts create mode 100644 packages/server/src/engine/user.ts create mode 100644 packages/server/src/lib/email.ts create mode 100644 packages/server/src/routes/admin.ts create mode 100644 packages/server/src/routes/billing.ts create mode 100644 packages/server/src/routes/organization.ts create mode 100644 packages/server/src/routes/stripeWebhook.ts create mode 100644 packages/server/src/routes/user.ts create mode 100644 packages/types/src/organization.ts create mode 100644 site/auth.css create mode 100644 site/dashboard.css create mode 100644 site/dashboard.html create mode 100644 site/login.html create mode 100644 site/signup.html create mode 100644 site/verify.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 679c5c9c..f7a9569a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -50,7 +50,8 @@ "Bash(npm ls:*)", "Bash(npx --workspace=packages/server drizzle-kit generate:*)", "Bash(npx next build)", - "Bash(npx tsx:*)" + "Bash(npx tsx:*)", + "Bash(npm run build:*)" ] }, "enableAllProjectMcpServers": true, diff --git a/billing-plan.md b/billing-plan.md new file mode 100644 index 00000000..c408e063 --- /dev/null +++ b/billing-plan.md @@ -0,0 +1,276 @@ +# Billing & Workspace Lifecycle Plan + +## Overview + +Add organizations, billing (Stripe + external), workspace TTL, and email-based signup to RelayCast. Free workspaces require no signup. Paid workspaces require an org with verified email and active payment. + +--- + +## Data Model + +### New: `organizations` table + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | Snowflake ID | +| name | text | Org display name | +| email | text | Signup email (unique, nullable for shadow orgs) | +| email_verified | integer | 0/1 boolean | +| password_hash | text | Argon2 hash (nullable for shadow orgs) | +| plan | text | `'free'` or `'pro'` | +| billing_source | text | `'stripe'`, `'external'`, or `null` | +| stripe_customer_id | text | Nullable | +| subscription_status | text | `'active'`, `'past_due'`, `'canceled'`, or `null` | +| org_api_key_hash | text | SHA256 hash of `rk_org_*` key (nullable for shadow orgs) | +| created_at | integer | Unix timestamp | + +### Modified: `workspaces` table + +| Change | Column | Notes | +|--------|--------|-------| +| ADD | organization_id | FK → organizations.id, NOT NULL | +| ADD | last_activity_at | Unix timestamp, updated on messages/events | +| ADD | deleted_at | Nullable, set on soft-delete | +| DROP | plan | Inherited from org | + +### New: `sessions` table (web auth) + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | Random token | +| organization_id | text FK | | +| expires_at | integer | Unix timestamp | +| created_at | integer | | + +### New: `email_verifications` table + +| Column | Type | Notes | +|--------|------|-------| +| id | text PK | | +| email | text | | +| code | text | 6-digit code | +| organization_id | text FK | | +| expires_at | integer | 15 min TTL | +| created_at | integer | | + +--- + +## API Endpoints + +### Org Management + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | /orgs | None | Sign up: email + password + name → creates org, sends verification email, returns org API key | +| POST | /orgs/verify | None | Verify email with code | +| POST | /orgs/login | None | Email + password → sets session cookie + returns org API key | +| POST | /orgs/logout | Session cookie | Clears session | +| GET | /org | Org key or session | Get current org details | +| PATCH | /org | Org key or session | Update org name | +| POST | /org/claim | Org key or session | Attach free workspace to org (body: `{ workspace_api_key }`) | +| POST | /org/workspaces | Org key or session | Create workspace under this org | +| GET | /org/workspaces | Org key or session | List org's workspaces | + +### Billing + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | /org/billing/checkout | Org key or session | Create Stripe Checkout session → return URL | +| POST | /org/billing/portal | Org key or session | Create Stripe Customer Portal session → return URL | +| GET | /org/billing | Org key or session | Get billing status (plan, subscription_status, current_period_end) | +| POST | /webhooks/stripe | Stripe signature | Handle Stripe events | + +### Admin (shared secret) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| PUT | /admin/orgs/:id/plan | `X-Admin-Secret` header | Set plan + billing_source for external billing | + +### Existing (unchanged behavior) + +`POST /workspaces` stays unauthenticated. Internally it now creates a shadow org (`email: null`, `plan: 'free'`) and links the workspace to it. + +--- + +## Auth Model + +Three auth mechanisms, checked in order: + +1. **Workspace API key** (`Authorization: Bearer rk_live_*`) — scoped to one workspace, used by agents. Same as today. +2. **Org API key** (`Authorization: Bearer rk_org_*`) — scoped to org, used for management. Only works after email verification. +3. **Session cookie** (`relaycast_session`) — set by `/orgs/login`, used by the web UI. HttpOnly, Secure, SameSite=Lax. + +Org-level endpoints accept either org key or session cookie. + +--- + +## Stripe Integration + +### Checkout Flow + +1. User hits "Upgrade" in web UI → `POST /org/billing/checkout` +2. Server creates Stripe Checkout Session with `client_reference_id: org.id` +3. Redirects to Stripe Checkout +4. On success, Stripe sends `checkout.session.completed` webhook +5. Server sets `plan: 'pro'`, `billing_source: 'stripe'`, `subscription_status: 'active'`, stores `stripe_customer_id` + +### Webhook Events + +| Event | Action | +|-------|--------| +| `checkout.session.completed` | Set pro + active | +| `invoice.paid` | Set active (renewal confirmation) | +| `invoice.payment_failed` | Set past_due | +| `customer.subscription.deleted` | Set plan: free, status: canceled | + +### External Billing + +External service calls `PUT /admin/orgs/:id/plan` with: +```json +{ "plan": "pro", "billing_source": "external" } +``` +To downgrade: `{ "plan": "free" }`. No Stripe involvement. + +--- + +## Workspace TTL & Cleanup + +### Rules + +| Org Plan | Message Retention | Workspace Lifetime | +|----------|-------------------|-------------------| +| free | Rolling 30 days | 60 days after last activity (30 normal + 30 grace) | +| pro | Unlimited | Never expires | + +### `last_activity_at` Updates + +Updated on: message send, reaction add, file upload, agent connect. Use KV write-coalescing to avoid a D1 write per event — batch update every 5 minutes per workspace. + +### Scheduled Worker (Cron Trigger, daily) + +``` +1. For each free-plan org: + a. DELETE messages WHERE created_at < now() - 30 days + b. For workspaces WHERE last_activity_at < now() - 60 days AND deleted_at IS NULL: + - SET deleted_at = now() (soft delete) + c. For workspaces WHERE deleted_at < now() - 30 days: + - Hard delete workspace + all related data +2. Clean up expired sessions and email verification codes +``` + +Soft-deleted workspaces return `410 Gone` on API requests. The workspace API key still resolves (for error messaging) but all operations are blocked. + +--- + +## Email (Resend) + +- Verification emails on signup (6-digit code, 15-min expiry) +- Workspace expiration warnings (7 days before soft-delete) +- Payment failure notifications + +Environment binding: `RESEND_API_KEY` secret in wrangler.toml. + +--- + +## Web UI (site/) + +Add to the existing static site at relaycast.dev: + +| Page | Path | Description | +|------|------|-------------| +| Sign Up | /signup | Email + password + org name form | +| Verify | /verify | Enter 6-digit code | +| Login | /login | Email + password | +| Dashboard | /dashboard | Org overview: workspaces, plan, usage | +| Billing | /dashboard/billing | Current plan, upgrade button, Stripe portal link | +| Workspaces | /dashboard/workspaces | List, create, delete workspaces | + +These can be static HTML + JS (same pattern as current site) calling the API with session cookies. No framework needed. + +--- + +## Migration Plan + +### D1 Migration SQL + +```sql +-- 1. Create organizations table +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE, + email_verified INTEGER NOT NULL DEFAULT 0, + password_hash TEXT, + plan TEXT NOT NULL DEFAULT 'free', + billing_source TEXT, + stripe_customer_id TEXT, + subscription_status TEXT, + org_api_key_hash TEXT UNIQUE, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- 2. Create sessions table +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- 3. Create email_verifications table +CREATE TABLE email_verifications ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + code TEXT NOT NULL, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- 4. Add columns to workspaces +ALTER TABLE workspaces ADD COLUMN organization_id TEXT REFERENCES organizations(id); +ALTER TABLE workspaces ADD COLUMN last_activity_at INTEGER; +ALTER TABLE workspaces ADD COLUMN deleted_at INTEGER; + +-- 5. Backfill: create shadow org per workspace +-- (Run as a script, not raw SQL — needs snowflake ID generation) + +-- 6. Make organization_id NOT NULL after backfill +-- (D1 doesn't support ALTER COLUMN, so this is enforced in application code) + +-- 7. Drop plan from workspaces +-- (D1 doesn't support DROP COLUMN — leave it, stop reading/writing it) +``` + +--- + +## Environment Additions (wrangler.toml) + +```toml +# Secrets (set via `wrangler secret put`) +# RESEND_API_KEY +# STRIPE_SECRET_KEY +# STRIPE_WEBHOOK_SECRET +# ADMIN_SECRET + +# Cron trigger +[triggers] +crons = ["0 4 * * *"] # Daily at 4am UTC +``` + +--- + +## Implementation Order + +1. **Schema migration** — new tables, alter workspaces +2. **Org engine + routes** — CRUD, shadow org creation +3. **Auth middleware** — org key + session cookie support +4. **Email verification** — Resend integration, verify flow +5. **Update `POST /workspaces`** — auto-create shadow org +6. **Claiming** — `POST /org/claim` with workspace key proof +7. **Stripe integration** — checkout, webhooks, portal +8. **Admin endpoint** — external billing support +9. **TTL worker** — cron trigger, message trimming, workspace cleanup +10. **`last_activity_at` tracking** — KV coalescing on hot paths +11. **Web UI** — signup, login, dashboard, billing pages +12. **Update README + openapi.yaml** diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 6bdf68d9..05f90788 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -31,12 +31,24 @@ 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', + billingSource: null, + stripeCustomerId: null, + subscriptionStatus: null, + 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 +84,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 +128,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..e5d569d7 --- /dev/null +++ b/packages/server/src/db/migrations/0002_organizations.sql @@ -0,0 +1,66 @@ +-- 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', + billing_source TEXT, + stripe_customer_id TEXT, + subscription_status TEXT, + 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); +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 c2f27f4d..ceb6c3b9 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -9,15 +9,109 @@ 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'), + billingSource: text('billing_source'), // 'stripe' | 'external' | null + stripeCustomerId: text('stripe_customer_id'), + subscriptionStatus: text('subscription_status'), // 'active' | 'past_due' | 'canceled' | null + 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/organization.ts b/packages/server/src/engine/organization.ts new file mode 100644 index 00000000..840cdeb4 --- /dev/null +++ b/packages/server/src/engine/organization.ts @@ -0,0 +1,309 @@ +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, + billing_source: org.billingSource, + subscription_status: org.subscriptionStatus, + 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, + updates: { + plan: string; + billing_source?: string | null; + stripe_customer_id?: string | null; + subscription_status?: string | null; + }, +) { + const setClause: Record = { plan: updates.plan }; + if (updates.billing_source !== undefined) setClause.billingSource = updates.billing_source; + if (updates.stripe_customer_id !== undefined) setClause.stripeCustomerId = updates.stripe_customer_id; + if (updates.subscription_status !== undefined) setClause.subscriptionStatus = updates.subscription_status; + + await db + .update(organizations) + .set(setClause) + .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/ttl.ts b/packages/server/src/engine/ttl.ts new file mode 100644 index 00000000..6109455c --- /dev/null +++ b/packages/server/src/engine/ttl.ts @@ -0,0 +1,116 @@ +import { eq, and, lt, isNull, isNotNull, sql } from 'drizzle-orm'; +import type { getDb } from '../db/index.js'; +import { organizations, workspaces, messages } 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), + lt(workspaces.lastActivityAt, 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( + (await import('../db/schema.js')).sessions, + ).where(lt((await import('../db/schema.js')).sessions.expiresAt, new Date())); + + await db.delete( + (await import('../db/schema.js')).emailVerifications, + ).where(lt((await import('../db/schema.js')).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..3582decf --- /dev/null +++ b/packages/server/src/engine/user.ts @@ -0,0 +1,296 @@ +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); + return hash.toString('hex') === hashHex; +} + +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 = generateId(); + 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..a8500979 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,17 @@ export interface CloudflareBindings { RELAYCAST_TELEMETRY_DISABLED?: string; POSTHOG_API_KEY?: string; POSTHOG_HOST?: string; + RESEND_API_KEY?: string; + STRIPE_SECRET_KEY?: string; + STRIPE_WEBHOOK_SECRET?: 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/lib/email.ts b/packages/server/src/lib/email.ts new file mode 100644 index 00000000..37b6ae32 --- /dev/null +++ b/packages/server/src/lib/email.ts @@ -0,0 +1,69 @@ +interface ResendPayload { + from: string; + to: string; + subject: string; + html: string; +} + +async function sendEmail(apiKey: string, payload: ResendPayload): Promise { + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Resend API error (${res.status}): ${body}`); + } +} + +export async function sendVerificationEmail( + apiKey: string, + to: string, + code: string, +): Promise { + await sendEmail(apiKey, { + from: 'Relaycast ', + to, + subject: 'Verify your Relaycast email', + html: ` +
+

Verify your email

+

Your verification code is:

+
+ ${code} +
+

This code expires in 15 minutes.

+
+ `, + }); +} + +export async function sendExpirationWarning( + apiKey: string, + to: string, + workspaceName: string, + daysRemaining: number, +): Promise { + await sendEmail(apiKey, { + from: 'Relaycast ', + to, + subject: `Your workspace "${workspaceName}" will expire in ${daysRemaining} days`, + html: ` +
+

Workspace expiration warning

+

Your workspace ${workspaceName} has been inactive and will be deleted in ${daysRemaining} days.

+

To prevent deletion, either:

+ +

Free workspaces are deleted after 60 days of inactivity.

+
+ `, + }); +} 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..e9d6cad6 100644 --- a/packages/server/src/middleware/rateLimit.ts +++ b/packages/server/src/middleware/rateLimit.ts @@ -5,7 +5,6 @@ import type { AppEnv } from '../env.js'; const RATE_LIMITS: Record = { free: 60, pro: 300, - enterprise: 1000, }; // Per-route rate limit multipliers (fraction of global limit) @@ -66,7 +65,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 +118,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 +136,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..0df13807 --- /dev/null +++ b/packages/server/src/routes/admin.ts @@ -0,0 +1,41 @@ +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']), + billing_source: z.enum(['external']).optional(), +}); + +// PUT /admin/orgs/:id/plan - set org plan (external billing) +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 { plan, billing_source } = parsed.data; + + const result = await orgEngine.setOrgPlan(db, orgId, { + plan, + billing_source: billing_source ?? (plan === 'free' ? null : undefined), + subscription_status: plan === 'pro' ? 'active' : null, + }); + + 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/billing.ts b/packages/server/src/routes/billing.ts new file mode 100644 index 00000000..d29bc2b5 --- /dev/null +++ b/packages/server/src/routes/billing.ts @@ -0,0 +1,108 @@ +import { Hono } from 'hono'; +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'; + +export const billingRoutes = new Hono(); + +// POST /org/billing/checkout - create Stripe Checkout session +billingRoutes.post('/org/billing/checkout', requireOrgAuth, rateLimit, async (c) => { + const stripeKey = c.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + return c.json({ ok: false, error: { code: 'billing_not_configured', message: 'Stripe is not configured' } }, 503); + } + + const org = c.get('organization'); + if (org.plan === 'pro' && org.subscriptionStatus === 'active') { + return c.json({ ok: false, error: { code: 'already_subscribed', message: 'Organization already has an active subscription' } }, 409); + } + + try { + // Create Stripe Checkout Session via API + const params = new URLSearchParams(); + params.set('mode', 'subscription'); + params.set('client_reference_id', org.id); + params.set('success_url', 'https://relaycast.dev/dashboard/billing?success=true'); + params.set('cancel_url', 'https://relaycast.dev/dashboard/billing?canceled=true'); + params.set('line_items[0][price]', 'price_relaycast_pro'); // Stripe price ID to be configured + params.set('line_items[0][quantity]', '1'); + + if (org.stripeCustomerId) { + params.set('customer', org.stripeCustomerId); + } + + const res = await fetch('https://api.stripe.com/v1/checkout/sessions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${stripeKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!res.ok) { + const body = await res.text(); + return c.json({ ok: false, error: { code: 'stripe_error', message: `Stripe error: ${body}` } }, 502); + } + + const session = await res.json() as { url: string; id: string }; + return c.json({ ok: true, data: { checkout_url: session.url, session_id: session.id } }); + } catch (err: unknown) { + const error = err as Error; + return c.json({ ok: false, error: { code: 'internal_error', message: error.message } }, 500); + } +}); + +// POST /org/billing/portal - create Stripe Customer Portal session +billingRoutes.post('/org/billing/portal', requireOrgAuth, rateLimit, async (c) => { + const stripeKey = c.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + return c.json({ ok: false, error: { code: 'billing_not_configured', message: 'Stripe is not configured' } }, 503); + } + + const org = c.get('organization'); + if (!org.stripeCustomerId) { + return c.json({ ok: false, error: { code: 'no_subscription', message: 'No Stripe customer found. Subscribe first.' } }, 400); + } + + try { + const params = new URLSearchParams(); + params.set('customer', org.stripeCustomerId); + params.set('return_url', 'https://relaycast.dev/dashboard/billing'); + + const res = await fetch('https://api.stripe.com/v1/billing_portal/sessions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${stripeKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!res.ok) { + const body = await res.text(); + return c.json({ ok: false, error: { code: 'stripe_error', message: `Stripe error: ${body}` } }, 502); + } + + const session = await res.json() as { url: string }; + return c.json({ ok: true, data: { portal_url: session.url } }); + } catch (err: unknown) { + const error = err as Error; + return c.json({ ok: false, error: { code: 'internal_error', message: error.message } }, 500); + } +}); + +// GET /org/billing - get billing status +billingRoutes.get('/org/billing', requireOrgAuth, rateLimit, async (c) => { + const org = c.get('organization'); + return c.json({ + ok: true, + data: { + plan: org.plan, + billing_source: org.billingSource, + subscription_status: org.subscriptionStatus, + stripe_customer_id: org.stripeCustomerId ?? null, + }, + }); +}); 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/stripeWebhook.ts b/packages/server/src/routes/stripeWebhook.ts new file mode 100644 index 00000000..7563356a --- /dev/null +++ b/packages/server/src/routes/stripeWebhook.ts @@ -0,0 +1,156 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../env.js'; +import { getDb } from '../db/index.js'; +import * as orgEngine from '../engine/organization.js'; +import { organizations } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; +import { createLogger, toErrorDetails } from '../lib/logger.js'; + +export const stripeWebhookRoutes = new Hono(); + +async function verifyStripeSignature( + body: string, + signature: string, + secret: string, +): Promise { + const parts = signature.split(','); + const timestamp = parts.find((p) => p.startsWith('t='))?.slice(2); + const v1 = parts.find((p) => p.startsWith('v1='))?.slice(3); + + if (!timestamp || !v1) return false; + + const payload = `${timestamp}.${body}`; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); + const expected = Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + // Timing-safe comparison + if (expected.length !== v1.length) return false; + let mismatch = 0; + for (let i = 0; i < expected.length; i++) { + mismatch |= expected.charCodeAt(i) ^ v1.charCodeAt(i); + } + return mismatch === 0; +} + +interface StripeEvent { + type: string; + data: { + object: Record; + }; +} + +// POST /webhooks/stripe +stripeWebhookRoutes.post('/webhooks/stripe', async (c) => { + const webhookSecret = c.env.STRIPE_WEBHOOK_SECRET; + if (!webhookSecret) { + return c.text('Webhook secret not configured', 503); + } + + const signature = c.req.header('Stripe-Signature'); + if (!signature) { + return c.text('Missing Stripe-Signature header', 400); + } + + const body = await c.req.text(); + const valid = await verifyStripeSignature(body, signature, webhookSecret); + if (!valid) { + return c.text('Invalid signature', 401); + } + + const event: StripeEvent = JSON.parse(body); + const db = getDb(c.env.DB); + const logger = createLogger(c.env, { source: 'stripe_webhook' }); + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as { + client_reference_id?: string; + customer?: string; + subscription?: string; + }; + const orgId = session.client_reference_id; + if (orgId) { + await orgEngine.setOrgPlan(db, orgId, { + plan: 'pro', + billing_source: 'stripe', + stripe_customer_id: (session.customer as string) ?? null, + subscription_status: 'active', + }); + logger.info('Org upgraded to pro via Stripe checkout', { org_id: orgId }); + } + break; + } + + case 'invoice.paid': { + const invoice = event.data.object as { customer?: string }; + if (invoice.customer) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.stripeCustomerId, invoice.customer as string)); + if (org) { + await orgEngine.setOrgPlan(db, org.id, { + plan: 'pro', + subscription_status: 'active', + }); + } + } + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object as { customer?: string }; + if (invoice.customer) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.stripeCustomerId, invoice.customer as string)); + if (org) { + await orgEngine.setOrgPlan(db, org.id, { + plan: org.plan, + subscription_status: 'past_due', + }); + logger.warn('Payment failed for org', { org_id: org.id }); + } + } + break; + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as { customer?: string }; + if (subscription.customer) { + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.stripeCustomerId, subscription.customer as string)); + if (org) { + await orgEngine.setOrgPlan(db, org.id, { + plan: 'free', + subscription_status: 'canceled', + }); + logger.info('Org downgraded to free via subscription cancellation', { org_id: org.id }); + } + } + break; + } + } + } catch (err) { + logger.error('Stripe webhook handler error', { + event_type: event.type, + ...toErrorDetails(err), + }); + } + + await logger.flush(); + return c.text('ok', 200); +}); diff --git a/packages/server/src/routes/user.ts b/packages/server/src/routes/user.ts new file mode 100644 index 00000000..b311aa9f --- /dev/null +++ b/packages/server/src/routes/user.ts @@ -0,0 +1,163 @@ +import { Hono } from 'hono'; +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'; +import { sendVerificationEmail } from '../lib/email.js'; + +export const userRoutes = new Hono(); + +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', 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); + + if (c.env.RESEND_API_KEY) { + await sendVerificationEmail(c.env.RESEND_API_KEY, result.email, result.verification_code); + } + + return c.json({ + ok: true, + data: { + user_id: result.user_id, + 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', 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', 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..06098fcd 100644 --- a/packages/server/src/worker.ts +++ b/packages/server/src/worker.ts @@ -25,6 +25,11 @@ 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 { billingRoutes } from './routes/billing.js'; +import { stripeWebhookRoutes } from './routes/stripeWebhook.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 +245,17 @@ app.get('/v1/ws', async (c) => { return c.json({ ok: false, error: { code: 'invalid_token', message: 'Invalid token format' } }, 401); }); +// Stripe webhook — outside v1, raw body needed for signature verification +app.route('/', stripeWebhookRoutes); + +// 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('/', billingRoutes); v1.route('/', presenceRoutes); v1.route('/', systemPromptRoutes); v1.route('/', workspaceRoutes); @@ -322,7 +336,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 c83fd0dc..24fc5845 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..4e9938e2 --- /dev/null +++ b/packages/types/src/organization.ts @@ -0,0 +1,110 @@ +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']), + billing_source: z.enum(['stripe', 'external']).nullable(), + subscription_status: z.enum(['active', 'past_due', 'canceled']).nullable(), + 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 OrgBillingSchema = z.object({ + plan: z.enum(['free', 'pro']), + billing_source: z.enum(['stripe', 'external']).nullable(), + subscription_status: z.enum(['active', 'past_due', 'canceled']).nullable(), + stripe_customer_id: z.string().nullable(), +}); +export type OrgBilling = z.infer; + +export const AdminSetPlanRequestSchema = z.object({ + plan: z.enum(['free', 'pro']), + billing_source: z.enum(['external']).optional(), +}); +export type AdminSetPlanRequest = z.infer; diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index 04e22b5a..2e4de7da 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -2,10 +2,11 @@ import { z } from 'zod'; export const WorkspaceSchema = z.object({ id: z.string(), + organization_id: z.string(), name: z.string(), api_key_hash: 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/auth.css b/site/auth.css new file mode 100644 index 00000000..9e74a5fb --- /dev/null +++ b/site/auth.css @@ -0,0 +1,126 @@ +/* ── Auth Pages ── */ +.auth-container { + display: flex; + justify-content: center; + align-items: center; + min-height: calc(100vh - 64px); + padding: 40px 20px; +} + +.auth-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 40px; + width: 100%; + max-width: 420px; +} + +.auth-card h1 { + font-size: 1.5rem; + margin-bottom: 8px; +} + +.auth-sub { + color: var(--text-muted); + margin-bottom: 24px; + font-size: 0.9rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-muted); +} + +.form-group input { + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 0.95rem; + font-family: var(--sans); + outline: none; + transition: border-color 0.2s; +} + +.form-group input:focus { + border-color: var(--accent); +} + +.form-group input::placeholder { + color: var(--text-muted); + opacity: 0.5; +} + +.code-input { + font-family: var(--mono) !important; + font-size: 1.5rem !important; + text-align: center; + letter-spacing: 8px; +} + +.auth-btn { + width: 100%; + justify-content: center; + margin-top: 8px; +} + +.auth-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius-sm); + padding: 10px 14px; + color: #f87171; + font-size: 0.85rem; +} + +.auth-success { + background: rgba(52, 211, 153, 0.1); + border: 1px solid rgba(52, 211, 153, 0.3); + border-radius: var(--radius-sm); + padding: 10px 14px; + color: var(--green); + font-size: 0.85rem; +} + +.auth-footer { + margin-top: 20px; + text-align: center; + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ── Modal ── */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} + +.modal-content { + max-width: 440px; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} diff --git a/site/dashboard.css b/site/dashboard.css new file mode 100644 index 00000000..3ba02126 --- /dev/null +++ b/site/dashboard.css @@ -0,0 +1,169 @@ +/* ── Dashboard ── */ +.dash-container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px 80px; +} + +.dash-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 32px; +} + +.dash-header h1 { + font-size: 1.75rem; +} + +.plan-badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.plan-free { + background: rgba(122, 122, 142, 0.15); + color: var(--text-muted); +} + +.plan-pro { + background: var(--accent-glow); + color: var(--accent-hover); +} + +.dash-section { + margin-bottom: 32px; +} + +.dash-section h2 { + font-size: 1.1rem; + margin-bottom: 12px; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.section-header h2 { + margin-bottom: 0; +} + +.section-desc { + color: var(--text-muted); + font-size: 0.85rem; + margin-bottom: 12px; +} + +.dash-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +/* ── Billing ── */ +.billing-info { + display: flex; + gap: 32px; + margin-bottom: 16px; +} + +.billing-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.billing-label { + font-size: 0.8rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.billing-value { + font-size: 1rem; + font-weight: 500; +} + +.billing-actions { + display: flex; + gap: 12px; +} + +/* ── Workspaces ── */ +.workspaces-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.workspace-card { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 18px; +} + +.ws-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ws-name { + font-weight: 500; + font-family: var(--mono); + font-size: 0.9rem; +} + +.ws-meta { + font-size: 0.8rem; + color: var(--text-muted); +} + +.ws-activity { + font-size: 0.8rem; + color: var(--text-muted); +} + +.empty-state { + color: var(--text-muted); + font-size: 0.9rem; + padding: 20px; + text-align: center; +} + +/* ── Claim ── */ +.claim-form { + display: flex; + gap: 8px; +} + +.claim-form input { + flex: 1; + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-family: var(--mono); + font-size: 0.85rem; + outline: none; +} + +.claim-form input:focus { + border-color: var(--accent); +} diff --git a/site/dashboard.html b/site/dashboard.html new file mode 100644 index 00000000..adef1104 --- /dev/null +++ b/site/dashboard.html @@ -0,0 +1,242 @@ + + + + + + Dashboard — Relaycast + + + + + + + + + + + + + +
+
+

Loading dashboard...

+
+
+ + + + + + + diff --git a/site/index.html b/site/index.html index a86e70b5..12bf52de 100644 --- a/site/index.html +++ b/site/index.html @@ -25,7 +25,8 @@ OpenClaw Docs GitHub - Get Started + Log In + Sign Up @@ -373,7 +374,7 @@

Simple, predictable pricing

  • MCP server included
  • Community support
  • - Get Started + Get Started Free
    Enterprise
    diff --git a/site/login.html b/site/login.html new file mode 100644 index 00000000..0fde5d3b --- /dev/null +++ b/site/login.html @@ -0,0 +1,90 @@ + + + + + + Log In — Relaycast + + + + + + + + + +
    +
    +

    Log in

    +

    Sign in to manage your organization.

    + +
    +
    + + +
    +
    + + +
    + + +
    + + +
    +
    + + + + diff --git a/site/signup.html b/site/signup.html new file mode 100644 index 00000000..42c8d9fd --- /dev/null +++ b/site/signup.html @@ -0,0 +1,91 @@ + + + + + + Sign Up — Relaycast + + + + + + + + + +
    +
    +

    Create your organization

    +

    Sign up to manage workspaces and unlock paid features.

    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + + +
    +
    + + + + diff --git a/site/verify.html b/site/verify.html new file mode 100644 index 00000000..f985a71e --- /dev/null +++ b/site/verify.html @@ -0,0 +1,93 @@ + + + + + + Verify Email — Relaycast + + + + + + + + + +
    +
    +

    Check your email

    +

    We sent a 6-digit verification code to your email address.

    + +
    +
    + + +
    + + +
    + + +
    +
    + + + + 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 From f9751fc55ecda140a9b60df09a7fc02773aaf3f7 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 3 Mar 2026 08:41:43 -0800 Subject: [PATCH 2/4] Remove Stripe billing support Remove legacy Stripe billing integration and related schema/fields. Deleted billing and Stripe webhook routes and billing docs; removed STRIPE_* env vars; dropped billing columns from organizations schema and added a DB migration (0003_remove_billing.sql) to remove those columns. Updated organization engine (getOrg, setOrgPlan signature), admin route, worker route registration, types, and tests to reflect removal of billing fields. Run the new migration to apply schema changes in the database. --- billing-plan.md | 276 ------------------ packages/server/src/__tests__/test-helpers.ts | 3 - .../src/db/migrations/0002_organizations.sql | 3 - packages/server/src/db/schema.ts | 3 - packages/server/src/engine/organization.ts | 16 +- packages/server/src/env.ts | 2 - packages/server/src/routes/admin.ts | 10 +- packages/server/src/routes/billing.ts | 108 ------- packages/server/src/routes/stripeWebhook.ts | 156 ---------- packages/server/src/worker.ts | 6 - packages/types/src/organization.ts | 11 - 11 files changed, 4 insertions(+), 590 deletions(-) delete mode 100644 billing-plan.md delete mode 100644 packages/server/src/routes/billing.ts delete mode 100644 packages/server/src/routes/stripeWebhook.ts diff --git a/billing-plan.md b/billing-plan.md deleted file mode 100644 index c408e063..00000000 --- a/billing-plan.md +++ /dev/null @@ -1,276 +0,0 @@ -# Billing & Workspace Lifecycle Plan - -## Overview - -Add organizations, billing (Stripe + external), workspace TTL, and email-based signup to RelayCast. Free workspaces require no signup. Paid workspaces require an org with verified email and active payment. - ---- - -## Data Model - -### New: `organizations` table - -| Column | Type | Notes | -|--------|------|-------| -| id | text PK | Snowflake ID | -| name | text | Org display name | -| email | text | Signup email (unique, nullable for shadow orgs) | -| email_verified | integer | 0/1 boolean | -| password_hash | text | Argon2 hash (nullable for shadow orgs) | -| plan | text | `'free'` or `'pro'` | -| billing_source | text | `'stripe'`, `'external'`, or `null` | -| stripe_customer_id | text | Nullable | -| subscription_status | text | `'active'`, `'past_due'`, `'canceled'`, or `null` | -| org_api_key_hash | text | SHA256 hash of `rk_org_*` key (nullable for shadow orgs) | -| created_at | integer | Unix timestamp | - -### Modified: `workspaces` table - -| Change | Column | Notes | -|--------|--------|-------| -| ADD | organization_id | FK → organizations.id, NOT NULL | -| ADD | last_activity_at | Unix timestamp, updated on messages/events | -| ADD | deleted_at | Nullable, set on soft-delete | -| DROP | plan | Inherited from org | - -### New: `sessions` table (web auth) - -| Column | Type | Notes | -|--------|------|-------| -| id | text PK | Random token | -| organization_id | text FK | | -| expires_at | integer | Unix timestamp | -| created_at | integer | | - -### New: `email_verifications` table - -| Column | Type | Notes | -|--------|------|-------| -| id | text PK | | -| email | text | | -| code | text | 6-digit code | -| organization_id | text FK | | -| expires_at | integer | 15 min TTL | -| created_at | integer | | - ---- - -## API Endpoints - -### Org Management - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | /orgs | None | Sign up: email + password + name → creates org, sends verification email, returns org API key | -| POST | /orgs/verify | None | Verify email with code | -| POST | /orgs/login | None | Email + password → sets session cookie + returns org API key | -| POST | /orgs/logout | Session cookie | Clears session | -| GET | /org | Org key or session | Get current org details | -| PATCH | /org | Org key or session | Update org name | -| POST | /org/claim | Org key or session | Attach free workspace to org (body: `{ workspace_api_key }`) | -| POST | /org/workspaces | Org key or session | Create workspace under this org | -| GET | /org/workspaces | Org key or session | List org's workspaces | - -### Billing - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| POST | /org/billing/checkout | Org key or session | Create Stripe Checkout session → return URL | -| POST | /org/billing/portal | Org key or session | Create Stripe Customer Portal session → return URL | -| GET | /org/billing | Org key or session | Get billing status (plan, subscription_status, current_period_end) | -| POST | /webhooks/stripe | Stripe signature | Handle Stripe events | - -### Admin (shared secret) - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| PUT | /admin/orgs/:id/plan | `X-Admin-Secret` header | Set plan + billing_source for external billing | - -### Existing (unchanged behavior) - -`POST /workspaces` stays unauthenticated. Internally it now creates a shadow org (`email: null`, `plan: 'free'`) and links the workspace to it. - ---- - -## Auth Model - -Three auth mechanisms, checked in order: - -1. **Workspace API key** (`Authorization: Bearer rk_live_*`) — scoped to one workspace, used by agents. Same as today. -2. **Org API key** (`Authorization: Bearer rk_org_*`) — scoped to org, used for management. Only works after email verification. -3. **Session cookie** (`relaycast_session`) — set by `/orgs/login`, used by the web UI. HttpOnly, Secure, SameSite=Lax. - -Org-level endpoints accept either org key or session cookie. - ---- - -## Stripe Integration - -### Checkout Flow - -1. User hits "Upgrade" in web UI → `POST /org/billing/checkout` -2. Server creates Stripe Checkout Session with `client_reference_id: org.id` -3. Redirects to Stripe Checkout -4. On success, Stripe sends `checkout.session.completed` webhook -5. Server sets `plan: 'pro'`, `billing_source: 'stripe'`, `subscription_status: 'active'`, stores `stripe_customer_id` - -### Webhook Events - -| Event | Action | -|-------|--------| -| `checkout.session.completed` | Set pro + active | -| `invoice.paid` | Set active (renewal confirmation) | -| `invoice.payment_failed` | Set past_due | -| `customer.subscription.deleted` | Set plan: free, status: canceled | - -### External Billing - -External service calls `PUT /admin/orgs/:id/plan` with: -```json -{ "plan": "pro", "billing_source": "external" } -``` -To downgrade: `{ "plan": "free" }`. No Stripe involvement. - ---- - -## Workspace TTL & Cleanup - -### Rules - -| Org Plan | Message Retention | Workspace Lifetime | -|----------|-------------------|-------------------| -| free | Rolling 30 days | 60 days after last activity (30 normal + 30 grace) | -| pro | Unlimited | Never expires | - -### `last_activity_at` Updates - -Updated on: message send, reaction add, file upload, agent connect. Use KV write-coalescing to avoid a D1 write per event — batch update every 5 minutes per workspace. - -### Scheduled Worker (Cron Trigger, daily) - -``` -1. For each free-plan org: - a. DELETE messages WHERE created_at < now() - 30 days - b. For workspaces WHERE last_activity_at < now() - 60 days AND deleted_at IS NULL: - - SET deleted_at = now() (soft delete) - c. For workspaces WHERE deleted_at < now() - 30 days: - - Hard delete workspace + all related data -2. Clean up expired sessions and email verification codes -``` - -Soft-deleted workspaces return `410 Gone` on API requests. The workspace API key still resolves (for error messaging) but all operations are blocked. - ---- - -## Email (Resend) - -- Verification emails on signup (6-digit code, 15-min expiry) -- Workspace expiration warnings (7 days before soft-delete) -- Payment failure notifications - -Environment binding: `RESEND_API_KEY` secret in wrangler.toml. - ---- - -## Web UI (site/) - -Add to the existing static site at relaycast.dev: - -| Page | Path | Description | -|------|------|-------------| -| Sign Up | /signup | Email + password + org name form | -| Verify | /verify | Enter 6-digit code | -| Login | /login | Email + password | -| Dashboard | /dashboard | Org overview: workspaces, plan, usage | -| Billing | /dashboard/billing | Current plan, upgrade button, Stripe portal link | -| Workspaces | /dashboard/workspaces | List, create, delete workspaces | - -These can be static HTML + JS (same pattern as current site) calling the API with session cookies. No framework needed. - ---- - -## Migration Plan - -### D1 Migration SQL - -```sql --- 1. Create organizations table -CREATE TABLE organizations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT UNIQUE, - email_verified INTEGER NOT NULL DEFAULT 0, - password_hash TEXT, - plan TEXT NOT NULL DEFAULT 'free', - billing_source TEXT, - stripe_customer_id TEXT, - subscription_status TEXT, - org_api_key_hash TEXT UNIQUE, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - --- 2. Create sessions table -CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - --- 3. Create email_verifications table -CREATE TABLE email_verifications ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL, - code TEXT NOT NULL, - organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) -); - --- 4. Add columns to workspaces -ALTER TABLE workspaces ADD COLUMN organization_id TEXT REFERENCES organizations(id); -ALTER TABLE workspaces ADD COLUMN last_activity_at INTEGER; -ALTER TABLE workspaces ADD COLUMN deleted_at INTEGER; - --- 5. Backfill: create shadow org per workspace --- (Run as a script, not raw SQL — needs snowflake ID generation) - --- 6. Make organization_id NOT NULL after backfill --- (D1 doesn't support ALTER COLUMN, so this is enforced in application code) - --- 7. Drop plan from workspaces --- (D1 doesn't support DROP COLUMN — leave it, stop reading/writing it) -``` - ---- - -## Environment Additions (wrangler.toml) - -```toml -# Secrets (set via `wrangler secret put`) -# RESEND_API_KEY -# STRIPE_SECRET_KEY -# STRIPE_WEBHOOK_SECRET -# ADMIN_SECRET - -# Cron trigger -[triggers] -crons = ["0 4 * * *"] # Daily at 4am UTC -``` - ---- - -## Implementation Order - -1. **Schema migration** — new tables, alter workspaces -2. **Org engine + routes** — CRUD, shadow org creation -3. **Auth middleware** — org key + session cookie support -4. **Email verification** — Resend integration, verify flow -5. **Update `POST /workspaces`** — auto-create shadow org -6. **Claiming** — `POST /org/claim` with workspace key proof -7. **Stripe integration** — checkout, webhooks, portal -8. **Admin endpoint** — external billing support -9. **TTL worker** — cron trigger, message trimming, workspace cleanup -10. **`last_activity_at` tracking** — KV coalescing on hot paths -11. **Web UI** — signup, login, dashboard, billing pages -12. **Update README + openapi.yaml** diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 05f90788..b2630c19 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -35,9 +35,6 @@ export const FAKE_ORGANIZATION = { id: 'org_123', name: 'test-org', plan: 'free', - billingSource: null, - stripeCustomerId: null, - subscriptionStatus: null, orgApiKeyHash: null, createdAt: new Date(), }; diff --git a/packages/server/src/db/migrations/0002_organizations.sql b/packages/server/src/db/migrations/0002_organizations.sql index e5d569d7..35641b59 100644 --- a/packages/server/src/db/migrations/0002_organizations.sql +++ b/packages/server/src/db/migrations/0002_organizations.sql @@ -13,9 +13,6 @@ CREATE TABLE IF NOT EXISTS organizations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL DEFAULT 'free', - billing_source TEXT, - stripe_customer_id TEXT, - subscription_status TEXT, org_api_key_hash TEXT UNIQUE, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 90b183a6..7821c7ef 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -28,9 +28,6 @@ export const organizations = sqliteTable('organizations', { id: text('id').primaryKey(), name: text('name').notNull(), plan: text('plan').notNull().default('free'), - billingSource: text('billing_source'), // 'stripe' | 'external' | null - stripeCustomerId: text('stripe_customer_id'), - subscriptionStatus: text('subscription_status'), // 'active' | 'past_due' | 'canceled' | null orgApiKeyHash: text('org_api_key_hash').unique(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`), }); diff --git a/packages/server/src/engine/organization.ts b/packages/server/src/engine/organization.ts index 840cdeb4..a55eb96c 100644 --- a/packages/server/src/engine/organization.ts +++ b/packages/server/src/engine/organization.ts @@ -70,8 +70,6 @@ export async function getOrg(db: Db, orgId: string) { id: org.id, name: org.name, plan: org.plan, - billing_source: org.billingSource, - subscription_status: org.subscriptionStatus, created_at: org.createdAt.toISOString(), }; } @@ -172,21 +170,11 @@ export async function getOrgWorkspaces(db: Db, orgId: string) { export async function setOrgPlan( db: Db, orgId: string, - updates: { - plan: string; - billing_source?: string | null; - stripe_customer_id?: string | null; - subscription_status?: string | null; - }, + plan: string, ) { - const setClause: Record = { plan: updates.plan }; - if (updates.billing_source !== undefined) setClause.billingSource = updates.billing_source; - if (updates.stripe_customer_id !== undefined) setClause.stripeCustomerId = updates.stripe_customer_id; - if (updates.subscription_status !== undefined) setClause.subscriptionStatus = updates.subscription_status; - await db .update(organizations) - .set(setClause) + .set({ plan }) .where(eq(organizations.id, orgId)); return getOrg(db, orgId); diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index a8500979..d7bac762 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -26,8 +26,6 @@ export interface CloudflareBindings { POSTHOG_API_KEY?: string; POSTHOG_HOST?: string; RESEND_API_KEY?: string; - STRIPE_SECRET_KEY?: string; - STRIPE_WEBHOOK_SECRET?: string; ADMIN_SECRET?: string; } diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 0df13807..28155deb 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -8,10 +8,9 @@ export const adminRoutes = new Hono(); const setPlanSchema = z.object({ plan: z.enum(['free', 'pro']), - billing_source: z.enum(['external']).optional(), }); -// PUT /admin/orgs/:id/plan - set org plan (external billing) +// 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()); @@ -21,13 +20,8 @@ adminRoutes.put('/admin/orgs/:id/plan', requireAdminSecret, async (c) => { const db = c.get('db'); const orgId = c.req.param('id'); - const { plan, billing_source } = parsed.data; - const result = await orgEngine.setOrgPlan(db, orgId, { - plan, - billing_source: billing_source ?? (plan === 'free' ? null : undefined), - subscription_status: plan === 'pro' ? 'active' : null, - }); + 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); diff --git a/packages/server/src/routes/billing.ts b/packages/server/src/routes/billing.ts deleted file mode 100644 index d29bc2b5..00000000 --- a/packages/server/src/routes/billing.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Hono } from 'hono'; -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'; - -export const billingRoutes = new Hono(); - -// POST /org/billing/checkout - create Stripe Checkout session -billingRoutes.post('/org/billing/checkout', requireOrgAuth, rateLimit, async (c) => { - const stripeKey = c.env.STRIPE_SECRET_KEY; - if (!stripeKey) { - return c.json({ ok: false, error: { code: 'billing_not_configured', message: 'Stripe is not configured' } }, 503); - } - - const org = c.get('organization'); - if (org.plan === 'pro' && org.subscriptionStatus === 'active') { - return c.json({ ok: false, error: { code: 'already_subscribed', message: 'Organization already has an active subscription' } }, 409); - } - - try { - // Create Stripe Checkout Session via API - const params = new URLSearchParams(); - params.set('mode', 'subscription'); - params.set('client_reference_id', org.id); - params.set('success_url', 'https://relaycast.dev/dashboard/billing?success=true'); - params.set('cancel_url', 'https://relaycast.dev/dashboard/billing?canceled=true'); - params.set('line_items[0][price]', 'price_relaycast_pro'); // Stripe price ID to be configured - params.set('line_items[0][quantity]', '1'); - - if (org.stripeCustomerId) { - params.set('customer', org.stripeCustomerId); - } - - const res = await fetch('https://api.stripe.com/v1/checkout/sessions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${stripeKey}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params.toString(), - }); - - if (!res.ok) { - const body = await res.text(); - return c.json({ ok: false, error: { code: 'stripe_error', message: `Stripe error: ${body}` } }, 502); - } - - const session = await res.json() as { url: string; id: string }; - return c.json({ ok: true, data: { checkout_url: session.url, session_id: session.id } }); - } catch (err: unknown) { - const error = err as Error; - return c.json({ ok: false, error: { code: 'internal_error', message: error.message } }, 500); - } -}); - -// POST /org/billing/portal - create Stripe Customer Portal session -billingRoutes.post('/org/billing/portal', requireOrgAuth, rateLimit, async (c) => { - const stripeKey = c.env.STRIPE_SECRET_KEY; - if (!stripeKey) { - return c.json({ ok: false, error: { code: 'billing_not_configured', message: 'Stripe is not configured' } }, 503); - } - - const org = c.get('organization'); - if (!org.stripeCustomerId) { - return c.json({ ok: false, error: { code: 'no_subscription', message: 'No Stripe customer found. Subscribe first.' } }, 400); - } - - try { - const params = new URLSearchParams(); - params.set('customer', org.stripeCustomerId); - params.set('return_url', 'https://relaycast.dev/dashboard/billing'); - - const res = await fetch('https://api.stripe.com/v1/billing_portal/sessions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${stripeKey}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: params.toString(), - }); - - if (!res.ok) { - const body = await res.text(); - return c.json({ ok: false, error: { code: 'stripe_error', message: `Stripe error: ${body}` } }, 502); - } - - const session = await res.json() as { url: string }; - return c.json({ ok: true, data: { portal_url: session.url } }); - } catch (err: unknown) { - const error = err as Error; - return c.json({ ok: false, error: { code: 'internal_error', message: error.message } }, 500); - } -}); - -// GET /org/billing - get billing status -billingRoutes.get('/org/billing', requireOrgAuth, rateLimit, async (c) => { - const org = c.get('organization'); - return c.json({ - ok: true, - data: { - plan: org.plan, - billing_source: org.billingSource, - subscription_status: org.subscriptionStatus, - stripe_customer_id: org.stripeCustomerId ?? null, - }, - }); -}); diff --git a/packages/server/src/routes/stripeWebhook.ts b/packages/server/src/routes/stripeWebhook.ts deleted file mode 100644 index 7563356a..00000000 --- a/packages/server/src/routes/stripeWebhook.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Hono } from 'hono'; -import type { AppEnv } from '../env.js'; -import { getDb } from '../db/index.js'; -import * as orgEngine from '../engine/organization.js'; -import { organizations } from '../db/schema.js'; -import { eq } from 'drizzle-orm'; -import { createLogger, toErrorDetails } from '../lib/logger.js'; - -export const stripeWebhookRoutes = new Hono(); - -async function verifyStripeSignature( - body: string, - signature: string, - secret: string, -): Promise { - const parts = signature.split(','); - const timestamp = parts.find((p) => p.startsWith('t='))?.slice(2); - const v1 = parts.find((p) => p.startsWith('v1='))?.slice(3); - - if (!timestamp || !v1) return false; - - const payload = `${timestamp}.${body}`; - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); - const expected = Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - - // Timing-safe comparison - if (expected.length !== v1.length) return false; - let mismatch = 0; - for (let i = 0; i < expected.length; i++) { - mismatch |= expected.charCodeAt(i) ^ v1.charCodeAt(i); - } - return mismatch === 0; -} - -interface StripeEvent { - type: string; - data: { - object: Record; - }; -} - -// POST /webhooks/stripe -stripeWebhookRoutes.post('/webhooks/stripe', async (c) => { - const webhookSecret = c.env.STRIPE_WEBHOOK_SECRET; - if (!webhookSecret) { - return c.text('Webhook secret not configured', 503); - } - - const signature = c.req.header('Stripe-Signature'); - if (!signature) { - return c.text('Missing Stripe-Signature header', 400); - } - - const body = await c.req.text(); - const valid = await verifyStripeSignature(body, signature, webhookSecret); - if (!valid) { - return c.text('Invalid signature', 401); - } - - const event: StripeEvent = JSON.parse(body); - const db = getDb(c.env.DB); - const logger = createLogger(c.env, { source: 'stripe_webhook' }); - - try { - switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as { - client_reference_id?: string; - customer?: string; - subscription?: string; - }; - const orgId = session.client_reference_id; - if (orgId) { - await orgEngine.setOrgPlan(db, orgId, { - plan: 'pro', - billing_source: 'stripe', - stripe_customer_id: (session.customer as string) ?? null, - subscription_status: 'active', - }); - logger.info('Org upgraded to pro via Stripe checkout', { org_id: orgId }); - } - break; - } - - case 'invoice.paid': { - const invoice = event.data.object as { customer?: string }; - if (invoice.customer) { - const [org] = await db - .select() - .from(organizations) - .where(eq(organizations.stripeCustomerId, invoice.customer as string)); - if (org) { - await orgEngine.setOrgPlan(db, org.id, { - plan: 'pro', - subscription_status: 'active', - }); - } - } - break; - } - - case 'invoice.payment_failed': { - const invoice = event.data.object as { customer?: string }; - if (invoice.customer) { - const [org] = await db - .select() - .from(organizations) - .where(eq(organizations.stripeCustomerId, invoice.customer as string)); - if (org) { - await orgEngine.setOrgPlan(db, org.id, { - plan: org.plan, - subscription_status: 'past_due', - }); - logger.warn('Payment failed for org', { org_id: org.id }); - } - } - break; - } - - case 'customer.subscription.deleted': { - const subscription = event.data.object as { customer?: string }; - if (subscription.customer) { - const [org] = await db - .select() - .from(organizations) - .where(eq(organizations.stripeCustomerId, subscription.customer as string)); - if (org) { - await orgEngine.setOrgPlan(db, org.id, { - plan: 'free', - subscription_status: 'canceled', - }); - logger.info('Org downgraded to free via subscription cancellation', { org_id: org.id }); - } - } - break; - } - } - } catch (err) { - logger.error('Stripe webhook handler error', { - event_type: event.type, - ...toErrorDetails(err), - }); - } - - await logger.flush(); - return c.text('ok', 200); -}); diff --git a/packages/server/src/worker.ts b/packages/server/src/worker.ts index 06098fcd..96bdacac 100644 --- a/packages/server/src/worker.ts +++ b/packages/server/src/worker.ts @@ -27,8 +27,6 @@ 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 { billingRoutes } from './routes/billing.js'; -import { stripeWebhookRoutes } from './routes/stripeWebhook.js'; import { adminRoutes } from './routes/admin.js'; import { isWorkspaceStreamEnabled } from './lib/workspaceStream.js'; import { createLogger, getRequestLogger, toErrorDetails } from './lib/logger.js'; @@ -245,9 +243,6 @@ app.get('/v1/ws', async (c) => { return c.json({ ok: false, error: { code: 'invalid_token', message: 'Invalid token format' } }, 401); }); -// Stripe webhook — outside v1, raw body needed for signature verification -app.route('/', stripeWebhookRoutes); - // Admin routes — outside v1 prefix app.route('/v1', adminRoutes); @@ -255,7 +250,6 @@ app.route('/v1', adminRoutes); const v1 = new Hono(); v1.route('/', userRoutes); v1.route('/', organizationRoutes); -v1.route('/', billingRoutes); v1.route('/', presenceRoutes); v1.route('/', systemPromptRoutes); v1.route('/', workspaceRoutes); diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts index 4e9938e2..15c02911 100644 --- a/packages/types/src/organization.ts +++ b/packages/types/src/organization.ts @@ -52,8 +52,6 @@ export const OrganizationSchema = z.object({ id: z.string(), name: z.string(), plan: z.enum(['free', 'pro']), - billing_source: z.enum(['stripe', 'external']).nullable(), - subscription_status: z.enum(['active', 'past_due', 'canceled']).nullable(), created_at: z.string(), }); export type Organization = z.infer; @@ -95,16 +93,7 @@ export const ClaimWorkspaceRequestSchema = z.object({ }); export type ClaimWorkspaceRequest = z.infer; -export const OrgBillingSchema = z.object({ - plan: z.enum(['free', 'pro']), - billing_source: z.enum(['stripe', 'external']).nullable(), - subscription_status: z.enum(['active', 'past_due', 'canceled']).nullable(), - stripe_customer_id: z.string().nullable(), -}); -export type OrgBilling = z.infer; - export const AdminSetPlanRequestSchema = z.object({ plan: z.enum(['free', 'pro']), - billing_source: z.enum(['external']).optional(), }); export type AdminSetPlanRequest = z.infer; From d1874f808aa36a82683d83c180ac4612ba81aa5a Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 3 Mar 2026 09:11:13 -0800 Subject: [PATCH 3/4] Remove .claude/settings.local.json and ignore it Delete tracked .claude/settings.local.json (local Claude/MCP config) and add it to .gitignore to prevent committing local/sensitive settings. This avoids storing machine-specific permissions and server configs in the repository. --- .claude/settings.local.json | 61 ------------------------------------- .gitignore | 1 + 2 files changed, 1 insertion(+), 61 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index f7a9569a..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,61 +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:*)", - "Bash(npm run build:*)" - ] - }, - "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/ From b9517eaad89128ff86ffa6bed584cc89dd6f38bb Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 3 Mar 2026 09:26:44 -0800 Subject: [PATCH 4/4] Track workspace activity, auth rate limits, TTL Add workspace activity touch calls across file, message and reaction flows and make workspaces.organization_id cascade on delete (migration). Improve TTL cleanup to expire workspaces with no last activity based on createdAt, and delete expired sessions and email verifications via schema imports. Harden password verification with timing-safe comparison and switch session id creation to generateSessionToken. Remove Resend email helper and related env var usage; return verification code in signup response instead of sending email. Add IP-based auth rate limiter for unauthenticated auth endpoints and bump enterprise global rate limit. Remove several static auth/dashboard site files and update index CTA. --- .../src/db/migrations/0002_organizations.sql | 2 +- packages/server/src/engine/file.ts | 3 + packages/server/src/engine/message.ts | 4 +- packages/server/src/engine/reaction.ts | 3 + packages/server/src/engine/ttl.ts | 18 +- packages/server/src/engine/user.ts | 6 +- packages/server/src/env.ts | 1 - packages/server/src/lib/email.ts | 69 ----- packages/server/src/middleware/rateLimit.ts | 1 + packages/server/src/routes/user.ts | 40 ++- site/auth.css | 126 --------- site/dashboard.css | 169 ------------ site/dashboard.html | 242 ------------------ site/index.html | 4 +- site/login.html | 90 ------- site/signup.html | 91 ------- site/verify.html | 93 ------- 17 files changed, 56 insertions(+), 906 deletions(-) delete mode 100644 packages/server/src/lib/email.ts delete mode 100644 site/auth.css delete mode 100644 site/dashboard.css delete mode 100644 site/dashboard.html delete mode 100644 site/login.html delete mode 100644 site/signup.html delete mode 100644 site/verify.html diff --git a/packages/server/src/db/migrations/0002_organizations.sql b/packages/server/src/db/migrations/0002_organizations.sql index 35641b59..995837ef 100644 --- a/packages/server/src/db/migrations/0002_organizations.sql +++ b/packages/server/src/db/migrations/0002_organizations.sql @@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS email_verifications ( 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); +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; 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/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 index 6109455c..7d46847a 100644 --- a/packages/server/src/engine/ttl.ts +++ b/packages/server/src/engine/ttl.ts @@ -1,6 +1,6 @@ -import { eq, and, lt, isNull, isNotNull, sql } from 'drizzle-orm'; +import { eq, and, lt, or, isNull, isNotNull, sql } from 'drizzle-orm'; import type { getDb } from '../db/index.js'; -import { organizations, workspaces, messages } from '../db/schema.js'; +import { organizations, workspaces, messages, sessions, emailVerifications } from '../db/schema.js'; import type { Logger } from '../lib/logger.js'; type Db = ReturnType; @@ -68,7 +68,10 @@ export async function runTtlCleanup(db: Db, logger: Logger) { and( eq(workspaces.organizationId, org.id), isNull(workspaces.deletedAt), - lt(workspaces.lastActivityAt, sixtyDaysAgo), + or( + lt(workspaces.lastActivityAt, sixtyDaysAgo), + and(isNull(workspaces.lastActivityAt), lt(workspaces.createdAt, sixtyDaysAgo)), + ), ), ); @@ -106,11 +109,6 @@ export async function runTtlCleanup(db: Db, logger: Logger) { } // 4. Clean up expired sessions and verification codes - await db.delete( - (await import('../db/schema.js')).sessions, - ).where(lt((await import('../db/schema.js')).sessions.expiresAt, new Date())); - - await db.delete( - (await import('../db/schema.js')).emailVerifications, - ).where(lt((await import('../db/schema.js')).emailVerifications.expiresAt, new Date())); + 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 index 3582decf..812dd81d 100644 --- a/packages/server/src/engine/user.ts +++ b/packages/server/src/engine/user.ts @@ -42,7 +42,9 @@ async function verifyPassword(password: string, stored: string): Promise { - const res = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Resend API error (${res.status}): ${body}`); - } -} - -export async function sendVerificationEmail( - apiKey: string, - to: string, - code: string, -): Promise { - await sendEmail(apiKey, { - from: 'Relaycast ', - to, - subject: 'Verify your Relaycast email', - html: ` -
    -

    Verify your email

    -

    Your verification code is:

    -
    - ${code} -
    -

    This code expires in 15 minutes.

    -
    - `, - }); -} - -export async function sendExpirationWarning( - apiKey: string, - to: string, - workspaceName: string, - daysRemaining: number, -): Promise { - await sendEmail(apiKey, { - from: 'Relaycast ', - to, - subject: `Your workspace "${workspaceName}" will expire in ${daysRemaining} days`, - html: ` -
    -

    Workspace expiration warning

    -

    Your workspace ${workspaceName} has been inactive and will be deleted in ${daysRemaining} days.

    -

    To prevent deletion, either:

    - -

    Free workspaces are deleted after 60 days of inactivity.

    -
    - `, - }); -} diff --git a/packages/server/src/middleware/rateLimit.ts b/packages/server/src/middleware/rateLimit.ts index e9d6cad6..1ff2cd0f 100644 --- a/packages/server/src/middleware/rateLimit.ts +++ b/packages/server/src/middleware/rateLimit.ts @@ -5,6 +5,7 @@ import type { AppEnv } from '../env.js'; const RATE_LIMITS: Record = { free: 60, pro: 300, + enterprise: 1000, }; // Per-route rate limit multipliers (fraction of global limit) diff --git a/packages/server/src/routes/user.ts b/packages/server/src/routes/user.ts index b311aa9f..85675a24 100644 --- a/packages/server/src/routes/user.ts +++ b/packages/server/src/routes/user.ts @@ -1,13 +1,40 @@ 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'; -import { sendVerificationEmail } from '../lib/email.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(), @@ -29,7 +56,7 @@ const switchOrgSchema = z.object({ }); // POST /user/signup -userRoutes.post('/user/signup', async (c) => { +userRoutes.post('/user/signup', authRateLimit(5), async (c) => { try { const parsed = signupSchema.safeParse(await c.req.json()); if (!parsed.success) { @@ -39,14 +66,11 @@ userRoutes.post('/user/signup', async (c) => { const db = c.get('db'); const result = await userEngine.signup(db, parsed.data); - if (c.env.RESEND_API_KEY) { - await sendVerificationEmail(c.env.RESEND_API_KEY, result.email, result.verification_code); - } - return c.json({ ok: true, data: { user_id: result.user_id, + verification_code: result.verification_code, created_at: result.created_at, }, }, 201); @@ -57,7 +81,7 @@ userRoutes.post('/user/signup', async (c) => { }); // POST /user/verify -userRoutes.post('/user/verify', async (c) => { +userRoutes.post('/user/verify', authRateLimit(10), async (c) => { try { const parsed = verifyEmailSchema.safeParse(await c.req.json()); if (!parsed.success) { @@ -74,7 +98,7 @@ userRoutes.post('/user/verify', async (c) => { }); // POST /user/login -userRoutes.post('/user/login', async (c) => { +userRoutes.post('/user/login', authRateLimit(5), async (c) => { try { const parsed = loginSchema.safeParse(await c.req.json()); if (!parsed.success) { diff --git a/site/auth.css b/site/auth.css deleted file mode 100644 index 9e74a5fb..00000000 --- a/site/auth.css +++ /dev/null @@ -1,126 +0,0 @@ -/* ── Auth Pages ── */ -.auth-container { - display: flex; - justify-content: center; - align-items: center; - min-height: calc(100vh - 64px); - padding: 40px 20px; -} - -.auth-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 40px; - width: 100%; - max-width: 420px; -} - -.auth-card h1 { - font-size: 1.5rem; - margin-bottom: 8px; -} - -.auth-sub { - color: var(--text-muted); - margin-bottom: 24px; - font-size: 0.9rem; -} - -.auth-form { - display: flex; - flex-direction: column; - gap: 16px; -} - -.form-group { - display: flex; - flex-direction: column; - gap: 6px; -} - -.form-group label { - font-size: 0.85rem; - font-weight: 500; - color: var(--text-muted); -} - -.form-group input { - padding: 10px 14px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg); - color: var(--text); - font-size: 0.95rem; - font-family: var(--sans); - outline: none; - transition: border-color 0.2s; -} - -.form-group input:focus { - border-color: var(--accent); -} - -.form-group input::placeholder { - color: var(--text-muted); - opacity: 0.5; -} - -.code-input { - font-family: var(--mono) !important; - font-size: 1.5rem !important; - text-align: center; - letter-spacing: 8px; -} - -.auth-btn { - width: 100%; - justify-content: center; - margin-top: 8px; -} - -.auth-error { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - border-radius: var(--radius-sm); - padding: 10px 14px; - color: #f87171; - font-size: 0.85rem; -} - -.auth-success { - background: rgba(52, 211, 153, 0.1); - border: 1px solid rgba(52, 211, 153, 0.3); - border-radius: var(--radius-sm); - padding: 10px 14px; - color: var(--green); - font-size: 0.85rem; -} - -.auth-footer { - margin-top: 20px; - text-align: center; - font-size: 0.85rem; - color: var(--text-muted); -} - -/* ── Modal ── */ -.modal { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - justify-content: center; - align-items: center; - z-index: 100; -} - -.modal-content { - max-width: 440px; -} - -.modal-actions { - display: flex; - gap: 12px; - justify-content: flex-end; -} diff --git a/site/dashboard.css b/site/dashboard.css deleted file mode 100644 index 3ba02126..00000000 --- a/site/dashboard.css +++ /dev/null @@ -1,169 +0,0 @@ -/* ── Dashboard ── */ -.dash-container { - max-width: 800px; - margin: 0 auto; - padding: 40px 20px 80px; -} - -.dash-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 32px; -} - -.dash-header h1 { - font-size: 1.75rem; -} - -.plan-badge { - display: inline-flex; - align-items: center; - padding: 4px 12px; - border-radius: 20px; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.plan-free { - background: rgba(122, 122, 142, 0.15); - color: var(--text-muted); -} - -.plan-pro { - background: var(--accent-glow); - color: var(--accent-hover); -} - -.dash-section { - margin-bottom: 32px; -} - -.dash-section h2 { - font-size: 1.1rem; - margin-bottom: 12px; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} - -.section-header h2 { - margin-bottom: 0; -} - -.section-desc { - color: var(--text-muted); - font-size: 0.85rem; - margin-bottom: 12px; -} - -.dash-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; -} - -/* ── Billing ── */ -.billing-info { - display: flex; - gap: 32px; - margin-bottom: 16px; -} - -.billing-row { - display: flex; - flex-direction: column; - gap: 4px; -} - -.billing-label { - font-size: 0.8rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.billing-value { - font-size: 1rem; - font-weight: 500; -} - -.billing-actions { - display: flex; - gap: 12px; -} - -/* ── Workspaces ── */ -.workspaces-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.workspace-card { - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 14px 18px; -} - -.ws-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ws-name { - font-weight: 500; - font-family: var(--mono); - font-size: 0.9rem; -} - -.ws-meta { - font-size: 0.8rem; - color: var(--text-muted); -} - -.ws-activity { - font-size: 0.8rem; - color: var(--text-muted); -} - -.empty-state { - color: var(--text-muted); - font-size: 0.9rem; - padding: 20px; - text-align: center; -} - -/* ── Claim ── */ -.claim-form { - display: flex; - gap: 8px; -} - -.claim-form input { - flex: 1; - padding: 10px 14px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg); - color: var(--text); - font-family: var(--mono); - font-size: 0.85rem; - outline: none; -} - -.claim-form input:focus { - border-color: var(--accent); -} diff --git a/site/dashboard.html b/site/dashboard.html deleted file mode 100644 index adef1104..00000000 --- a/site/dashboard.html +++ /dev/null @@ -1,242 +0,0 @@ - - - - - - Dashboard — Relaycast - - - - - - - - - - - - - -
    -
    -

    Loading dashboard...

    -
    -
    - - - - - - - diff --git a/site/index.html b/site/index.html index 12bf52de..ff191905 100644 --- a/site/index.html +++ b/site/index.html @@ -25,8 +25,6 @@ OpenClaw Docs GitHub - Log In - Sign Up
    @@ -393,7 +391,7 @@

    Simple, predictable pricing

  • WebSocket streaming
  • Priority support
  • - Upgrade to Pro + Get Started
    Enterprise
    diff --git a/site/login.html b/site/login.html deleted file mode 100644 index 0fde5d3b..00000000 --- a/site/login.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - Log In — Relaycast - - - - - - - - - -
    -
    -

    Log in

    -

    Sign in to manage your organization.

    - -
    -
    - - -
    -
    - - -
    - - -
    - - -
    -
    - - - - diff --git a/site/signup.html b/site/signup.html deleted file mode 100644 index 42c8d9fd..00000000 --- a/site/signup.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - Sign Up — Relaycast - - - - - - - - - -
    -
    -

    Create your organization

    -

    Sign up to manage workspaces and unlock paid features.

    - -
    -
    - - -
    -
    - - -
    -
    - - -
    - - -
    - - -
    -
    - - - - diff --git a/site/verify.html b/site/verify.html deleted file mode 100644 index f985a71e..00000000 --- a/site/verify.html +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - Verify Email — Relaycast - - - - - - - - - -
    -
    -

    Check your email

    -

    We sent a 6-digit verification code to your email address.

    - -
    -
    - - -
    - - -
    - - -
    -
    - - - -