Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions e2e/funnel-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/* funnel-recovery.spec.ts — mocked-contract Playwright gate for the
* auth/claim funnel-recovery surfaces shipped 2026-06-10:
*
* F4 — the magic-link "we sent a link" state is no longer a silent
* dead-end: it offers a Resend affordance + a GitHub-OAuth fallback
* (email delivery is 100%-failing while the Brevo sender is
* unvalidated, so this is the only path off the screen).
* F6 — the /claim dead-ends (tokenless "Missing claim link" + invalid/
* expired token) surface GitHub OAuth as a primary recovery CTA.
* D2 — the CLI device-flow: /login?cli_session=<id> forwards the id
* through the OAuth/magic-link return_to so LoginCallbackPage can
* POST /auth/cli/{id}/complete after sign-in.
*
* Runs under the DEFAULT mocked config (playwright.config.ts → VITE_NO_PROXY=1,
* same-origin), so every page.route() glob below intercepts the SPA's fetch and
* no upstream api is contacted. This is the browser-rendered, real-src/api layer
* that complements the vitest component tests (which stub the api module).
*/

import { expect, test, type Page, type Route } from '@playwright/test'

// ─── Constants ───────────────────────────────────────────────────────────────
const EMAIL_START_PATH = '**/auth/email/start'
const AUTH_ME_PATH = '**/auth/me'
const CLI_COMPLETE_PATH = /\/auth\/cli\/[^/]+\/complete$/
const TEST_EMAIL = 'founder@acme.dev'
const CLI_SESSION_ID = 'cli_sess_abc123'
const SESSION_TOKEN = 'sess_jwt_callback'

/** Mock POST /auth/email/start → 202 (the api returns 202 regardless of
* whether the email exists). Captures the request body so the test can assert
* the return_to carries the cli_session when present. */
async function mockEmailStart(page: Page, captured: { body?: any; count: number }) {
await page.route(EMAIL_START_PATH, (route: Route) => {
if (route.request().method() !== 'POST') return route.continue()
captured.count += 1
captured.body = JSON.parse(route.request().postData() ?? '{}')
return route.fulfill({ status: 202, contentType: 'application/json', body: '{}' })
})
}

/** Mock GET /auth/me → 200 so the callback page's post-token verification
* succeeds and it proceeds to navigation. */
async function mockAuthMe(page: Page) {
await page.route(AUTH_ME_PATH, (route: Route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, user_id: 'u1', team_id: 't1', email: TEST_EMAIL, tier: 'free' }),
}),
)
}

// ─── F4: magic-link sent state is not a dead-end ─────────────────────────────

