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
15 changes: 14 additions & 1 deletion scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<s> (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
// <title> 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.
Expand Down Expand Up @@ -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 })
Expand Down
135 changes: 135 additions & 0 deletions src/App.cli-auth.test.tsx
Original file line number Diff line number Diff line change
@@ -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')
})
})
37 changes: 37 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading