Skip to content

Commit 32f4c2c

Browse files
fix(funnel): magic-link/claim recovery + CLI device-flow web half + llms anon-deploy sync (#215)
Funnel-recovery and CLI device-flow fixes for the auth/claim surfaces, plus the llms.txt anon-deploy contract sync and the prerender pipeline gap that was silently serving the un-guarded llms.txt. F4 — magic-link "we sent a link" was a silent dead-end (email delivery is 100%-failing while the Brevo sender is unvalidated). The sent state now offers a "Resend" affordance and an "or continue with GitHub" fallback to the one working auth path. (src/pages/LoginPage.tsx) F6 — the /claim dead-ends (tokenless "Missing claim link" + invalid/expired token) now surface GitHub OAuth as a primary recovery CTA. (src/pages/ClaimPage.tsx) D2 (web half) — finish the CLI device-flow. LoginPage now forwards ?cli_session=<id> through the OAuth/magic-link return_to; LoginCallbackPage POSTs /auth/cli/{id}/complete with the session Bearer after sign-in (new completeCliSession() helper, best-effort — never blocks the browser user). Before this, App.tsx forwarded the param to /login then dropped it and the CLI polled forever. (src/api/index.ts, src/pages/LoginPage.tsx, LoginCallbackPage.tsx) llms.txt anon-deploy sync — the canonical homepage example (anonymous "no signup" flow) told the agent to POST /deploy/new, which is RequireAuth and 401s for an anonymous caller; corrected to POST /stacks/new (memory project_anonymous_deploy_via_stacks_not_deploy_new). Pinned via a fetch-content requireMarkers guard + llmsContract.test.ts row so a content-repo sync can't silently revert it. Also fixed scripts/prerender.mjs Step 5, which published dist/llms.txt straight from .content/ — bypassing the requireMarkers guard so the SERVED /llms.txt reverted to stale upstream even when public/llms.txt carried the correction. It now publishes the guarded copy. S6 — VERDICT: operator/edge, NOT fixable in-repo. The live site is GitHub Pages behind Cloudflare (verified via served headers: server:cloudflare + x-github-request-id, Fastly via:varnish, no nginx). GitHub Pages cannot emit custom response headers, so CSP/X-Frame-Options are absent on every route, not just /app/*. The repo nginx.conf is only used by the Dockerfile path (not live). X-Frame-Options is header-only (no <meta> equivalent), so a build-only fix is impossible. The live fix needs a Cloudflare Transform Rule. Documented in nginx.conf; ALSO fixed a real latent nginx add_header-inheritance bug there (location / redefined add_header, dropping all server-level security headers for HTML/app routes under the Docker path). Tests: 9 new vitest cases (LoginPage F4+D2, LoginCallbackPage D2, ClaimPage F6, completeCliSession wrapper) + 1 llmsContract row, and a new mocked Playwright spec e2e/funnel-recovery.spec.ts (10 cases). Gate green: tsc --noEmit + vite build + vitest (81 files, 1169 passed / 3 skipped). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8e4e7cd commit 32f4c2c

14 files changed

Lines changed: 649 additions & 21 deletions

e2e/funnel-recovery.spec.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/* funnel-recovery.spec.ts — mocked-contract Playwright gate for the
2+
* auth/claim funnel-recovery surfaces shipped 2026-06-10:
3+
*
4+
* F4 — the magic-link "we sent a link" state is no longer a silent
5+
* dead-end: it offers a Resend affordance + a GitHub-OAuth fallback
6+
* (email delivery is 100%-failing while the Brevo sender is
7+
* unvalidated, so this is the only path off the screen).
8+
* F6 — the /claim dead-ends (tokenless "Missing claim link" + invalid/
9+
* expired token) surface GitHub OAuth as a primary recovery CTA.
10+
* D2 — the CLI device-flow: /login?cli_session=<id> forwards the id
11+
* through the OAuth/magic-link return_to so LoginCallbackPage can
12+
* POST /auth/cli/{id}/complete after sign-in.
13+
*
14+
* Runs under the DEFAULT mocked config (playwright.config.ts → VITE_NO_PROXY=1,
15+
* same-origin), so every page.route() glob below intercepts the SPA's fetch and
16+
* no upstream api is contacted. This is the browser-rendered, real-src/api layer
17+
* that complements the vitest component tests (which stub the api module).
18+
*/
19+
20+
import { expect, test, type Page, type Route } from '@playwright/test'
21+
22+
// ─── Constants ───────────────────────────────────────────────────────────────
23+
const EMAIL_START_PATH = '**/auth/email/start'
24+
const AUTH_ME_PATH = '**/auth/me'
25+
const CLI_COMPLETE_PATH = /\/auth\/cli\/[^/]+\/complete$/
26+
const TEST_EMAIL = 'founder@acme.dev'
27+
const CLI_SESSION_ID = 'cli_sess_abc123'
28+
const SESSION_TOKEN = 'sess_jwt_callback'
29+
30+
/** Mock POST /auth/email/start → 202 (the api returns 202 regardless of
31+
* whether the email exists). Captures the request body so the test can assert
32+
* the return_to carries the cli_session when present. */
33+
async function mockEmailStart(page: Page, captured: { body?: any; count: number }) {
34+
await page.route(EMAIL_START_PATH, (route: Route) => {
35+
if (route.request().method() !== 'POST') return route.continue()
36+
captured.count += 1
37+
captured.body = JSON.parse(route.request().postData() ?? '{}')
38+
return route.fulfill({ status: 202, contentType: 'application/json', body: '{}' })
39+
})
40+
}
41+
42+
/** 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) {
45+
await page.route(AUTH_ME_PATH, (route: Route) =>
46+
route.fulfill({
47+
status: 200,
48+
contentType: 'application/json',
49+
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier: 'free' }),
50+
}),
51+
)
52+
}
53+
54+
// ─── F4: magic-link sent state is not a dead-end ─────────────────────────────
55+
56+
test.describe('F4 — magic-link recovery affordances', () => {
57+
async function reachSentState(page: Page) {
58+
await page.getByTestId('email-input').fill(TEST_EMAIL)
59+
await page.getByTestId('email-submit').click()
60+
await expect(page.getByTestId('magic-link-sent')).toBeVisible()
61+
}
62+
63+
test('the sent state renders Resend + GitHub-fallback controls', async ({ page }) => {
64+
const cap = { count: 0 } as { body?: any; count: number }
65+
await mockEmailStart(page, cap)
66+
await page.goto('/login')
67+
await reachSentState(page)
68+
await expect(page.getByTestId('magic-link-resend')).toBeVisible()
69+
await expect(page.getByTestId('magic-link-github-fallback')).toBeVisible()
70+
})
71+
72+
test('Resend re-fires POST /auth/email/start', async ({ page }) => {
73+
const cap = { count: 0 } as { body?: any; count: number }
74+
await mockEmailStart(page, cap)
75+
await page.goto('/login')
76+
await reachSentState(page)
77+
expect(cap.count).toBe(1)
78+
await page.getByTestId('magic-link-resend').click()
79+
await expect.poll(() => cap.count).toBe(2)
80+
})
81+
82+
test('the GitHub fallback navigates to the OAuth start handler', async ({ page }) => {
83+
const cap = { count: 0 } as { body?: any; count: number }
84+
await mockEmailStart(page, cap)
85+
// The github/start redirect leaves the SPA — intercept it so the test
86+
// doesn't navigate to the real api. Asserting the URL we were sent to.
87+
await page.route('**/auth/github/start*', (route: Route) =>
88+
route.fulfill({ status: 200, contentType: 'text/html', body: '<html>oauth start</html>' }),
89+
)
90+
await page.goto('/login')
91+
await reachSentState(page)
92+
await Promise.all([
93+
page.waitForURL(/\/auth\/github\/start\?return_to=/),
94+
page.getByTestId('magic-link-github-fallback').click(),
95+
])
96+
})
97+
})
98+
99+
// ─── F6: claim dead-ends surface GitHub OAuth ────────────────────────────────
100+
101+
test.describe('F6 — claim funnel recovery via GitHub OAuth', () => {
102+
test('the tokenless "Missing claim link" state surfaces a GitHub CTA', async ({ page }) => {
103+
await page.goto('/claim')
104+
await expect(page.getByText(/missing claim link/i)).toBeVisible()
105+
await expect(page.getByTestId('claim-github-oauth')).toBeVisible()
106+
})
107+
108+
test('the invalid/expired-link state surfaces a GitHub CTA', async ({ page }) => {
109+
await page.goto('/claim?t=not-a-valid-jwt-blob')
110+
await expect(page.getByTestId('claim-invalid')).toBeVisible()
111+
await expect(page.getByTestId('claim-github-oauth')).toBeVisible()
112+
})
113+
114+
test('the GitHub CTA navigates to the OAuth start handler', async ({ page }) => {
115+
await page.route('**/auth/github/start*', (route: Route) =>
116+
route.fulfill({ status: 200, contentType: 'text/html', body: '<html>oauth start</html>' }),
117+
)
118+
await page.goto('/claim')
119+
await Promise.all([
120+
page.waitForURL(/\/auth\/github\/start\?return_to=/),
121+
page.getByTestId('claim-github-oauth').click(),
122+
])
123+
})
124+
})
125+
126+
// ─── D2: CLI device-flow — cli_session preserved + completed ─────────────────
127+
128+
test.describe('D2 — CLI device-flow completion', () => {
129+
test('LoginPage forwards cli_session into the magic-link return_to', async ({ page }) => {
130+
const cap = { count: 0 } as { body?: any; count: number }
131+
await mockEmailStart(page, cap)
132+
await page.goto(`/login?cli_session=${CLI_SESSION_ID}`)
133+
await page.getByTestId('email-input').fill(TEST_EMAIL)
134+
await page.getByTestId('email-submit').click()
135+
await expect(page.getByTestId('magic-link-sent')).toBeVisible()
136+
// The return_to the SPA sent the api must carry the cli_session so the
137+
// post-auth callback can complete the device flow.
138+
expect(cap.body?.return_to).toContain(`/login/callback?cli_session=${CLI_SESSION_ID}`)
139+
})
140+
141+
test('the callback POSTs /auth/cli/{id}/complete then lands the user on /app', async ({ page }) => {
142+
await mockAuthMe(page)
143+
const completeCap = { id: '', count: 0 }
144+
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
145+
completeCap.count += 1
146+
// Pull the session id out of the path: /auth/cli/<id>/complete
147+
const m = new URL(route.request().url()).pathname.match(/\/auth\/cli\/([^/]+)\/complete$/)
148+
completeCap.id = m ? decodeURIComponent(m[1]) : ''
149+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
150+
})
151+
152+
// The callback uses the legacy ?session_token path (no cookie exchange
153+
// needed for the mock) + ?cli_session to trigger completion.
154+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
155+
await expect(page).toHaveURL(/\/app\/?$/)
156+
expect(completeCap.count).toBe(1)
157+
expect(completeCap.id).toBe(CLI_SESSION_ID)
158+
})
159+
160+
test('a cli-completion failure does NOT block the user sign-in (still lands on /app)', async ({ page }) => {
161+
await mockAuthMe(page)
162+
await page.route(CLI_COMPLETE_PATH, (route: Route) =>
163+
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'session_not_found' }) }),
164+
)
165+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
166+
// completeCliSession swallows the error; the browser user must still
167+
// reach the app.
168+
await expect(page).toHaveURL(/\/app\/?$/)
169+
})
170+
171+
test('no cli_session → the callback never calls /auth/cli/.../complete', async ({ page }) => {
172+
await mockAuthMe(page)
173+
let completeCalled = false
174+
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
175+
completeCalled = true
176+
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
177+
})
178+
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
179+
await expect(page).toHaveURL(/\/app\/?$/)
180+
expect(completeCalled).toBe(false)
181+
})
182+
})