test.describe('F4 — magic-link recovery affordances', () => {
async function reachSentState(page: Page) {
await page.getByTestId('email-input').fill(TEST_EMAIL)
await page.getByTestId('email-submit').click()
await expect(page.getByTestId('magic-link-sent')).toBeVisible()
}

test('the sent state renders Resend + GitHub-fallback controls', async ({ page }) => {
const cap = { count: 0 } as { body?: any; count: number }
await mockEmailStart(page, cap)
await page.goto('/login')
await reachSentState(page)
await expect(page.getByTestId('magic-link-resend')).toBeVisible()
await expect(page.getByTestId('magic-link-github-fallback')).toBeVisible()
})

test('Resend re-fires POST /auth/email/start', async ({ page }) => {
const cap = { count: 0 } as { body?: any; count: number }
await mockEmailStart(page, cap)
await page.goto('/login')
await reachSentState(page)
expect(cap.count).toBe(1)
await page.getByTestId('magic-link-resend').click()
await expect.poll(() => cap.count).toBe(2)
})

test('the GitHub fallback navigates to the OAuth start handler', async ({ page }) => {
const cap = { count: 0 } as { body?: any; count: number }
await mockEmailStart(page, cap)
// The github/start redirect leaves the SPA — intercept it so the test
// doesn't navigate to the real api. Asserting the URL we were sent to.
await page.route('**/auth/github/start*', (route: Route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<html>oauth start</html>' }),
)
await page.goto('/login')
await reachSentState(page)
await Promise.all([
page.waitForURL(/\/auth\/github\/start\?return_to=/),
page.getByTestId('magic-link-github-fallback').click(),
])
})
})

// ─── F6: claim dead-ends surface GitHub OAuth ────────────────────────────────

test.describe('F6 — claim funnel recovery via GitHub OAuth', () => {
test('the tokenless "Missing claim link" state surfaces a GitHub CTA', async ({ page }) => {
await page.goto('/claim')
await expect(page.getByText(/missing claim link/i)).toBeVisible()
await expect(page.getByTestId('claim-github-oauth')).toBeVisible()
})

test('the invalid/expired-link state surfaces a GitHub CTA', async ({ page }) => {
await page.goto('/claim?t=not-a-valid-jwt-blob')
await expect(page.getByTestId('claim-invalid')).toBeVisible()
await expect(page.getByTestId('claim-github-oauth')).toBeVisible()
})

test('the GitHub CTA navigates to the OAuth start handler', async ({ page }) => {
await page.route('**/auth/github/start*', (route: Route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<html>oauth start</html>' }),
)
await page.goto('/claim')
await Promise.all([
page.waitForURL(/\/auth\/github\/start\?return_to=/),
page.getByTestId('claim-github-oauth').click(),
])
})
})

// ─── D2: CLI device-flow — cli_session preserved + completed ─────────────────

test.describe('D2 — CLI device-flow completion', () => {
test('LoginPage forwards cli_session into the magic-link return_to', async ({ page }) => {
const cap = { count: 0 } as { body?: any; count: number }
await mockEmailStart(page, cap)
await page.goto(`/login?cli_session=${CLI_SESSION_ID}`)
await page.getByTestId('email-input').fill(TEST_EMAIL)
await page.getByTestId('email-submit').click()
await expect(page.getByTestId('magic-link-sent')).toBeVisible()
// The return_to the SPA sent the api must carry the cli_session so the
// post-auth callback can complete the device flow.
expect(cap.body?.return_to).toContain(`/login/callback?cli_session=${CLI_SESSION_ID}`)
})

test('the callback POSTs /auth/cli/{id}/complete then lands the user on /app', async ({ page }) => {
await mockAuthMe(page)
const completeCap = { id: '', count: 0 }
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
completeCap.count += 1
// Pull the session id out of the path: /auth/cli/<id>/complete
const m = new URL(route.request().url()).pathname.match(/\/auth\/cli\/([^/]+)\/complete$/)
completeCap.id = m ? decodeURIComponent(m[1]) : ''
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
})

// The callback uses the legacy ?session_token path (no cookie exchange
// needed for the mock) + ?cli_session to trigger completion.
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
await expect(page).toHaveURL(/\/app\/?$/)
expect(completeCap.count).toBe(1)
expect(completeCap.id).toBe(CLI_SESSION_ID)
})

test('a cli-completion failure does NOT block the user sign-in (still lands on /app)', async ({ page }) => {
await mockAuthMe(page)
await page.route(CLI_COMPLETE_PATH, (route: Route) =>
route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'session_not_found' }) }),
)
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}&cli_session=${CLI_SESSION_ID}`)
// completeCliSession swallows the error; the browser user must still
// reach the app.
await expect(page).toHaveURL(/\/app\/?$/)
})

test('no cli_session → the callback never calls /auth/cli/.../complete', async ({ page }) => {
await mockAuthMe(page)
let completeCalled = false
await page.route(CLI_COMPLETE_PATH, (route: Route) => {
completeCalled = true
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
})
await page.goto(`/login/callback?session_token=${SESSION_TOKEN}`)
await expect(page).toHaveURL(/\/app\/?$/)
expect(completeCalled).toBe(false)
})
})
40 changes: 36 additions & 4 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,29 @@ server {
# - the New Relic browser agent boots inline at the top of <head>
# - JSON-LD blog metadata is inline <script type="application/ld+json">
# Connect-src allows the agent API + NR ingest so dashboard fetches
# and RUM beacons aren't broken. add_header inherits down — it must
# be set at server-level OR repeated in every block, otherwise nested
# location blocks lose it. We set it here once for that reason.
# and RUM beacons aren't broken.
#
# ⚠️ S6 (2026-06-10) — THIS CONFIG IS NOT THE LIVE SERVING LAYER. The
# live instanode.dev is GitHub Pages behind Cloudflare (verified:
# `server: cloudflare` + `x-github-request-id` on every response, Fastly
# `via: varnish`, no nginx). GitHub Pages cannot emit custom response
# headers, so NONE of these headers (CSP, X-Frame-Options, etc.) reach the
# browser on the live site — on `/app/*` OR any other route. This nginx.conf
# is only used by the Dockerfile build (an alternative deploy path that does
# not serve instanode.dev today). The LIVE fix is OPERATOR/EDGE-side: add a
# Cloudflare Transform Rule (or equivalent edge response-header rule) that
# applies this exact header set in front of GitHub Pages. X-Frame-Options in
# particular is header-only — it cannot be set via an index.html <meta> tag,
# so a build-only fix is impossible for the clickjacking control.
#
# nginx `add_header` inheritance gotcha (fixed below): when a `location`
# block defines its OWN `add_header`, nginx DROPS all server-level
# `add_header` directives for responses served by that block. The SPA
# fallback `location /` (and the asset/llms blocks) each set their own
# Cache-Control, so the security headers were silently lost for every HTML
# route under the Docker path too. We now repeat them via the
# `security_headers` include map below so the Docker path is actually
# correct if/when it's used.
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;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
Expand Down Expand Up @@ -91,11 +111,23 @@ server {

# ── SPA fallback ────────────────────────────────────────────────────
# Every unmatched route falls through to index.html so React Router
# owns it. Critical: index.html itself must NOT be cached.
# owns it — this is the block that serves /app/* and every HTML route.
# Critical: index.html itself must NOT be cached.
#
# The security headers MUST be repeated here: this block defines its own
# `add_header` (Cache-Control/Pragma/Expires), and nginx drops ALL
# server-level `add_header` directives the moment a location adds even one
# of its own. Without these repeats, every HTML response (including /app/*)
# would ship with no CSP / X-Frame-Options under the Docker path. (S6.)
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
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;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
try_files $uri $uri/ /index.html;
}

Expand Down
2 changes: 1 addition & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A user who wants to try instanode.dev just tells their agent, in plain language,

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

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

## API endpoints

Expand Down
10 changes: 10 additions & 0 deletions scripts/fetch-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,23 @@ const SYNC_FILES = [
// reading the live https://instanode.dev/llms.txt can always find the
// failure-diagnosis loop. Mirrors the CONTRACT_MARKERS row in
// src/lib/llmsContract.test.ts (content PR #27 already carries both, no-op today).
//
// F6/anon-deploy (2026-06-10): the canonical homepage example is the
// anonymous "no signup" flow, which MUST deploy via POST /stacks/new
// (/deploy/new requires a Bearer JWT — memory
// project_anonymous_deploy_via_stacks_not_deploy_new). The marker pins
// the corrected wording so an upstream content sync that still tells the
// anon flow to call /deploy/new PRESERVES the committed copy rather than
// reverting agents back to the broken instruction. No-op once the content
// repo carries the same correction.
requireMarkers: [
'redeploy=true',
'"redeployed":',
'not yet a self-serve tier',
'**Enterprise**',
'troubleshooting-deploys',
'/api/v1/deployments/:id/events',
'anonymous deploys go through `/stacks/new`',
],
},
]
Expand Down
32 changes: 25 additions & 7 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -514,17 +514,35 @@ async function main() {
await writeFile(resolve(DIST, '404.html'), notFoundBody, 'utf-8')
console.log('prerender: wrote dist/404.html with rendered NotFoundPage')

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

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