Add organizations, billing & workspace lifecycle#47
Add organizations, billing & workspace lifecycle#47willwashburn wants to merge 5 commits 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).
site/login.html
Outdated
| 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.
site/signup.html
Outdated
| 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.
site/verify.html
Outdated
| 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.
.claude/settings.local.json
Outdated
| "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
billing-plan.md
Outdated
| --- | ||
|
|
||
| ## 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
There was a problem hiding this comment.
I think we just brand it all under relay
| } | ||
|
|
||
| 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.
packages/server/src/engine/ttl.ts
Outdated
| 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
site/signup.html
Outdated
| </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 |
site/signup.html
Outdated
| 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).
packages/server/src/lib/email.ts
Outdated
| 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.
packages/server/src/routes/user.ts
Outdated
| 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'); |
site/verify.html
Outdated
| 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.
| return; | ||
| } |
There was a problem hiding this comment.
🔴 Rate limiting is silently skipped for all org-authenticated routes
The rateLimit middleware at packages/server/src/middleware/rateLimit.ts:62-66 checks c.get('workspace') and returns early (skips rate limiting) if no workspace is set. However, all org-level routes (/org/*, /org/billing/*, /org/members/*) use requireOrgAuth which sets c.var.organization but never sets c.var.workspace.
Affected Routes
Every route in packages/server/src/routes/organization.ts and packages/server/src/routes/billing.ts applies rateLimit middleware after requireOrgAuth, but since workspace is never set for these requests, the rate limiter always bypasses:
// rateLimit.ts:62-66
const workspace = c.get('workspace');
if (!workspace) {
await next(); // ← always hits this path for org routes
return;
}This means org-level endpoints like billing checkout, workspace creation, member invite, etc. have zero rate limiting, making them vulnerable to abuse.
(Refers to lines 62-66)
Prompt for agents
In packages/server/src/middleware/rateLimit.ts, the rate limiter needs to also work when only an organization (not a workspace) is available. Around lines 61-70, modify the logic to use org.id as the rate limit key when workspace is not set. For example:
const workspace = c.get('workspace');
const org = c.get('organization');
const entityId = workspace?.id || org?.id;
if (!entityId) { await next(); return; }
const plan = org?.plan || workspace?.plan || 'free';
const globalLimit = RATE_LIMITS[plan] || RATE_LIMITS.free;
Then use entityId instead of workspace.id throughout the rest of the function (lines 83, 129).
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
site/dashboard.html
Outdated
| list.innerHTML = wsRes.data.map(ws => ` | ||
| <div class="workspace-card"> | ||
| <div class="ws-info"> | ||
| <span class="ws-name">${ws.name}</span> | ||
| <span class="ws-meta">Created ${new Date(ws.created_at).toLocaleDateString()}</span> | ||
| </div> | ||
| <div class="ws-activity">${ws.last_activity_at ? 'Active ' + new Date(ws.last_activity_at).toLocaleDateString() : 'No activity'}</div> | ||
| </div> | ||
| `).join(''); |
There was a problem hiding this comment.
🔴 Stored XSS via workspace name injected into innerHTML without escaping
The dashboard's loadWorkspaces() function interpolates ws.name directly into an HTML string assigned to innerHTML, without any HTML escaping. A workspace with a malicious name like <img src=x onerror=alert(1)> would execute arbitrary JavaScript in any user viewing the dashboard.
Root Cause
At site/dashboard.html:161:
<span class="ws-name">${ws.name}</span>The ws.name value comes from the API response and is user-controlled (set when creating a workspace). Since it is interpolated into a template literal that is assigned to list.innerHTML on line 158, any HTML/JS in the name is rendered and executed.
Impact: Any org member who creates a workspace with a malicious name can execute arbitrary JavaScript in the browser of every other member who views the dashboard, enabling session theft, data exfiltration, etc.
| list.innerHTML = wsRes.data.map(ws => ` | |
| <div class="workspace-card"> | |
| <div class="ws-info"> | |
| <span class="ws-name">${ws.name}</span> | |
| <span class="ws-meta">Created ${new Date(ws.created_at).toLocaleDateString()}</span> | |
| </div> | |
| <div class="ws-activity">${ws.last_activity_at ? 'Active ' + new Date(ws.last_activity_at).toLocaleDateString() : 'No activity'}</div> | |
| </div> | |
| `).join(''); | |
| function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } | |
| list.innerHTML = wsRes.data.map(ws => ` | |
| <div class="workspace-card"> | |
| <div class="ws-info"> | |
| <span class="ws-name">${escapeHtml(ws.name)}</span> | |
| <span class="ws-meta">Created ${escapeHtml(new Date(ws.created_at).toLocaleDateString())}</span> | |
| </div> | |
| <div class="ws-activity">${ws.last_activity_at ? 'Active ' + escapeHtml(new Date(ws.last_activity_at).toLocaleDateString()) : 'No activity'}</div> | |
| </div> | |
| `).join(''); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| const secret = c.req.header('X-Admin-Secret'); | ||
| const expected = c.env.ADMIN_SECRET; | ||
|
|
||
| if (!expected || !secret || secret !== expected) { |
There was a problem hiding this comment.
🟡 Admin secret comparison uses non-constant-time string equality, enabling timing attacks
The requireAdminSecret middleware compares the provided secret with the expected value using secret !== expected, which is a standard JavaScript string comparison that short-circuits on the first differing character.
Security Impact
At packages/server/src/middleware/auth.ts:334, the comparison secret !== expected leaks information about the secret through timing differences. An attacker can measure response times to determine the correct secret one character at a time. While network jitter makes this harder to exploit remotely, it is a well-known security anti-pattern for secret comparison. The fix is to use crypto.timingSafeEqual() or equivalent.
Impact: The admin secret (which controls plan changes via the admin endpoint) could potentially be extracted through a timing side-channel attack.
| if (!expected || !secret || secret !== expected) { | |
| if (!expected || !secret || expected.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(secret), Buffer.from(expected))) { |
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
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.
| return c.json({ | ||
| ok: true, | ||
| data: { | ||
| user_id: result.user_id, | ||
| verification_code: result.verification_code, | ||
| created_at: result.created_at, | ||
| }, | ||
| }, 201); |
There was a problem hiding this comment.
🔴 Signup endpoint returns email verification code in the API response, defeating email verification
The POST /user/signup endpoint returns the verification_code directly in the JSON response body, allowing any caller to verify an email address they don't own.
Security Impact
In packages/server/src/engine/user.ts:96-101, the signup function returns the verification code as part of its result:
return {
user_id: userId,
verification_code: code, // the 6-digit secret
...
};The route handler at packages/server/src/routes/user.ts:69-76 then forwards this directly to the caller:
return c.json({
ok: true,
data: {
user_id: result.user_id,
verification_code: result.verification_code, // leaked!
...
},
}, 201);An attacker can:
- Call
POST /v1/user/signupwith any email (e.g.,victim@example.com) - Read the
verification_codefrom the response - Immediately call
POST /v1/user/verifywith the code to verify the email
This completely bypasses the email verification security measure. The verification code should only be delivered via the email channel (e.g., using Resend), never in the API response.
| return c.json({ | |
| ok: true, | |
| data: { | |
| user_id: result.user_id, | |
| verification_code: result.verification_code, | |
| created_at: result.created_at, | |
| }, | |
| }, 201); | |
| return c.json({ | |
| ok: true, | |
| data: { | |
| user_id: result.user_id, | |
| created_at: result.created_at, | |
| }, | |
| }, 201); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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 } }); |
There was a problem hiding this comment.
🟡 switchOrg returns potentially undefined org, causing null dereference in route handler
The switchOrg function can return undefined, and the route handler dereferences it without a null check, which would cause a 500 error.
Detailed Explanation
In packages/server/src/engine/user.ts:253-258, switchOrg queries for the org and returns the raw result:
const [org] = await db
.select()
.from(organizations)
.where(eq(organizations.id, orgId));
return org; // can be undefinedThe route handler at packages/server/src/routes/user.ts:181-182 then accesses properties without checking:
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 } });If the organization was deleted between the membership check and the org lookup (a race condition), org would be undefined and accessing org.id would throw a TypeError, resulting in an opaque 500 error instead of a meaningful error response.
| 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 } }); | |
| const org = await userEngine.switchOrg(db, match[1], user.id, parsed.data.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: { id: org.id, name: org.name, plan: org.plan } }); |
Was this helpful? React with 👍 or 👎 to provide feedback.
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).