Skip to content

Commit a71cf40

Browse files
feat(funnel): commerce-first redirect at post-auth landing by tier (#216)
* feat(funnel): commerce-first redirect at post-auth landing by tier User interactions are scarce, so every login touchpoint must push commerce (memory project_commerce_first_redirect_at_interactions). Add a pure `postAuthDestination(tier, next)` helper and route the post-auth landing by plan tier: - free → /pricing (drive first purchase) - hobby / hobby_plus / pro / growth → /app/billing (show the next tier) - team (top tier) / unknown → /app (no upsell) HARD RULES encoded: never route to a Team checkout (Team is gated / contact-sales), and an explicit safe deep-link (`?next=` / saved /app/* return_to) ALWAYS wins so the login→checkout flow is preserved and there is no pricing→login→pricing loop. The membership of the "upgrade-eligible" bucket is derived from the canonical TIER_RANK ladder, not a hand-typed list. Wired into both post-auth landing surfaces: - LoginCallbackPage (OAuth / magic-link return) — reads me.user.tier - LoginPage PAT submit — same decision, keeps the BUG-P013 ?next= round-trip Web-only v1 (reads the tier the app already knows; no api change). Tests: - src/lib/postAuthDestination.test.ts — exhaustive per-tier + next-precedence matrix, registry-iterating guard over TIER_RANK, open-redirect rejection (100% statements/branches/functions on the helper) - LoginCallbackPage.test.tsx + LoginPage.test.tsx — tier-driven landing + deep-link override (component, ../api stubbed) - e2e/commerce-first-redirect.spec.ts — Playwright: free→/pricing, pro→/app/billing, team→/app, deep-link honored (mocked-contract, every PR) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(e2e): align auth + D2 CLI-flow landing assertions with commerce-first redirect The commerce-first redirect (postAuthDestination) routes the post-auth landing by plan tier, so four pre-existing specs that asserted a hard /app landing now red: - auth.spec.ts "login accepts valid token": installAPIFake mocks tier hobby (paid + upgrade-eligible) -> the intended landing is /app/billing, not /app. The mocked tier is now passed EXPLICITLY (installAPIFake(page, { tier: 'hobby' })) so the asserted destination can never drift if the fixture default changes. - funnel-recovery.spec.ts D2 x3: mockAuthMe mocked tier free -> the intended landing is /pricing. mockAuthMe now takes the tier as a required explicit argument. The CLI completion POST assertions (/auth/cli/{id}/complete fired exactly once with the right id / swallowed on failure / never fired without cli_session) are unchanged -- only the final landing URL moved, matching the PR contract that cli_session is not a deep-link; only an explicit ?next= / saved return_to overrides the tier rule. installAPIFake gains an optional { tier } param (default FAKE_TIER = 'hobby', named constant) so other specs are untouched. Local: VITE_NO_PROXY=1 playwright chromium full suite 55 passed / 1 skipped; npm run gate green (tsc + build + 1214 vitest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 32f4c2c commit a71cf40

10 files changed

Lines changed: 481 additions & 30 deletions

e2e/auth.spec.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,21 @@ test.describe('Auth gate', () => {
2828
await expect(page).toHaveURL(/\/login$/)
2929
})
3030

31-
test('login accepts valid token, lands on overview', async ({ page }) => {
32-
await installAPIFake(page)
31+
test('login accepts valid token, lands on the commerce-first destination for its tier', async ({ page }) => {
32+
// COMMERCE-FIRST REDIRECT (2026-06-10, memory
33+
// project_commerce_first_redirect_at_interactions): a plain login (no
34+
// ?next= deep-link) routes by plan tier — free → /pricing, paid +
35+
// upgrade-eligible → /app/billing, team → /app. Pin the mocked tier
36+
// EXPLICITLY so the asserted destination is deterministic.
37+
await installAPIFake(page, { tier: 'hobby' })
3338
await page.goto('/login')
3439
await page.getByTestId('toggle-token-form').click()
3540
await page.getByTestId('token-input').fill('ink_VALID')
3641
await page.getByTestId('login-submit').click()
37-
// LoginPage navigates to /app on success (see LoginPage.tsx).
38-
await expect(page).toHaveURL(/\/app\/?$/)
42+
// hobby = paid + upgrade-eligible → the in-app upgrade/billing surface
43+
// (see src/lib/postAuthDestination.ts; the full per-tier matrix is
44+
// covered by e2e/commerce-first-redirect.spec.ts).
45+
await expect(page).toHaveURL(/\/app\/billing$/)
3946
})
4047

4148
test('OAuth buttons redirect to backend handlers', async ({ page }) => {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* commerce-first-redirect.spec.ts — mocked-contract Playwright gate for the
2+
* COMMERCE-FIRST REDIRECT (2026-06-10,
3+
* memory project_commerce_first_redirect_at_interactions).
4+
*
5+
* The product rule: a successful login is a scarce interaction, so the
6+
* post-auth landing routes by plan tier to push commerce —
7+
* free → /pricing (drive the first purchase)
8+
* paid + upgrade-eligible → /app/billing (show the next tier)
9+
* top tier (team) → /app (no upsell — NEVER a Team checkout)
10+
* — UNLESS the user carried an explicit deep-link (a saved /app/* return_to
11+
* or a /login?next=), which always wins (and prevents pricing→login→pricing
12+
* loops).
13+
*
14+
* This drives the REAL SPA route (LoginCallbackPage) through the REAL src/api
15+
* client with the network mocked at the page.route() boundary, so it runs on
16+
* every web PR (mocked playwright.config.ts, VITE_NO_PROXY=1) and reds the PR
17+
* if the tier→destination wiring breaks. It complements:
18+
* - src/lib/postAuthDestination.test.ts (the pure decision matrix, vitest)
19+
* - src/pages/LoginCallbackPage.test.tsx (component, ../api stubbed)
20+
* by exercising the browser-rendered redirect against the real api client.
21+
*/
22+
23+
import { expect, test, type Page, type Route } from '@playwright/test'
24+
25+
const AUTH_ME_PATH = '**/auth/me'
26+
const SESSION_TOKEN = 'sess_jwt_commerce'
27+
28+
// Catch-all for the dependent dashboard bootstrap fetches that fire once the
29+
// SPA lands on an /app/* route (counts + billing). We don't assert on them —
30+
// we only care WHERE the user was routed — so we stub them to harmless empties
31+
// so the destination page doesn't error mid-render.
32+
const RESOURCES_PATH = /\/api\/v1\/resources(\?[^/]*)?$/
33+
const DEPLOYMENTS_PATH = /\/api\/v1\/deployments(\?[^/]*)?$/
34+
const VAULT_PATH = /\/api\/v1\/vault(\?[^/]*)?$/
35+
const BILLING_PATH = '**/api/v1/billing'
36+
37+
/** Mock GET /auth/me to report the given plan tier. The wire shape is the FLAT
38+
* agent payload ({ ok, user_id, team_id, email, tier }); fetchMe() adapts it
39+
* into { user: { tier } } which postAuthDestination reads. */
40+
async function mockAuthMe(page: Page, tier: string) {
41+
await page.route(AUTH_ME_PATH, (route: Route) =>
42+
route.fulfill({
43+
status: 200,
44+
contentType: 'application/json',
45+
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: 'founder@acme.dev', tier }),
46+
}),
47+
)
48+
}
49+
50+
/** Stub the dashboard bootstrap fetches so an /app/* destination renders. */
51+
async function mockDashboardBootstrap(page: Page) {
52+
await page.route(RESOURCES_PATH, (route: Route) =>
53+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }),
54+
)
55+
await page.route(DEPLOYMENTS_PATH, (route: Route) =>
56+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, items: [], total: 0 }) }),
57+
)
58+
await page.route(VAULT_PATH, (route: Route) =>
59+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, entries: [] }) }),
60+
)
61+
await page.route(BILLING_PATH, (route: Route) =>
62+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, billing: { tier: 'free', subscription_status: 'none' } }) }),
63+
)
64+
}
65+
66+
test.describe('commerce-first redirect — post-auth landing by tier', () => {
67+
test('free tier lands on /pricing (drive the first purchase)', async ({ page }) => {
68+
await mockAuthMe(page, 'free')
69+
await mockDashboardBootstrap(page)
70+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
71+
await expect(page).toHaveURL(/\/pricing$/)
72+
})
73+
74+
test('paid+eligible tier (pro) lands on /app/billing (show the next tier)', async ({ page }) => {
75+
await mockAuthMe(page, 'pro')
76+
await mockDashboardBootstrap(page)
77+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
78+
await expect(page).toHaveURL(/\/app\/billing$/)
79+
})
80+
81+
test('top tier (team) lands on /app — never a Team checkout', async ({ page }) => {
82+
await mockAuthMe(page, 'team')
83+
await mockDashboardBootstrap(page)
84+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
85+
await expect(page).toHaveURL(/\/app\/?$/)
86+
// Hard guard: a team user must NOT be pushed to a commerce surface.
87+
await expect(page).not.toHaveURL(/\/pricing$/)
88+
await expect(page).not.toHaveURL(/\/app\/billing$/)
89+
})
90+
91+
test('an explicit /app deep-link (saved return_to) overrides the free-tier pricing push', async ({ page }) => {
92+
await mockAuthMe(page, 'free')
93+
await mockDashboardBootstrap(page)
94+
// Seed the 401-interceptor's saved destination before the callback runs.
95+
// We use /app/resources (a stable page that just lists the empty resource
96+
// set we mocked) so the test asserts the deep-link wins without depending
97+
// on a page that itself redirects (e.g. CheckoutPage auto-fires checkout).
98+
await page.addInitScript(() => {
99+
try { localStorage.setItem('instanode.return_to', '/app/resources') } catch {}
100+
})
101+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
102+
// Deep-link wins — the user lands on the saved destination, NOT /pricing.
103+
await expect(page).toHaveURL(/\/app\/resources$/)
104+
await expect(page).not.toHaveURL(/\/pricing$/)
105+
})
106+
})

