diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 7ecb058..0366da2 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -178,6 +178,14 @@ const ROUTE_META = { description: 'Claim the anonymous resources your agent provisioned and convert them to a permanent team account.', }, + // /cli-auth — defensive redirect to /login?cli_session= (App.tsx + // CliAuthRedirect). Even though the visible time on this URL is a few + // ms before the Navigate runs, the SPA shell still needs a meaningful + // so the tab strip doesn't briefly flash the homepage title. + '/cli-auth': { + title: 'Signing in CLI… · instanode', + description: 'Completing CLI device-flow sign-in for instanode.dev.', + }, // /app is the dashboard SPA entry. Visitors who type instanode.dev/app // hit this shell before AuthGate runs; a meaningful title is friendlier // than the homepage title bleeding through. @@ -416,7 +424,12 @@ async function main() { // /login in a new tab saw the wrong title, breaking WCAG 2.4.2 and // confusing tab-strip navigation. metaForRoute() returns sensible // titles for /login, /login/callback, and /claim from ROUTE_META. - const authShellRoutes = ['/login', '/login/callback', '/claim'] + // /cli-auth — defensive redirect emitted by App.tsx's CliAuthRedirect. + // The api emits the canonical /login?cli_session=<id>, but /cli-auth + // appears in the CLI test mock and any stale terminal scrollback / + // chat transcript a user pastes. Without an entry under dist/cli-auth/, + // GH Pages returns its 404 shell and the React Navigate never runs. + const authShellRoutes = ['/login', '/login/callback', '/claim', '/cli-auth'] for (const route of authShellRoutes) { const p = resolve(DIST, route.replace(/^\//, ''), 'index.html') await mkdir(dirname(p), { recursive: true }) diff --git a/src/App.cli-auth.test.tsx b/src/App.cli-auth.test.tsx new file mode 100644 index 0000000..06635ee --- /dev/null +++ b/src/App.cli-auth.test.tsx @@ -0,0 +1,135 @@ +/* App.cli-auth.test.tsx — fix/cli-auth-route. + * + * Pins the /cli-auth redirect contract. + * + * Background: a user reported "That page is not provisioned" hitting + * https://instanode.dev/cli-auth — the api emits the canonical + * /login?cli_session=<id> path (since 0c7991c) but /cli-auth still + * appears in the CLI test mock and any old terminal scrollback / + * chat transcript a user pastes. Without a route, /cli-auth fell + * through to the catch-all NotFoundPage. + * + * The fix is App.tsx's CliAuthRedirect — a Navigate that normalizes + * /cli-auth?cli_session=<id> → /login?cli_session=<id> + * /cli-auth?s=<id> → /login?cli_session=<id> (test-mock shape) + * /cli-auth → /login (no param) + * + * These tests fail closed if a future refactor drops the param + * preservation, drops the s→cli_session rename, or routes to the + * wrong destination. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { render, cleanup } from '@testing-library/react' +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom' +import { CliAuthRedirect } from './App' + +// LocationSink — renders the current router pathname + search so the +// test can assert what CliAuthRedirect's <Navigate> landed on. We +// can't read window.location for this because MemoryRouter doesn't +// touch the global — it updates its internal context only. useLocation +// is the right truth surface. +function LocationSink() { + const loc = useLocation() + return <div data-testid="landed">{loc.pathname + loc.search}</div> +} + +// CliAuthRedirect reads window.location.search directly (so it can +// preserve the query through Navigate). MemoryRouter doesn't update +// window.location, so we stub it per test. +const realLocation = window.location + +function stubLocation(search: string) { + // jsdom's window.location is read-only as a whole, but its + // individual properties (.search) are configurable. Reassign just + // what we need. + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...realLocation, search }, + writable: true, + }) +} + +afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: realLocation, + writable: true, + }) + cleanup() +}) + +// Helper — mount CliAuthRedirect with a sink route that renders the +// landed-on pathname + search so we can assert the redirect target. +function mountAt(initialEntry: string, search: string) { + stubLocation(search) + return render( + <MemoryRouter initialEntries={[initialEntry]}> + <Routes> + <Route path="/cli-auth" element={<CliAuthRedirect />} /> + <Route path="/login" element={<LocationSink />} /> + </Routes> + </MemoryRouter>, + ) +} + +describe('CliAuthRedirect — /cli-auth defensive redirect (fix/cli-auth-route)', () => { + it('preserves ?cli_session=<id> into /login?cli_session=<id>', () => { + const { container } = mountAt('/cli-auth?cli_session=abc123', '?cli_session=abc123') + // The Navigate runs synchronously during render — by the time + // the assertion runs, the second route should be mounted. + const landed = container.querySelector('[data-testid="landed"]') + expect(landed).not.toBeNull() + expect(landed?.textContent).toContain('/login') + expect(landed?.textContent).toContain('cli_session=abc123') + }) + + it('rewrites ?s=<id> (test-mock shape) into /login?cli_session=<id>', () => { + const { container } = mountAt('/cli-auth?s=test', '?s=test') + const landed = container.querySelector('[data-testid="landed"]') + expect(landed).not.toBeNull() + expect(landed?.textContent).toContain('cli_session=test') + // The original ?s= form must NOT leak through unchanged — that + // would mean the LoginPage couldn't find the session. + expect(landed?.textContent).not.toMatch(/[?&]s=/) + }) + + it('falls back to /login (no param) when query is empty', () => { + const { container } = mountAt('/cli-auth', '') + const landed = container.querySelector('[data-testid="landed"]') + expect(landed).not.toBeNull() + // No cli_session param — bare /login. + expect(landed?.textContent).toBe('/login') + }) + + it('URL-encodes a session id containing reserved characters', () => { + // /cli-auth?cli_session=a/b%20d + // URLSearchParams.get('cli_session') decodes %20 → ' ', leaving 'a/b d'. + // encodeURIComponent then re-encodes: '/' → '%2F', ' ' → '%20'. + // Net effect: the canonical /login link is well-formed. + const { container } = mountAt( + '/cli-auth?cli_session=a/b%20d', + '?cli_session=a/b%20d', + ) + const landed = container.querySelector('[data-testid="landed"]') + expect(landed).not.toBeNull() + expect(landed?.textContent).toContain('cli_session=a%2Fb%20d') + // Unencoded '/' would break a URL parser that interprets the + // path/query boundary — fail fast if a future refactor drops the + // encodeURIComponent call. + expect(landed?.textContent).not.toMatch(/cli_session=a\/b/) + }) + + it('prefers cli_session over s when both are present', () => { + // Defensive: if a stale link somehow carries both, the canonical + // name wins. + const { container } = mountAt( + '/cli-auth?cli_session=real&s=stale', + '?cli_session=real&s=stale', + ) + const landed = container.querySelector('[data-testid="landed"]') + expect(landed).not.toBeNull() + expect(landed?.textContent).toContain('cli_session=real') + expect(landed?.textContent).not.toContain('stale') + }) +}) diff --git a/src/App.tsx b/src/App.tsx index f8a8cc0..fb940fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -208,6 +208,36 @@ function LegacyDeploymentRedirect() { return <Navigate to={`/app/deployments/${id}`} replace /> } +// CliAuthRedirect — defensive fallback for the /cli-auth path. +// +// The canonical CLI device-flow URL the api emits today is +// /login?cli_session=<id>. /cli-auth was never a real route on +// instanode.dev — but it appears as a stale URL in: +// - cli/cmd/testapi_test.go (hermetic test mock — "?s=test") +// - any old terminal scrollback / chat transcript a user pastes +// - any external docs we missed +// Until the test mock is rewritten AND every CLI binary in the wild +// has rotated, /cli-auth must not 404. We normalize ?s=<id> and +// ?cli_session=<id> to the canonical /login?cli_session=<id> path so +// the user lands on the real login form with the session preserved. +// +// Note: NO query param → still redirect to /login. The LoginPage's +// session_expired banner and OAuth start paths both work without a +// cli_session, so this is safe. +// Exported for unit testing in App.cli-auth.test.tsx — keeps the redirect +// logic verifiable without mounting the full lazy-loaded route tree. +export function CliAuthRedirect() { + if (typeof window === 'undefined') { + // SSR: emit a Navigate without a query string. The client will + // re-run and pick the query up from window.location.search. + return <Navigate to="/login" replace /> + } + const params = new URLSearchParams(window.location.search) + const session = params.get('cli_session') || params.get('s') || '' + const dest = session ? `/login?cli_session=${encodeURIComponent(session)}` : '/login' + return <Navigate to={dest} replace /> +} + // AppLoadingFallback — shown while a lazy-loaded /app/* chunk is in flight. // Tiny inline style so it renders even before the page's own CSS resolves. // In practice this fallback is on screen for ~50-150ms on a warm cache. @@ -349,6 +379,13 @@ export function AppRoutes() { <Route path="/team" element={<Navigate to="/app/team" replace />} /> <Route path="/billing" element={<Navigate to="/app/billing" replace />} /> <Route path="/settings" element={<Navigate to="/app/settings" replace />} /> + {/* /cli-auth — defensive redirect to the canonical /login?cli_session=<s>. + The api has always emitted /login?cli_session=...; /cli-auth was + never a real route. But it surfaces in the CLI test mock and in + any stale terminal scrollback / chat transcript a user pastes. + Without this route, /cli-auth fell through to the catch-all 404. + See CliAuthRedirect above for the param-preservation logic. */} + <Route path="/cli-auth" element={<CliAuthRedirect />} /> {/* B1-P1 (2026-05-20): real 404 page replaces the silent redirect-to-homepage. The same NotFoundPage is also