Add organizations, billing & workspace lifecycle#47
Add organizations, billing & workspace lifecycle#47willwashburn wants to merge 1 commit intomainfrom
Conversation
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).
| btn.textContent = 'Logging in...'; | ||
|
|
||
| try { | ||
| const res = await fetch(`${API}/orgs/login`, { |
There was a problem hiding this comment.
🔴 Frontend login/signup/verify/logout pages call non-existent API routes
All frontend auth pages call /v1/orgs/* paths but the server defines routes under /v1/user/*, causing every auth flow to 404.
Detailed route mismatch breakdown
The frontend pages set const API = 'https://api.relaycast.dev/v1' and then call:
login.html:67:POST ${API}/orgs/login→ resolves to/v1/orgs/loginsignup.html:65:POST ${API}/orgs→ resolves to/v1/orgsverify.html:64:POST ${API}/orgs/verify→ resolves to/v1/orgs/verifydashboard.html:235:POST /orgs/logout→ resolves to/v1/orgs/logout
But the server routes in packages/server/src/routes/user.ts are:
userRoutes.post('/user/signup', ...)→/v1/user/signupuserRoutes.post('/user/login', ...)→/v1/user/loginuserRoutes.post('/user/verify', ...)→/v1/user/verifyuserRoutes.post('/user/logout', ...)→/v1/user/logout
None of the frontend paths match any server route. The POST /v1/orgs route in organization.ts:34 does exist but it's the "create org" endpoint (requires requireOrgAuth), not signup.
Impact: The entire signup, login, email verification, and logout flows are completely broken — every request will return a 404.
Prompt for agents
The frontend auth pages call API paths that don't match the server routes. Either update the frontend pages to use the correct server paths, or update the server routes to match the frontend expectations. The mismatches are:
1. site/login.html line 67: calls POST /v1/orgs/login but server route is POST /v1/user/login
2. site/signup.html line 65: calls POST /v1/orgs but server route is POST /v1/user/signup
3. site/verify.html line 64: calls POST /v1/orgs/verify but server route is POST /v1/user/verify
4. site/dashboard.html line 235: calls POST /v1/orgs/logout but server route is POST /v1/user/logout
The simplest fix is to update the frontend pages to use /user/signup, /user/login, /user/verify, and /user/logout respectively. However, note that signup.html also has a payload mismatch (see BUG-0002).
Was this helpful? React with 👍 or 👎 to provide feedback.
| sessionStorage.setItem('rc_org_id', data.data.organization_id); | ||
| sessionStorage.setItem('rc_org_key', data.data.org_api_key); |
There was a problem hiding this comment.
🔴 signup.html expects org-centric response fields but server returns user-centric fields
The signup page expects data.organization_id and data.org_api_key in the response, but the server's signup endpoint returns data.user_id and data.created_at.
Payload mismatch details
In site/signup.html:79-80, after a successful signup the frontend stores:
sessionStorage.setItem('rc_org_id', data.data.organization_id);
sessionStorage.setItem('rc_org_key', data.data.org_api_key);But the server's signup function in packages/server/src/engine/user.ts:94-99 returns:
return {
user_id: userId,
verification_code: code,
email: input.email,
created_at: user.createdAt.toISOString(),
};And the route handler in packages/server/src/routes/user.ts:46-52 returns:
return c.json({ ok: true, data: { user_id: result.user_id, created_at: result.created_at } }, 201);So data.data.organization_id and data.data.org_api_key will both be undefined. The verify page then sends { organization_id: undefined, code } but the server expects { user_id, code }.
Impact: Even if the route mismatch (BUG-0001) were fixed, the signup → verify flow would still fail because the frontend stores the wrong fields and the verify page sends the wrong payload.
| sessionStorage.setItem('rc_org_id', data.data.organization_id); | |
| sessionStorage.setItem('rc_org_key', data.data.org_api_key); | |
| sessionStorage.setItem('rc_user_id', data.data.user_id); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| body: JSON.stringify({ | ||
| organization_id: orgId, | ||
| code: form.code.value, |
There was a problem hiding this comment.
🔴 verify.html sends organization_id instead of user_id to verification endpoint
The verify page sends { organization_id, code } but the server's verify endpoint expects { user_id, code }.
Payload mismatch details
In site/verify.html:67-69, the verify form sends:
body: JSON.stringify({
organization_id: orgId, // from sessionStorage 'rc_org_id'
code: form.code.value,
})But the server's verifyEmailSchema in packages/server/src/routes/user.ts:17-19 expects:
const verifyEmailSchema = z.object({
user_id: z.string(),
code: z.string().length(6),
});The organization_id field is not recognized by the schema, and user_id is missing, so zod validation will fail with a 400 error.
Impact: Email verification is completely broken — users cannot verify their email after signup.
| body: JSON.stringify({ | |
| organization_id: orgId, | |
| code: form.code.value, | |
| user_id: sessionStorage.getItem('rc_user_id'), | |
| code: form.code.value, |
Was this helpful? React with 👍 or 👎 to provide feedback.
| } | ||
|
|
||
| // Create session | ||
| const sessionId = generateId(); |
There was a problem hiding this comment.
🔴 Session IDs use predictable snowflake IDs instead of cryptographic random tokens
The login function uses generateId() (snowflake) for session tokens, making them predictable and guessable, despite a generateSessionToken() function being defined but never called.
Security impact details
At packages/server/src/engine/user.ts:184, the session ID is generated with:
const sessionId = generateId();Snowflake IDs (packages/server/src/engine/snowflake.ts) are composed of a timestamp, a 10-bit worker ID, and a 12-bit sequence counter. They are sequential and predictable — an attacker who knows the approximate creation time can enumerate valid session IDs.
Meanwhile, at packages/server/src/engine/user.ts:52-54, there's an unused function:
function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex');
}This generates a 256-bit cryptographically random token, which is the correct approach for session tokens. The session token is set as a cookie (relaycast_session) and used directly as the session lookup key in the database.
Impact: Session hijacking vulnerability — an attacker can predict or brute-force session IDs since snowflake IDs have very low entropy (~22 bits of timestamp precision per second + 10 bits worker + 12 bits sequence).
| const sessionId = generateId(); | |
| const sessionId = generateSessionToken(); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const RATE_LIMITS: Record<string, number> = { | ||
| free: 60, | ||
| pro: 300, | ||
| enterprise: 1000, | ||
| }; |
There was a problem hiding this comment.
🔴 Enterprise plan rate limit removed, causing enterprise workspaces to get free-tier rate limits
The enterprise entry was removed from RATE_LIMITS in rateLimit.ts, causing enterprise-plan workspaces to fall back to the free-tier rate limit of 60 requests/minute instead of 1000.
Regression details
The old code had:
const RATE_LIMITS: Record<string, number> = {
free: 60,
pro: 300,
enterprise: 1000,
};The new code at packages/server/src/middleware/rateLimit.ts:5-8 is:
const RATE_LIMITS: Record<string, number> = {
free: 60,
pro: 300,
};At line 71, the fallback is RATE_LIMITS[plan] || RATE_LIMITS.free, so enterprise plans get RATE_LIMITS['enterprise'] which is undefined, falling back to RATE_LIMITS.free = 60 rpm.
Note that PLAN_LIMITS in planLimits.ts:7 still has enterprise defined, and the pricing page still lists an enterprise tier. The enterprise plan is still a valid plan value.
Impact: Any existing enterprise-plan workspaces would be rate-limited to 60 rpm (free tier) instead of 1000 rpm, a ~17x reduction.
| const RATE_LIMITS: Record<string, number> = { | |
| free: 60, | |
| pro: 300, | |
| enterprise: 1000, | |
| }; | |
| const RATE_LIMITS: Record<string, number> = { | |
| free: 60, | |
| pro: 300, | |
| enterprise: 1000, | |
| }; |
Was this helpful? React with 👍 or 👎 to provide feedback.
| and( | ||
| eq(workspaces.organizationId, org.id), | ||
| isNull(workspaces.deletedAt), | ||
| lt(workspaces.lastActivityAt, sixtyDaysAgo), |
There was a problem hiding this comment.
🟡 TTL cleanup skips workspaces with NULL lastActivityAt in soft-delete query
The TTL cleanup's soft-delete query uses lt(workspaces.lastActivityAt, sixtyDaysAgo) which excludes workspaces where lastActivityAt is NULL, meaning pre-existing workspaces that were never updated will never be cleaned up.
Root Cause
At packages/server/src/engine/ttl.ts:71, the query filters:
lt(workspaces.lastActivityAt, sixtyDaysAgo)In SQL, NULL < date evaluates to NULL (falsy), so workspaces with lastActivityAt = NULL are excluded from the result set. This includes:
- All workspaces that existed before this migration (the migration at
0002_organizations.sql:58adds the column but doesn't backfill it) - Any workspaces created through code paths that don't set
lastActivityAt
The createWorkspace function in workspace.ts:39 does set lastActivityAt: new Date() for new workspaces, but all pre-existing workspaces from before this migration will have NULL.
Impact: Pre-existing free-tier workspaces with no activity will never be soft-deleted by the TTL cleanup, accumulating indefinitely.
Prompt for agents
In packages/server/src/engine/ttl.ts at line 71, the condition `lt(workspaces.lastActivityAt, sixtyDaysAgo)` excludes workspaces where lastActivityAt is NULL (pre-migration workspaces). The fix should also include workspaces with NULL lastActivityAt that were created more than 60 days ago. Change the where clause to use an `or()` condition that also matches `and(isNull(workspaces.lastActivityAt), lt(workspaces.createdAt, sixtyDaysAgo))`. You'll need to import `or` from drizzle-orm.
Was this helpful? React with 👍 or 👎 to provide feedback.
khaliqgant
left a comment
There was a problem hiding this comment.
Non blocking comments
| "Bash(npx next build)", | ||
| "Bash(npx tsx:*)" | ||
| "Bash(npx tsx:*)", | ||
| "Bash(npm run build:*)" |
There was a problem hiding this comment.
I think we should git ignore this and just commit a settings.json file
| --- | ||
|
|
||
| ## Web UI (site/) | ||
|
|
There was a problem hiding this comment.
I still haven't worked out how we want to do billing in combination with relay / relay cloud. Possible to only use relaycast in which case it is great to have this, but unsure how in the entire system this ties in
| } | ||
|
|
||
| const body = await c.req.text(); | ||
| const valid = await verifyStripeSignature(body, signature, webhookSecret); |
There was a problem hiding this comment.
Feel like AI never gets this stuff right the first time. I went through this with prpm so have this skill. Might be useful here: https://prpm.dev/packages/prpm/integrating-stripe-webhooks
There was a problem hiding this comment.
Pull request overview
This pull request introduces a comprehensive organizations and billing system to RelayCast, adding user accounts, multi-tenant organization management, Stripe payment integration, and workspace lifecycle/TTL management. However, the implementation contains critical bugs that prevent the entire authentication and signup flow from working.
Changes:
- Adds database schema for users, organizations, memberships, sessions, and email verifications with migration to backfill shadow orgs for existing workspaces
- Implements user signup/login/verification flows and organization CRUD/claiming/billing APIs
- Adds TTL cleanup cron job for message trimming and inactive workspace deletion
- Creates frontend pages (signup, login, verify, dashboard) and Stripe billing integration
- Updates auth middleware to support org API keys, session cookies, and soft-deleted workspaces
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 23 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/server/src/db/schema.ts | Adds users, organizations, orgMemberships, sessions, emailVerifications tables; modifies workspaces with organizationId, lastActivityAt, deletedAt |
| packages/server/src/db/migrations/0002_organizations.sql | Creates new tables and backfills shadow orgs for existing workspaces |
| packages/server/src/engine/user.ts | User signup, email verification, login, session management functions |
| packages/server/src/engine/organization.ts | Organization CRUD, workspace claiming, membership management |
| packages/server/src/engine/workspace.ts | Updates workspace creation to link shadow orgs, adds touchLastActivity (unused) |
| packages/server/src/engine/ttl.ts | Daily cron cleanup for messages, workspace soft/hard delete, session expiry |
| packages/server/src/routes/user.ts | User signup/verify/login/logout API endpoints |
| packages/server/src/routes/organization.ts | Organization and workspace management endpoints |
| packages/server/src/routes/billing.ts | Stripe checkout and billing portal endpoints |
| packages/server/src/routes/stripeWebhook.ts | Stripe webhook handler for subscription events |
| packages/server/src/routes/admin.ts | Admin endpoint for external billing management |
| packages/server/src/middleware/auth.ts | Adds requireOrgAuth, requireUserAuth, requireAdminSecret middleware; soft-delete checks |
| packages/server/src/middleware/rateLimit.ts | Updates to read plan from organization |
| packages/server/src/middleware/planLimits.ts | Updates to read plan from organization |
| packages/server/src/lib/email.ts | Resend email helpers for verification and expiration warnings |
| packages/server/src/env.ts | Adds organization, user types to env; new env bindings |
| packages/server/src/worker.ts | Adds cron handler, routes new endpoints |
| packages/types/src/organization.ts | TypeScript types for users, orgs, memberships, billing |
| packages/types/src/workspace.ts | Adds organization_id to workspace schema, removes enterprise plan |
| site/*.html | New signup/login/verify/dashboard pages with hardcoded API URLs |
| site/*.css | New auth and dashboard styling |
| wrangler.toml | Adds daily cron trigger and new secret bindings |
| billing-plan.md | Design document (doesn't match implementation) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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())); |
There was a problem hiding this comment.
The sessions and emailVerifications tables need to be imported at the top of the file alongside the other schema imports. Currently they're dynamically imported inline which is inefficient. Add them to the import statement on line 3:
import { organizations, workspaces, messages, sessions, emailVerifications } from '../db/schema.js';Then use them directly on lines 109-115 without the dynamic imports.
| 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), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
The query filters workspaces where last_activity_at < sixtyDaysAgo, but lastActivityAt is nullable in the schema. Workspaces with NULL lastActivityAt will be excluded from this query, even though they should probably be considered inactive and eligible for deletion.
Consider treating NULL as equivalent to the workspace's createdAt date, or add an explicit check: OR last_activity_at IS NULL AND created_at < sixtyDaysAgo
| </div> | ||
|
|
||
| <script> | ||
| const API = 'https://api.relaycast.dev/v1'; |
There was a problem hiding this comment.
The API URL is hardcoded to https://api.relaycast.dev/v1 which will break local development and staging testing. Consider detecting the environment or using a relative URL if the static site is served from the same domain as the API (e.g., const API = window.location.origin + '/v1'), or making it configurable via a build-time replacement.
| const API = 'https://api.relaycast.dev/v1'; | |
| const API = (window.RC_API_BASE || window.location.origin) + '/v1'; |
| 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 |
There was a problem hiding this comment.
The workspace schema still has a plan field (line 112) marked as deprecated with a comment "deprecated: read from org". However, the migration doesn't drop this column. This creates technical debt and potential confusion.
Either remove the field entirely from the schema (and update all code that references it), or document clearly that it's kept for backwards compatibility but should not be written to. Currently it's still being set with a default value which is wasteful.
| plan: text('plan').notNull().default('free'), // deprecated: read from org | |
| plan: text('plan').notNull(), // deprecated: read from org; kept for backwards compatibility, do not write to explicitly |
| const res = await fetch(`${API}/orgs`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| name: form.name.value, | ||
| email: form.email.value, | ||
| password: form.password.value, | ||
| }), |
There was a problem hiding this comment.
The signup form calls POST /orgs with email and password, but this endpoint only accepts {name} and requires authentication via requireOrgAuth middleware. This creates a catch-22 where users cannot sign up.
The correct endpoint should be POST /user/signup which accepts {name, email, password} and doesn't require prior authentication. Update the fetch call to use /user/signup instead of /orgs.
| 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 |
There was a problem hiding this comment.
The Stripe price ID is hardcoded as 'price_relaycast_pro'. This placeholder needs to be replaced with the actual Stripe price ID from your Stripe dashboard, or better yet, made configurable via an environment variable to support different pricing in different environments (staging vs production).
| subject: `Your workspace "${workspaceName}" will expire in ${daysRemaining} days`, | ||
| html: ` | ||
| <div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;"> | ||
| <h2>Workspace expiration warning</h2> | ||
| <p>Your workspace <strong>${workspaceName}</strong> has been inactive and will be deleted in <strong>${daysRemaining} days</strong>.</p> |
There was a problem hiding this comment.
The email HTML templates directly inject user input (code, workspaceName, daysRemaining) without HTML escaping. While code is controlled (6 digits) and daysRemaining is a number, workspaceName comes from user input and could contain HTML/JavaScript that would render in the email client.
Apply HTML escaping to user-controlled variables to prevent XSS in email clients. While most modern email clients block scripts, HTML injection could still cause display issues or phishing attacks.
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
The signup, verify, and login endpoints lack rate limiting, making them vulnerable to brute force attacks and abuse. An attacker could:
- Spam signups to fill the database
- Brute force verification codes (6 digits = only 1M combinations)
- Attempt credential stuffing attacks on login
Add rate limiting to these endpoints. For example:
- Signup: 3-5 attempts per IP per hour
- Verify: 10 attempts per user_id per 15 minutes (or lock after 3 failed attempts)
- Login: 5 attempts per email per 15 minutes
| params.set('success_url', 'https://relaycast.dev/dashboard/billing?success=true'); | ||
| params.set('cancel_url', 'https://relaycast.dev/dashboard/billing?canceled=true'); |
There was a problem hiding this comment.
The Stripe redirect URLs reference /dashboard/billing but this page doesn't exist in the site/ directory. Users who complete Stripe checkout or access the billing portal will be redirected to a 404 page.
Either create a dashboard/billing.html page or update these URLs to redirect to /dashboard.html instead.
| params.set('success_url', 'https://relaycast.dev/dashboard/billing?success=true'); | |
| params.set('cancel_url', 'https://relaycast.dev/dashboard/billing?canceled=true'); | |
| params.set('success_url', 'https://relaycast.dev/dashboard.html?success=true'); | |
| params.set('cancel_url', 'https://relaycast.dev/dashboard.html?canceled=true'); |
| const orgId = sessionStorage.getItem('rc_org_id'); | ||
| const orgKey = sessionStorage.getItem('rc_org_key'); | ||
|
|
||
| if (!orgId) { | ||
| window.location.href = '/signup.html'; |
There was a problem hiding this comment.
The code references sessionStorage.getItem('rc_org_id') but this should be rc_user_id to match the corrected signup flow. The verify page checks if a user (not org) ID exists before allowing verification.
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).