e2e/fixtures.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,15 @@ export async function mockAdminPromoIssue(
286286
})
287287
}
288288

289-
export async function installAPIFake(page: Page) {
289+
// The plan tier installAPIFake's /auth/me reports unless a test overrides it.
290+
// COMMERCE-FIRST REDIRECT (2026-06-10): the post-auth landing routes by this
291+
// tier (hobby = paid + upgrade-eligible → /app/billing), so any spec that
292+
// drives a real login flow must pass/assume the tier EXPLICITLY rather than
293+
// relying on this default silently — see auth.spec.ts.
294+
export const FAKE_TIER = 'hobby'
295+
296+
export async function installAPIFake(page: Page, opts: { tier?: string } = {}) {
297+
const tier = opts.tier ?? FAKE_TIER
290298
// GET /auth/me — agent API shape
291299
await page.route('**/auth/me', (route: Route) =>
292300
route.fulfill({
@@ -297,7 +305,7 @@ export async function installAPIFake(page: Page) {
297305
user_id: FAKE_USER,
298306
team_id: FAKE_TEAM,
299307
email: 'aanya@example.com',
300-
tier: 'hobby',
308+
tier,
301309
trial_ends_at: null,
302310
}),
303311
}),

e2e/funnel-recovery.spec.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,15 @@ async function mockEmailStart(page: Page, captured: { body?: any; count: number
4040
}
4141

4242
/** Mock GET /auth/me → 200 so the callback page's post-token verification
43-
* succeeds and it proceeds to navigation. */
44-
async function mockAuthMe(page: Page) {
43+
* succeeds and it proceeds to navigation. The tier is EXPLICIT because the
44+
* COMMERCE-FIRST REDIRECT (2026-06-10) routes the post-auth landing by it:
45+
* free → /pricing, paid+eligible → /app/billing, team → /app. */
46+
async function mockAuthMe(page: Page, tier: string) {
4547
await page.route(AUTH_ME_PATH, (route: Route) =>
4648
route.fulfill({
4749
status: 200,
4850
contentType: 'application/json',
49-
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier: 'free' }),
51+
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier }),
5052
}),
5153
)
5254
}
@@ -138,8 +140,15 @@ test.describe('D2 — CLI device-flow completion', () => {
138140
expect(cap.body?.return_to).toContain(`/login/callback?cli_session=${CLI_SESSION_ID}`)
139141
})
140142