nginx.conf

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,29 @@ server {
2525
# - the New Relic browser agent boots inline at the top of <head>
2626
# - JSON-LD blog metadata is inline <script type="application/ld+json">
2727
# Connect-src allows the agent API + NR ingest so dashboard fetches
28-
# and RUM beacons aren't broken. add_header inherits down — it must
29-
# be set at server-level OR repeated in every block, otherwise nested
30-
# location blocks lose it. We set it here once for that reason.
28+
# and RUM beacons aren't broken.
29+
#
30+
# ⚠️ S6 (2026-06-10) — THIS CONFIG IS NOT THE LIVE SERVING LAYER. The
31+
# live instanode.dev is GitHub Pages behind Cloudflare (verified:
32+
# `server: cloudflare` + `x-github-request-id` on every response, Fastly
33+
# `via: varnish`, no nginx). GitHub Pages cannot emit custom response
34+
# headers, so NONE of these headers (CSP, X-Frame-Options, etc.) reach the
35+
# browser on the live site — on `/app/*` OR any other route. This nginx.conf
36+
# is only used by the Dockerfile build (an alternative deploy path that does
37+
# not serve instanode.dev today). The LIVE fix is OPERATOR/EDGE-side: add a
38+
# Cloudflare Transform Rule (or equivalent edge response-header rule) that
39+
# applies this exact header set in front of GitHub Pages. X-Frame-Options in
40+
# particular is header-only — it cannot be set via an index.html <meta> tag,
41+
# so a build-only fix is impossible for the clickjacking control.
42+
#
43+
# nginx `add_header` inheritance gotcha (fixed below): when a `location`
44+
# block defines its OWN `add_header`, nginx DROPS all server-level
45+
# `add_header` directives for responses served by that block. The SPA
46+
# fallback `location /` (and the asset/llms blocks) each set their own
47+
# Cache-Control, so the security headers were silently lost for every HTML
48+
# route under the Docker path too. We now repeat them via the
49+
# `security_headers` include map below so the Docker path is actually
50+
# correct if/when it's used.
3151
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js-agent.newrelic.com https://bam.nr-data.net https://checkout.razorpay.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.instanode.dev https://*.nr-data.net https://*.newrelic.com https://api.razorpay.com wss://api.instanode.dev; frame-src https://checkout.razorpay.com; object-src 'none'; base-uri 'self'; form-action 'self' https://api.instanode.dev;" always;
3252
add_header X-Frame-Options "SAMEORIGIN" always;
3353
add_header X-Content-Type-Options "nosniff" always;
@@ -91,11 +111,23 @@ server {
91111

92112
# ── SPA fallback ────────────────────────────────────────────────────
93113
# Every unmatched route falls through to index.html so React Router
94-
# owns it. Critical: index.html itself must NOT be cached.
114+
# owns it — this is the block that serves /app/* and every HTML route.
115+
# Critical: index.html itself must NOT be cached.
116+
#
117+
# The security headers MUST be repeated here: this block defines its own
118+
# `add_header` (Cache-Control/Pragma/Expires), and nginx drops ALL
119+
# server-level `add_header` directives the moment a location adds even one
120+
# of its own. Without these repeats, every HTML response (including /app/*)
121+
# would ship with no CSP / X-Frame-Options under the Docker path. (S6.)
95122
location / {
96123
add_header Cache-Control "no-cache, no-store, must-revalidate";
97124
add_header Pragma "no-cache";
98125
add_header Expires "0";
126+
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js-agent.newrelic.com https://bam.nr-data.net https://checkout.razorpay.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.instanode.dev https://*.nr-data.net https://*.newrelic.com https://api.razorpay.com wss://api.instanode.dev; frame-src https://checkout.razorpay.com; object-src 'none'; base-uri 'self'; form-action 'self' https://api.instanode.dev;" always;
127+
add_header X-Frame-Options "SAMEORIGIN" always;
128+
add_header X-Content-Type-Options "nosniff" always;
129+
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
130+
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
99131
try_files $uri $uri/ /index.html;
100132
}
101133

public/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A user who wants to try instanode.dev just tells their agent, in plain language,
1010

1111
> Build me a tiny expense tracker — Postgres backing store, a FastAPI app on top, and deploy it to a public URL I can hit from my phone. Use instanode.dev — no signup, provision whatever you need.
1212

13-
On a prompt like that, the agent should: `POST /db/new` for Postgres, write the app + Dockerfile, `tar` it, `POST /deploy/new` with the `upgrade_jwt` from the db call, then hand the user the `*.deployment.instanode.dev` URL. Surface the claim link from the response so the user can keep it past 24h.
13+
On a prompt like that, the agent should: `POST /db/new` for Postgres, write the app + Dockerfile, `tar` it, and — because this is the anonymous "no signup" path — deploy via `POST /stacks/new` (anonymous deploys go through `/stacks/new`; `/deploy/new` requires a Bearer JWT and is the claimed-account path). Then hand the user the `*.deployment.instanode.dev` URL and surface the claim link from the response so the user can keep it past the 6h anonymous-stack TTL. Once the user has claimed (or the agent holds an `upgrade_jwt`), in-place version pushes and single-app deploys use `POST /deploy/new`.
1414

1515
## API endpoints
1616

scripts/fetch-content.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,23 @@ const SYNC_FILES = [
8989
// reading the live https://instanode.dev/llms.txt can always find the
9090
// failure-diagnosis loop. Mirrors the CONTRACT_MARKERS row in
9191
// src/lib/llmsContract.test.ts (content PR #27 already carries both, no-op today).
92+
//
93+
// F6/anon-deploy (2026-06-10): the canonical homepage example is the
94+
// anonymous "no signup" flow, which MUST deploy via POST /stacks/new
95+
// (/deploy/new requires a Bearer JWT — memory
96+
// project_anonymous_deploy_via_stacks_not_deploy_new). The marker pins
97+
// the corrected wording so an upstream content sync that still tells the
98+
// anon flow to call /deploy/new PRESERVES the committed copy rather than
99+
// reverting agents back to the broken instruction. No-op once the content
100+
// repo carries the same correction.
92101
requireMarkers: [
93102
'redeploy=true',
94103
'"redeployed":',
95104
'not yet a self-serve tier',
96105
'**Enterprise**',
97106
'troubleshooting-deploys',
98107
'/api/v1/deployments/:id/events',
108+
'anonymous deploys go through `/stacks/new`',
99109
],
100110
},
101111
]

scripts/prerender.mjs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -514,17 +514,35 @@ async function main() {
514514
await writeFile(resolve(DIST, '404.html'), notFoundBody, 'utf-8')
515515
console.log('prerender: wrote dist/404.html with rendered NotFoundPage')
516516

517-
// Step 5: copy /llms.txt from the content repo to dist root. The
518-
// llms.txt convention (https://llmstxt.org) expects the file at the
519-
// domain root.
520-
const llmsSource = resolve(ROOT, '.content/llms.txt')
517+
// Step 5: publish /llms.txt at the dist root. The llms.txt convention
518+
// (https://llmstxt.org) expects the file at the domain root.
519+
//
520+
// SOURCE PRECEDENCE (fixed 2026-06-10): prefer the committed, post-
521+
// fetch-content `public/llms.txt` over the raw `.content/llms.txt`. This
522+
// matters because scripts/fetch-content.mjs applies a `requireMarkers`
523+
// lock-step guard: when the content repo HEAD is missing a contract marker
524+
// documented ahead of its upstream landing, fetch-content PRESERVES the
525+
// committed `public/llms.txt` instead of reverting to the stale upstream.
526+
// The previous version of this step copied straight from `.content/llms.txt`,
527+
// bypassing that guard entirely — so the SERVED /llms.txt silently reverted
528+
// to upstream HEAD even though `public/llms.txt` (and llmsContract.test.ts)
529+
// carried the corrected contract. We now publish the guarded copy. Vite has
530+
// already copied `public/llms.txt` → `dist/llms.txt` during `vite build`
531+
// (this script runs after), so dist is the authoritative guarded copy; we
532+
// fall back to public/ then .content/ only if it's somehow absent.
533+
const llmsCandidates = [
534+
resolve(DIST, 'llms.txt'), // vite-copied public/llms.txt (guarded)
535+
resolve(ROOT, 'public/llms.txt'),
536+
resolve(ROOT, '.content/llms.txt'),
537+
]
538+
const llmsSource = llmsCandidates.find((p) => existsSync(p))
521539
let llmsBaseContent = ''
522-
if (existsSync(llmsSource)) {
540+
if (llmsSource) {
523541
llmsBaseContent = await readFile(llmsSource, 'utf-8')
524542
await writeFile(resolve(DIST, 'llms.txt'), llmsBaseContent, 'utf-8')
525-
console.log('prerender: copied llms.txt to dist root')
543+
console.log(`prerender: published llms.txt to dist root (source: ${llmsSource.replace(ROOT + '/', '')})`)
526544
} else {
527-
console.warn('prerender: no .content/llms.txt found, skipping')
545+
console.warn('prerender: no llms.txt found in dist/public/.content, skipping')
528546
}
529547

530548
// Step 6: emit .md mirror routes for every HTML page so LLMs and

0 commit comments

Comments
 (0)