Skip to content

Comments

Add organizations, billing & workspace lifecycle#47

Open
willwashburn wants to merge 1 commit intomainfrom
orgs-n-billing
Open

Add organizations, billing & workspace lifecycle#47
willwashburn wants to merge 1 commit 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

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.

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.

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.

}

// Create session
const sessionId = generateId();

Choose a reason for hiding this comment

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

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

Suggested change
const sessionId = generateId();
const sessionId = generateSessionToken();
Open in Devin Review

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

Comment on lines 5 to 8
const RATE_LIMITS: Record<string, number> = {
free: 60,
pro: 300,
enterprise: 1000,
};

Choose a reason for hiding this comment

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

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

Suggested change
const RATE_LIMITS: Record<string, number> = {
free: 60,
pro: 300,
enterprise: 1000,
};
const RATE_LIMITS: Record<string, number> = {
free: 60,
pro: 300,
enterprise: 1000,
};
Open in Devin Review

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

and(
eq(workspaces.organizationId, org.id),
isNull(workspaces.deletedAt),
lt(workspaces.lastActivityAt, sixtyDaysAgo),

Choose a reason for hiding this comment

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

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

  1. All workspaces that existed before this migration (the migration at 0002_organizations.sql:58 adds the column but doesn't backfill it)
  2. 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.
Open in Devin Review

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

Copy link
Contributor

@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
Contributor

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

---

## Web UI (site/)

Copy link
Contributor

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

}

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

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

2 participants