141-
test('the callback POSTs /auth/cli/{id}/complete then lands the user on /app', async ({ page }) => {
142-
await mockAuthMe(page)
143+
// The post-completion landing follows the COMMERCE-FIRST REDIRECT
144+
// (2026-06-10, memory project_commerce_first_redirect_at_interactions):
145+
// the CLI got its token via POST /auth/cli/{id}/complete, so the browser
146+
// tab is a scarce free interaction — a free-tier user is pushed to
147+
// /pricing (NOT /app). The cli_session is not a deep-link; only an
148+
// explicit ?next= / saved return_to overrides the tier rule.
149+
150+
test('the callback POSTs /auth/cli/{id}/complete then lands a free user on /pricing', async ({ page }) => {
151+
await mockAuthMe(page, 'free')
143152
const completeCap = { id: '', count: 0 }
144153
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
145154
completeCap.count += 1
@@ -152,31 +161,34 @@ test.describe('D2 — CLI device-flow completion', () => {
152161
// The callback uses the legacy ?session_token path (no cookie exchange
153162
// needed for the mock) + ?cli_session to trigger completion.
154163
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
155-
await expect(page).toHaveURL(/\/app\/?$/)
164+
// free tier → commerce-first push to /pricing after the device flow
165+
// completes; the CLI itself is already unblocked by the POST below.
166+
await expect(page).toHaveURL(/\/pricing$/)
156167
expect(completeCap.count).toBe(1)
157168
expect(completeCap.id).toBe(CLI_SESSION_ID)
158169
})
159170

160-
test('a cli-completion failure does NOT block the user sign-in (still lands on /app)', async ({ page }) => {
161-
await mockAuthMe(page)
171+
test('a cli-completion failure does NOT block the user sign-in (still lands post-auth)', async ({ page }) => {
172+
await mockAuthMe(page, 'free')
162173
await page.route(CLI_COMPLETE_PATH, (route: Route) =>
163174
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'session_not_found' }) }),
164175
)
165176
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
166177
// completeCliSession swallows the error; the browser user must still
167-
// reach the app.
168-
await expect(page).toHaveURL(/\/app\/?$/)
178+
// reach the signed-in landing (free tier → /pricing, commerce-first).
179+
await expect(page).toHaveURL(/\/pricing$/)
169180
})
170181

171182
test('no cli_session → the callback never calls /auth/cli/.../complete', async ({ page }) => {
172-
await mockAuthMe(page)
183+
await mockAuthMe(page, 'free')
173184
let completeCalled = false
174185
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
175186
completeCalled = true
176187
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
177188
})
178189
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
179-
await expect(page).toHaveURL(/\/app\/?$/)
190+
// free tier → /pricing (commerce-first post-auth landing).
191+
await expect(page).toHaveURL(/\/pricing$/)
180192
expect(completeCalled).toBe(false)
181193
})
182194
})
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/* postAuthDestination.test.ts — exhaustive per-tier + next-precedence
2+
* coverage for the commerce-first redirect decision (2026-06-10).
3+
*
4+
* The matrix this pins:
5+
* tier=free → /pricing
6+
* tier∈{hobby,hobby_plus,pro,growth} → /app/billing
7+
* tier=team → /app (top tier, NEVER a Team checkout)
8+
* tier∈{anonymous,'',unknown} → /app (degrade — no commerce push)
9+
* any explicit safe `next` → next (deep-link always wins)
10+
* unsafe `next` (off-origin/protocol-relative) → falls through to tier rule
11+
*/
12+
13+
import { describe, it, expect } from 'vitest'
14+
import { TIER_RANK, type Tier } from '../api'
15+
import {
16+
postAuthDestination,
17+
isSafeInternalNext,
18+
DEST_PRICING,
19+
DEST_BILLING,
20+
DEST_DASHBOARD,
21+
} from './postAuthDestination'
22+
23+
describe('postAuthDestination — per-tier landing (no next)', () => {
24+
it('free → /pricing (drive the first purchase)', () => {
25+
expect(postAuthDestination('free')).toBe(DEST_PRICING)
26+
expect(DEST_PRICING).toBe('/pricing')
27+
})
28+
29+
it.each<Tier>(['hobby', 'hobby_plus', 'pro', 'growth'])(
30+
'paid+upgrade-eligible tier %s → /app/billing',
31+
(tier) => {
32+
expect(postAuthDestination(tier)).toBe(DEST_BILLING)
33+
},
34+
)
35+
36+
it('team (top tier) → /app, NEVER a Team checkout', () => {
37+
expect(postAuthDestination('team')).toBe(DEST_DASHBOARD)
38+
// Hard guard: a team user must never be sent to a billing/pricing
39+
// commerce surface (Team is gated / contact-sales).
40+
expect(postAuthDestination('team')).not.toBe(DEST_BILLING)
41+
expect(postAuthDestination('team')).not.toBe(DEST_PRICING)
42+
})
43+
44+
it('anonymous → /app (shouldn’t reach here post-auth; degrade safely)', () => {
45+
expect(postAuthDestination('anonymous')).toBe(DEST_DASHBOARD)
46+
})
47+
48+
it.each(['', 'enterprise', 'mystery_tier', null, undefined])(
49+
'unknown/empty tier %p → /app (degrade, never upsell blind)',
50+
(tier) => {
51+
expect(postAuthDestination(tier as any)).toBe(DEST_DASHBOARD)
52+
},
53+
)
54+
55+
// Registry-iterating regression test (rule 18): every tier in the canonical
56+
// ladder resolves to exactly one of the three known destinations — a future
57+
// tier added to TIER_RANK can never resolve to an unexpected/empty path or
58+
// (critically) to a commerce surface for the top tier.
59+
it('every tier in TIER_RANK resolves to a known, non-Team-checkout destination', () => {
60+
const allowed = new Set([DEST_PRICING, DEST_BILLING, DEST_DASHBOARD])
61+
for (const tier of Object.keys(TIER_RANK)) {
62+
const dest = postAuthDestination(tier)
63+
expect(allowed.has(dest)).toBe(true)
64+
// The top tier is NEVER routed to a purchase surface.
65+
if (tier === 'team') {
66+
expect(dest).toBe(DEST_DASHBOARD)
67+
}
68+
}
69+
})
70+
})
71+
72+
describe('postAuthDestination — explicit next precedence', () => {
73+
it.each<Tier>(['anonymous', 'free', 'hobby', 'hobby_plus', 'pro', 'growth', 'team'])(
74+
'a safe internal next overrides the %s tier rule (deep-link wins)',
75+
(tier) => {
76+
expect(postAuthDestination(tier, '/app/checkout?plan=pro')).toBe('/app/checkout?plan=pro')
77+
},
78+
)
79+
80+
it('honours a saved return_to deep-link to /app/billing', () => {
81+
expect(postAuthDestination('free', '/app/billing')).toBe('/app/billing')
82+
})
83+
84+
it('honours a deep-link to /pricing itself without looping back to a tier rule', () => {
85+
// A user explicitly headed to /pricing must land on /pricing regardless of
86+
// tier — this is the anti-loop guarantee (pricing→login→pricing).
87+
expect(postAuthDestination('pro', '/pricing')).toBe('/pricing')
88+
})
89+
90+
it('ignores an empty next and falls through to the tier rule', () => {
91+
expect(postAuthDestination('free', '')).toBe(DEST_PRICING)
92+
expect(postAuthDestination('pro', null)).toBe(DEST_BILLING)
93+
expect(postAuthDestination('team', undefined)).toBe(DEST_DASHBOARD)
94+
})
95+
96+
it('ignores an off-origin next (absolute URL) and falls through to the tier rule', () => {
97+
expect(postAuthDestination('free', 'https://evil.example.com')).toBe(DEST_PRICING)
98+
expect(postAuthDestination('pro', 'http://evil.example.com/app')).toBe(DEST_BILLING)
99+
})
100+
101+
it('ignores a protocol-relative next ("//host") and falls through to the tier rule', () => {
102+
expect(postAuthDestination('free', '//evil.example.com/app')).toBe(DEST_PRICING)
103+
})
104+
105+
it('ignores a relative (non-slash-prefixed) next and falls through', () => {
106+
expect(postAuthDestination('team', 'app/billing')).toBe(DEST_DASHBOARD)
107+
})
108+
})
109+
110+
describe('isSafeInternalNext', () => {
111+
it.each(['/app', '/app/billing', '/pricing', '/app/checkout?plan=pro&frequency=monthly'])(
112+
'accepts safe internal path %p',
113+
(next) => {
114+
expect(isSafeInternalNext(next)).toBe(true)
115+
},
116+
)
117+
118+
it.each(['', null, undefined, 'app/billing', 'https://x.com', 'http://x.com', '//x.com', 'javascript:alert(1)'])(
119+
'rejects unsafe/empty next %p',
120+
(next) => {
121+
expect(isSafeInternalNext(next as any)).toBe(false)
122+
},
123+
)
124+
})

0 commit comments

Comments
 (0)