From 65a7db26f00b6f680606bd380151fd71743d7e0c Mon Sep 17 00:00:00 2001
From: Matteo
Date: Sat, 3 Oct 2026 15:10:34 +0200
Subject: [PATCH] Card trial for sign-ups from Claude, with a guaranteed way
back to approve
A new workspace admin who signs up from "Connect" in Claude is offered the
card trial once, after verifying their email and before approving the
connection. The pending authorization (kept 30 minutes by the backend) is
remembered across the detour, so every way out ends on the consent page:
- "Continue without payment details" goes straight back;
- a cancelled checkout returns to /start-trial (Stripe's cancel URL), where
skipping goes back;
- a completed checkout returns to /settings/license/activate, which
activates the licence and goes back instead of opening settings.
Members, workspaces without a running trial and an expired way back skip
the offer. Through MCP, billing is mentioned only when the plan's connector
limit stops an install: an admin on the free trial gets the card-trial page,
other admins the licence page, members who to ask.
---
.../adapters/connector-setup.service.spec.ts | 28 ++++
.../src/adapters/connector-setup.service.ts | 30 +++-
packages/frontend/src/app/login/page.tsx | 31 +++-
.../app/settings/license/activate/page.tsx | 10 +-
.../frontend/src/app/start-trial/page.tsx | 36 ++++-
packages/frontend/src/lib/card-trial.ts | 62 ++++++++
.../e2e/card-trial-claude-signup.spec.ts | 149 ++++++++++++++++++
.../e2e/neutral-signup-and-billing.spec.ts | 5 +-
8 files changed, 338 insertions(+), 13 deletions(-)
create mode 100644 packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts
diff --git a/packages/backend/src/adapters/connector-setup.service.spec.ts b/packages/backend/src/adapters/connector-setup.service.spec.ts
index 757e688e..5b5fd892 100644
--- a/packages/backend/src/adapters/connector-setup.service.spec.ts
+++ b/packages/backend/src/adapters/connector-setup.service.spec.ts
@@ -145,6 +145,34 @@ describe('ConnectorSetupService — install', () => {
expect(out.isError).toBe(true);
expect(adapters.importAdapter).not.toHaveBeenCalled();
});
+
+ it('at the limit, offers an admin on the free trial the card trial', async () => {
+ const { service, ctx, licenseGuard } = build({ role: 'ADMIN' });
+ licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Trial limit reached (2 connectors).'));
+ licenseGuard.getUsage.mockResolvedValueOnce({ plan: 'trial', connectors: { current: 2, max: 2 } });
+ const out: any = await service.install(ctx, { adapter: 'openplz' });
+ expect(out.body).toMatchObject({
+ error: 'Trial limit reached (2 connectors).',
+ upgradeUrl: 'https://cloud.example.com/start-trial',
+ });
+ expect(out.body.whatTheUserCanDo).toMatch(/nothing is charged before the trial ends/);
+ });
+
+ it('at the limit, sends an admin on a paid plan to the licence page', async () => {
+ const { service, ctx, licenseGuard } = build({ role: 'ADMIN' });
+ licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Connector limit reached.'));
+ licenseGuard.getUsage.mockResolvedValueOnce({ plan: 'cloud_starter', connectors: { current: 5, max: 5 } });
+ const out: any = await service.install(ctx, { adapter: 'openplz' });
+ expect(out.body.upgradeUrl).toBe('https://cloud.example.com/settings/license');
+ });
+
+ it('at the limit, tells an editor who can lift it, without a billing link', async () => {
+ const { service, ctx, licenseGuard } = build({ role: 'EDITOR' });
+ licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Trial limit reached (2 connectors).'));
+ const out: any = await service.install(ctx, { adapter: 'openplz' });
+ expect(out.body.upgradeUrl).toBeUndefined();
+ expect(out.body.whatTheUserCanDo).toMatch(/administrator/);
+ });
});
describe('ConnectorSetupService — links', () => {
diff --git a/packages/backend/src/adapters/connector-setup.service.ts b/packages/backend/src/adapters/connector-setup.service.ts
index b4a70f8b..79e985a6 100644
--- a/packages/backend/src/adapters/connector-setup.service.ts
+++ b/packages/backend/src/adapters/connector-setup.service.ts
@@ -171,7 +171,7 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
try {
await this.licenseGuard.checkCanCreateConnector(ctx.userId, ctx.organizationId);
} catch (err: any) {
- return { isError: true, body: { error: String(err?.message ?? err), dashboard: `${ctx.dashboardBase}/settings/license` } };
+ return { isError: true, body: await this.planLimitAnswer(ctx, String(err?.message ?? err)) };
}
let imported: Awaited>;
@@ -227,6 +227,34 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit
};
}
+ /**
+ * The plan's connector limit stopped an install. Billing is mentioned here
+ * and only here: the answer is the reason the request failed, with the one
+ * page that lifts the limit. An admin on the free trial gets the card-trial
+ * offer (nothing charged before the trial ends); other admins the licence
+ * page; members are told who can do it.
+ */
+ private async planLimitAnswer(ctx: SetupContext, reason: string): Promise> {
+ const member = await this.prisma.organizationMember
+ .findFirst({
+ where: { userId: ctx.userId, organizationId: ctx.organizationId, deactivatedAt: null },
+ select: { role: true },
+ })
+ .catch(() => null);
+ if (member?.role !== 'ADMIN') {
+ return { error: reason, whatTheUserCanDo: 'Ask a workspace administrator to upgrade the plan, or remove a connector they no longer need.' };
+ }
+ const usage = await this.licenseGuard.getUsage(ctx.userId, ctx.organizationId).catch(() => null);
+ const onTrial = usage?.plan === 'trial';
+ return {
+ error: reason,
+ whatTheUserCanDo: onTrial
+ ? 'Add a card to continue the trial on the full plan (nothing is charged before the trial ends), or remove a connector.'
+ : 'Choose a plan with more connectors, or remove a connector.',
+ upgradeUrl: `${ctx.dashboardBase}${onTrial ? '/start-trial' : '/settings/license'}`,
+ };
+ }
+
// ── Status ────────────────────────────────────────────────────────────
async status(ctx: SetupContext): Promise {
diff --git a/packages/frontend/src/app/login/page.tsx b/packages/frontend/src/app/login/page.tsx
index ed6ec476..34b12601 100644
--- a/packages/frontend/src/app/login/page.tsx
+++ b/packages/frontend/src/app/login/page.tsx
@@ -19,6 +19,7 @@ import {
cardTrialEligible,
parsePlanIntent,
readCardTrialPrompt,
+ saveAuthorizationReturn,
savePlanIntent,
parsePromoCode,
savePromoCode,
@@ -177,6 +178,31 @@ function LoginForm() {
return true;
};
+ /**
+ * Cloud only: someone who signed up from "Connect" in an AI client (the
+ * redirect is the pending authorization) is offered the card trial before
+ * approving, once. Every way out of /start-trial (skip, a cancelled or a
+ * completed checkout) returns to this authorization, which the backend
+ * keeps for 30 minutes. Resolves true when it has navigated.
+ */
+ const offerCardTrialBeforeAuthorization = async (
+ u: { id?: string; role?: string } | null | undefined,
+ token: string,
+ ): Promise => {
+ if (!isCloudMode || !returningToAuthorization || !u?.id || u.role !== 'ADMIN') return false;
+ if (readCardTrialPrompt(u.id) !== null) return false;
+ try {
+ const lic = await license.getStatus(token);
+ if (!cardTrialEligible({ isCloud: true, role: u.role, license: lic })) return false;
+ } catch {
+ return false;
+ }
+ writeCardTrialPrompt(u.id, 'shown');
+ saveAuthorizationReturn(redirectTo);
+ router.push('/start-trial');
+ return true;
+ };
+
// Surface a failure the SSO callback redirected back with.
useEffect(() => {
if (errorParam) setError(errorParam);
@@ -305,6 +331,7 @@ function LoginForm() {
login(result.accessToken, result.user);
if (isCloudMode && needsLicenseSetup && returningToAuthorization) {
await license.activateTrial(result.accessToken).catch(() => undefined);
+ if (await offerCardTrialBeforeAuthorization(result.user, result.accessToken)) return;
goTo(redirectTo);
} else if (isCloudMode && needsLicenseSetup) {
// Cloud mode: auto-activate trial for verified users
@@ -352,7 +379,9 @@ function LoginForm() {
if (isCloudMode && returningToAuthorization) {
// Verification already created the trial; the user is in the middle
- // of connecting an AI client, so take them straight back to approve.
+ // of connecting an AI client: offer the card trial once, then back
+ // to approve.
+ if (await offerCardTrialBeforeAuthorization(verifiedUser, authToken)) return;
goTo(redirectTo);
} else if (isCloudMode) {
// Cloud mode: auto-activate trial
diff --git a/packages/frontend/src/app/settings/license/activate/page.tsx b/packages/frontend/src/app/settings/license/activate/page.tsx
index 6f616eee..70f4a11a 100644
--- a/packages/frontend/src/app/settings/license/activate/page.tsx
+++ b/packages/frontend/src/app/settings/license/activate/page.tsx
@@ -9,6 +9,7 @@ import { buttonVariants } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { sessionStore } from '@/lib/storage';
+import { takeAuthorizationReturn } from '@/lib/card-trial';
const KEY_RE = /^AMCP-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}$/;
const PENDING_KEY = 'amcp_pending_license_key';
@@ -45,6 +46,7 @@ function LicenseActivateInner() {
const key = rawKey.toUpperCase();
const [phase, setPhase] = useState('loading');
const [message, setMessage] = useState('');
+ const [redirectLabel, setRedirectLabel] = useState('Redirecting to settings…');
const ran = useRef(false);
useEffect(() => {
@@ -85,10 +87,14 @@ function LicenseActivateInner() {
sessionStore.remove(PENDING_KEY);
setPhase('success');
setMessage(res.message || 'License activated successfully.');
+ // Started the card trial while connecting an AI client: finish that
+ // authorization (it waits 30 minutes) instead of opening settings.
+ const next = takeAuthorizationReturn('/settings/license');
+ setRedirectLabel(next.startsWith('/auth/') ? 'Taking you back to finish connecting your AI client…' : 'Redirecting to settings…');
// A full load rather than a client-side route change: the app shell
// (trial banner, licence wall) read the licence before activation and
// would keep showing the trial until the next reload.
- setTimeout(() => window.location.replace('/settings/license'), 1500);
+ setTimeout(() => window.location.replace(next), 1500);
})
.catch((err: any) => {
setPhase('error');
@@ -112,7 +118,7 @@ function LicenseActivateInner() {
{phase === 'success' && (
{message}
-
Redirecting to settings…
+
{redirectLabel}
)}
diff --git a/packages/frontend/src/app/start-trial/page.tsx b/packages/frontend/src/app/start-trial/page.tsx
index 95372849..39d2d8e9 100644
--- a/packages/frontend/src/app/start-trial/page.tsx
+++ b/packages/frontend/src/app/start-trial/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { license } from '@/lib/api';
@@ -14,8 +14,10 @@ import {
cardTrialEligible,
formatTrialEnd,
planById,
+ readAuthorizationReturn,
readPlanIntent,
readPromoCode,
+ takeAuthorizationReturn,
writeCardTrialPrompt,
type PlanSelection,
} from '@/lib/card-trial';
@@ -39,6 +41,19 @@ export default function StartTrialPage() {
const [ready, setReady] = useState(false);
const [selection, setSelection] = useState(DEFAULT_SELECTION);
const [promo, setPromo] = useState(null);
+ // Set when the user came from "Connect" in an AI client: every way out of
+ // this page goes back to that authorization.
+ const [authorizationReturn, setAuthorizationReturn] = useState(null);
+
+ /** Leave for the waiting authorization (a backend page) or for `fallback`. */
+ const leave = useCallback(
+ (fallback: string) => {
+ const target = takeAuthorizationReturn(fallback);
+ if (target.startsWith('/auth/')) window.location.assign(target);
+ else router.replace(target);
+ },
+ [router],
+ );
useEffect(() => {
if (isLoading || !deploymentModeLoaded) return;
@@ -47,7 +62,7 @@ export default function StartTrialPage() {
return;
}
if (deploymentMode !== 'cloud' || user.role !== 'ADMIN') {
- router.replace('/');
+ leave('/');
return;
}
let live = true;
@@ -56,21 +71,22 @@ export default function StartTrialPage() {
.then((lic) => {
if (!live) return;
if (!cardTrialEligible({ isCloud: true, role: user.role, license: lic })) {
- router.replace('/');
+ leave('/');
return;
}
+ setAuthorizationReturn(readAuthorizationReturn());
setTrialEnd(cardTrialDisplayEnd(lic.expiresAt));
setSelection(readPlanIntent() ?? DEFAULT_SELECTION);
setPromo(readPromoCode());
setReady(true);
})
.catch(() => {
- if (live) router.replace('/');
+ if (live) leave('/');
});
return () => {
live = false;
};
- }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router]);
+ }, [isLoading, deploymentModeLoaded, deploymentMode, token, user, router, leave]);
const handleStart = async () => {
if (!user) return;
@@ -80,8 +96,9 @@ export default function StartTrialPage() {
const handleSkip = () => {
if (user) writeCardTrialPrompt(user.id, 'skipped');
- // The dashboard decides what comes next (usually the /welcome wizard).
- router.replace('/');
+ // Back to the AI client's authorization if one is waiting; otherwise the
+ // dashboard decides what comes next (usually the /welcome wizard).
+ leave('/');
};
if (!ready) {
@@ -132,6 +149,11 @@ export default function StartTrialPage() {
lower connector limit, and you can add a card
any time to unlock your full plan.
+ {authorizationReturn && (
+
+ Either way, you go straight back to finish connecting your AI client.
+
+ )}
diff --git a/packages/frontend/src/lib/card-trial.ts b/packages/frontend/src/lib/card-trial.ts
index 14591689..5b8ebcb0 100644
--- a/packages/frontend/src/lib/card-trial.ts
+++ b/packages/frontend/src/lib/card-trial.ts
@@ -1,4 +1,5 @@
import { storage } from './storage';
+import { safeRedirect } from './safe-redirect';
/**
* The card trial on AnythingMCP Cloud: right after sign-up an admin is offered
@@ -260,3 +261,64 @@ export function formatTrialEnd(
...(timeZone && { timeZone }),
});
}
+
+// ── Back to an AI client's authorization ────────────────────────────────────
+
+/**
+ * Someone who signs up from "Connect" in Claude is offered the card trial
+ * before approving the connection. The authorization waits for them (the
+ * backend keeps it 30 minutes), so the way back is remembered across the
+ * detour: skipping, a cancelled checkout (Stripe returns to /start-trial) and
+ * a completed one (the licence site returns to /settings/license/activate)
+ * all end on the same consent page.
+ *
+ * localStorage, not sessionStorage: Stripe can open in another tab on some
+ * mobile browsers. Only the backend's own /auth/ pages qualify.
+ */
+const AUTH_RETURN_KEY = 'amcp_card_trial_return';
+export const AUTH_RETURN_TTL_MS = 30 * 60 * 1000;
+
+export function isAuthorizationPath(path: string | null | undefined): path is string {
+ return typeof path === 'string' && path.startsWith('/auth/') && safeRedirect(path, '') === path;
+}
+
+export function saveAuthorizationReturn(path: string, now: number = Date.now()): void {
+ if (!isAuthorizationPath(path)) return;
+ storage.set(AUTH_RETURN_KEY, JSON.stringify({ path, savedAt: now }));
+}
+
+/** The consent page to go back to, or null when none is waiting (or it expired). */
+export function readAuthorizationReturn(now: number = Date.now()): string | null {
+ const raw = storage.get(AUTH_RETURN_KEY);
+ if (!raw) return null;
+ try {
+ const data = JSON.parse(raw);
+ const savedAt = Number(data?.savedAt);
+ if (
+ isAuthorizationPath(data?.path) &&
+ Number.isFinite(savedAt) &&
+ savedAt <= now &&
+ now - savedAt <= AUTH_RETURN_TTL_MS
+ ) {
+ return data.path;
+ }
+ } catch {
+ /* fall through */
+ }
+ storage.remove(AUTH_RETURN_KEY);
+ return null;
+}
+
+export function clearAuthorizationReturn(): void {
+ storage.remove(AUTH_RETURN_KEY);
+}
+
+/**
+ * Where to go after the card-trial offer: the waiting authorization if there
+ * is one (spent here, so it is followed once), otherwise `fallback`.
+ */
+export function takeAuthorizationReturn(fallback: string): string {
+ const path = readAuthorizationReturn();
+ clearAuthorizationReturn();
+ return path ?? fallback;
+}
diff --git a/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts b/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts
new file mode 100644
index 00000000..34bea59f
--- /dev/null
+++ b/packages/frontend/tests/e2e/card-trial-claude-signup.spec.ts
@@ -0,0 +1,149 @@
+import { expect, test, type Page } from '@playwright/test';
+
+/**
+ * Sign-up from "Connect" in Claude: after verifying the email, a new admin on
+ * a fresh free trial is offered the card trial once, then always returns to
+ * the pending authorization (/auth/login, served by the backend), whichever
+ * way they leave the offer:
+ * - "Continue without payment details" → back to approve;
+ * - a completed checkout → the licence site returns to
+ * /settings/license/activate#key=…, which activates and goes back;
+ * - a cancelled checkout → Stripe returns to /start-trial, skip → back.
+ * The authorization must never be stranded, which is what an earlier version
+ * of the trial offer did in this flow.
+ */
+
+const CLOUD_INFO = { deploymentMode: 'cloud', mcpAuthMode: 'oauth2', hasUsers: true, registrationEnabled: true, ssoProviders: [] };
+const NEUTRAL = { verificationRequired: true, message: 'Check your inbox.' };
+const TRIAL_END = new Date(Date.now() + 7 * 86_400_000).toISOString();
+const KEY = 'AMCP-AB12-CD34-EF56-0789';
+
+interface Calls {
+ paths: string[];
+ checkout: unknown[];
+ setKey: unknown[];
+}
+
+async function mockCloud(page: Page, opts: { role?: string; license?: Record } = {}): Promise {
+ const calls: Calls = { paths: [], checkout: [], setKey: [] };
+ const user = { id: 'u-claude', email: 'claude@example.test', name: 'Jane', role: opts.role ?? 'ADMIN', organizationId: 'o1' };
+ await page.route(/\/(api|health)\//, async (route) => {
+ const req = route.request();
+ const p = new URL(req.url()).pathname;
+ calls.paths.push(p);
+ const json = (body: unknown, status = 200) => route.fulfill({ status, json: body });
+ if (p === '/health/server-info') return json(CLOUD_INFO);
+ if (p === '/api/auth/register') return json(NEUTRAL, 201);
+ if (p === '/api/auth/login') return json({ accessToken: 't', user: { ...user, emailVerified: false }, needsLicenseSetup: true });
+ if (p === '/api/auth/verify-email') return json({ message: 'ok', emailVerified: true });
+ if (p === '/api/license/status') {
+ return json(opts.license ?? { plan: 'trial', status: 'active', expiresAt: TRIAL_END, trialDaysLeft: 7, features: {} });
+ }
+ if (p === '/api/license/checkout-link') {
+ calls.checkout.push(req.postDataJSON());
+ return json({ url: 'https://checkout.example.test/start?intent=i1' });
+ }
+ if (p === '/api/license/key' || p === '/api/license/activate') {
+ calls.setKey.push(req.postDataJSON());
+ return json({ message: 'License activated successfully.' });
+ }
+ if (p.endsWith('/users/me')) return json({ ...user, emailVerified: true });
+ return json({});
+ });
+ // The backend's authorization page and Stripe Checkout, stubbed.
+ await page.route((url) => url.pathname === '/auth/login', (route) =>
+ route.fulfill({ status: 200, contentType: 'text/html', body: 'Authorize AnythingMCP
' }),
+ );
+ await page.route('https://checkout.example.test/**', (route) =>
+ route.fulfill({ status: 200, contentType: 'text/html', body: 'Stripe Checkout
' }),
+ );
+ return calls;
+}
+
+async function signUpFromClaude(page: Page) {
+ await page.goto('/login?mode=register&redirect=%2Fauth%2Flogin');
+ await page.locator('#auth-name').fill('Jane');
+ await page.locator('#auth-email').fill('claude@example.test');
+ await page.locator('#auth-password').fill('Str0ng#Passw0rd');
+ await page.locator('#auth-confirm-password').fill('Str0ng#Passw0rd');
+ await page.getByRole('checkbox').check();
+ await page.getByRole('button', { name: 'Create Account' }).click();
+ await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible();
+ await page.locator('input[autocomplete="one-time-code"]').fill('123456');
+ await page.getByRole('button', { name: 'Verify Email' }).click();
+}
+
+test.describe('card trial when signing up from Claude', () => {
+ test('is offered once after verifying, and skipping goes back to approve', async ({ page }) => {
+ const calls = await mockCloud(page);
+ await signUpFromClaude(page);
+
+ await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 });
+ await expect(page.getByText('you go straight back to finish connecting your AI client')).toBeVisible();
+ await page.getByRole('button', { name: 'Try free without payment details' }).click();
+
+ await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 });
+ await expect(page.getByRole('heading', { name: 'Authorize AnythingMCP' })).toBeVisible();
+ expect(calls.checkout).toHaveLength(0);
+ // The way back is spent: a later visit to /start-trial does not detour again.
+ expect(await page.evaluate(() => localStorage.getItem('amcp_card_trial_return'))).toBeNull();
+ });
+
+ test('a completed checkout activates the licence and goes back to approve', async ({ page }) => {
+ const calls = await mockCloud(page);
+ await signUpFromClaude(page);
+ await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 });
+
+ await page.getByRole('button', { name: 'Start free trial with card' }).click();
+ await expect(page.getByRole('heading', { name: 'Stripe Checkout' })).toBeVisible();
+ expect(calls.checkout).toEqual([expect.objectContaining({ trial: true })]);
+
+ // The licence site's success page hands the key over in the fragment.
+ await page.goto(`/settings/license/activate#key=${KEY}`);
+ await expect(page.getByText('Taking you back to finish connecting your AI client')).toBeVisible();
+ await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 });
+ expect(JSON.stringify(calls.setKey)).toContain(KEY);
+ });
+
+ test('a cancelled checkout comes back to the offer, and skipping still goes back to approve', async ({ page }) => {
+ await mockCloud(page);
+ await signUpFromClaude(page);
+ await expect(page).toHaveURL(/\/start-trial$/, { timeout: 15_000 });
+ await page.getByRole('button', { name: 'Start free trial with card' }).click();
+ await expect(page.getByRole('heading', { name: 'Stripe Checkout' })).toBeVisible();
+
+ // Stripe's cancel URL for a card trial.
+ await page.goto('/start-trial');
+ await page.getByRole('button', { name: 'Continue without payment details' }).click();
+ await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 });
+ });
+
+ test('a member who cannot subscribe goes straight back to approve', async ({ page }) => {
+ const calls = await mockCloud(page, { role: 'EDITOR' });
+ await signUpFromClaude(page);
+ await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 });
+ expect(calls.paths).not.toContain('/api/license/checkout-link');
+ });
+
+ test('without a running trial to convert, goes straight back to approve', async ({ page }) => {
+ await mockCloud(page, { license: { plan: 'cloud_team', status: 'active', expiresAt: TRIAL_END } });
+ await signUpFromClaude(page);
+ await expect(page).toHaveURL(/\/auth\/login$/, { timeout: 15_000 });
+ });
+
+ test('an expired way back is not followed', async ({ page }) => {
+ // A stale entry (older than the 30 minutes the backend keeps an
+ // authorization) must not send a later visitor to a dead consent page.
+ await mockCloud(page);
+ await page.context().addCookies([{ name: 'amcp_token', value: 't', url: test.info().project.use.baseURL ?? 'http://localhost:3100' }]);
+ await page.addInitScript(() => {
+ localStorage.setItem('amcp_token', 't');
+ localStorage.setItem('amcp_user', JSON.stringify({ id: 'u-claude', email: 'claude@example.test', role: 'ADMIN', organizationId: 'o1', emailVerified: true }));
+ localStorage.setItem('amcp_card_trial_return', JSON.stringify({ path: '/auth/login', savedAt: Date.now() - 31 * 60 * 1000 }));
+ });
+ await page.goto('/start-trial');
+ await expect(page.getByText('you go straight back to finish connecting your AI client')).toHaveCount(0);
+ await page.getByRole('button', { name: 'Continue without payment details' }).click();
+ await expect(page).not.toHaveURL(/\/auth\/login/);
+ });
+});
diff --git a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts
index 2e3fee8c..4213ff9c 100644
--- a/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts
+++ b/packages/frontend/tests/e2e/neutral-signup-and-billing.spec.ts
@@ -83,8 +83,9 @@ test.describe('sign-up from an AI client (Claude "Connect")', () => {
// The MCP authorization page links "Create an account" with
// redirect=/auth/login. After verifying, the new user must land back on that
// page (still inside the pending authorization) to approve with one click,
- // not on the trial offer or the welcome wizard, which used to strand the
- // connection half way.
+ // not on the welcome wizard, which used to strand the connection half way.
+ // The card-trial offer may come first, but only with a guaranteed way back
+ // (card-trial-claude-signup.spec.ts); here there is no trial to convert.
test('after verifying the email, goes straight back to the authorization page', async ({ page }) => {
const calls: string[] = [];
await page.route(/\/(api|health)\//, async (route) => {