Skip to content

Add organizations, billing & workspace lifecycle#47

Open
willwashburn wants to merge 5 commits intomainfrom
orgs-n-billing
Open

Add organizations, billing & workspace lifecycle#47
willwashburn wants to merge 5 commits intomainfrom
orgs-n-billing

Conversation

@willwashburn
Copy link
Member

@willwashburn willwashburn commented Feb 21, 2026

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).


Open with Devin

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).
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

View 10 additional findings in Devin Review.

Open in Devin Review

site/login.html Outdated
btn.textContent = 'Logging in...';

try {
const res = await fetch(`${API}/orgs/login`, {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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/login
  • signup.html:65: POST ${API}/orgs → resolves to /v1/orgs
  • verify.html:64: POST ${API}/orgs/verify → resolves to /v1/orgs/verify
  • dashboard.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/signup
  • userRoutes.post('/user/login', ...)/v1/user/login
  • userRoutes.post('/user/verify', ...)/v1/user/verify
  • userRoutes.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).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

site/signup.html Outdated
Comment on lines +79 to +80
sessionStorage.setItem('rc_org_id', data.data.organization_id);
sessionStorage.setItem('rc_org_key', data.data.org_api_key);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
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);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

site/verify.html Outdated
Comment on lines +67 to +69
body: JSON.stringify({
organization_id: orgId,
code: form.code.value,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
body: JSON.stringify({
organization_id: orgId,
code: form.code.value,
user_id: sessionStorage.getItem('rc_user_id'),
code: form.code.value,
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Member

@khaliqgant khaliqgant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non blocking comments

"Bash(npx next build)",
"Bash(npx tsx:*)"
"Bash(npx tsx:*)",
"Bash(npm run build:*)"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should git ignore this and just commit a settings.json file

billing-plan.md Outdated
---

## Web UI (site/)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we just brand it all under relay

}

const body = await c.req.text();
const valid = await verifyStripeSignature(body, signature, webhookSecret);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +109 to +115
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()));
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +73
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),
),
);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
site/signup.html Outdated
</div>

<script>
const API = 'https://api.relaycast.dev/v1';
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const API = 'https://api.relaycast.dev/v1';
const API = (window.RC_API_BASE || window.location.origin) + '/v1';

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
site/signup.html Outdated
Comment on lines +65 to +72
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,
}),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +59
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>
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +100
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);
}
});
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signup, verify, and login endpoints lack rate limiting, making them vulnerable to brute force attacks and abuse. An attacker could:

  1. Spam signups to fill the database
  2. Brute force verification codes (6 digits = only 1M combinations)
  3. 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

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +27
params.set('success_url', 'https://relaycast.dev/dashboard/billing?success=true');
params.set('cancel_url', 'https://relaycast.dev/dashboard/billing?canceled=true');
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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');

Copilot uses AI. Check for mistakes.
site/verify.html Outdated
Comment on lines +45 to +49
const orgId = sessionStorage.getItem('rc_org_id');
const orgKey = sessionStorage.getItem('rc_org_key');

if (!orgId) {
window.location.href = '/signup.html';
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 12 additional findings in Devin Review.

Open in Devin Review

Comment on lines 65 to 66
return;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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).
Open in Devin Review

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.
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +158 to +166
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('');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
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('');
Open in Devin Review

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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
if (!expected || !secret || secret !== expected) {
if (!expected || !secret || expected.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(secret), Buffer.from(expected))) {
Open in Devin Review

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.
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment on lines +69 to +76
return c.json({
ok: true,
data: {
user_id: result.user_id,
verification_code: result.verification_code,
created_at: result.created_at,
},
}, 201);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. Call POST /v1/user/signup with any email (e.g., victim@example.com)
  2. Read the verification_code from the response
  3. Immediately call POST /v1/user/verify with 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.

Suggested change
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);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +181 to +182
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 } });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 undefined

The 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.

Suggested change
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 } });
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants