Skip to content

Commit 6d05222

Browse files
fix(cli-auth): provision /cli-auth route (was 404 on instanode.dev) (#129)
User reported "That page is not provisioned" when CLI device-flow emitted `https://instanode.dev/cli-auth?cli_session=…`. The route wasn't defined on the marketing site — it had to be either on the dashboard OR a redirect from instanode.dev to the dashboard. Fix: defensive redirect route `/cli-auth` → `/login?cli_session=<id>`. Details: - The api currently emits the canonical `/login?cli_session=<id>` path (cli_auth.go since commit 0c7991c, B13-P0-F1 2026-05-20). - `/cli-auth` was never a real route — but it appears in `cli/cmd/testapi_test.go` (the hermetic test mock writes `https://instanode.dev/cli-auth?s=test`) and in any stale terminal scrollback / chat transcript a user pastes. - Without a route, `/cli-auth` fell through to the catch-all NotFoundPage → 404. Implementation: - New `CliAuthRedirect` component in App.tsx normalizes both query shapes: `?cli_session=<id>` and `?s=<id>` (test-mock shape) → canonical `/login?cli_session=<id>`. Bare `/cli-auth` → `/login`. - Route registered in App.tsx alongside the legacy /resources, /vault, /team, /billing redirects. - `scripts/prerender.mjs` adds `/cli-auth` to authShellRoutes so `dist/cli-auth/index.html` is written at build time — otherwise GH Pages would 404 before the SPA shell could boot the Navigate. - `ROUTE_META['/cli-auth']` adds a sensible <title> for the brief moment before the redirect runs. - 5 unit tests in `src/App.cli-auth.test.tsx` lock in the contract: param preservation, ?s→cli_session rewrite, bare-fallback, URL encoding of reserved characters, cli_session-wins-over-s when both are present. Does NOT touch the working `/login?cli_session=...` flow that the api emits — that path remains canonical. This is purely a defensive redirect for the legacy URL shape. Verification: - `npx tsc --noEmit`: clean - `npx vitest run`: 769 passed, 3 skipped, 0 failed (full suite) - `npm run build`: SSR prerender wrote 4 auth SPA shells (was 3); `dist/cli-auth/index.html` exists with title "Signing in CLI… · instanode" Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b69b4db commit 6d05222

3 files changed

Lines changed: 186 additions & 1 deletion

File tree

scripts/prerender.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ const ROUTE_META = {
178178
description:
179179
'Claim the anonymous resources your agent provisioned and convert them to a permanent team account.',
180180
},
181+
// /cli-auth — defensive redirect to /login?cli_session=<s> (App.tsx
182+
// CliAuthRedirect). Even though the visible time on this URL is a few
183+
// ms before the Navigate runs, the SPA shell still needs a meaningful
184+
// <title> so the tab strip doesn't briefly flash the homepage title.
185+
'/cli-auth': {
186+
title: 'Signing in CLI… · instanode',
187+
description: 'Completing CLI device-flow sign-in for instanode.dev.',
188+
},
181189
// /app is the dashboard SPA entry. Visitors who type instanode.dev/app
182190
// hit this shell before AuthGate runs; a meaningful title is friendlier
183191
// than the homepage title bleeding through.
@@ -416,7 +424,12 @@ async function main() {
416424
// /login in a new tab saw the wrong title, breaking WCAG 2.4.2 and
417425
// confusing tab-strip navigation. metaForRoute() returns sensible
418426
// titles for /login, /login/callback, and /claim from ROUTE_META.
419-
const authShellRoutes = ['/login', '/login/callback', '/claim']
427+
// /cli-auth — defensive redirect emitted by App.tsx's CliAuthRedirect.
428+
// The api emits the canonical /login?cli_session=<id>, but /cli-auth
429+
// appears in the CLI test mock and any stale terminal scrollback /
430+
// chat transcript a user pastes. Without an entry under dist/cli-auth/,
431+
// GH Pages returns its 404 shell and the React Navigate never runs.
432+
const authShellRoutes = ['/login', '/login/callback', '/claim', '/cli-auth']
420433
for (const route of authShellRoutes) {
421434
const p = resolve(DIST, route.replace(/^\//, ''), 'index.html')
422435
await mkdir(dirname(p), { recursive: true })

src/App.cli-auth.test.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/* App.cli-auth.test.tsx — fix/cli-auth-route.
2+
*
3+
* Pins the /cli-auth redirect contract.
4+
*
5+
* Background: a user reported "That page is not provisioned" hitting
6+
* https://instanode.dev/cli-auth — the api emits the canonical
7+
* /login?cli_session=<id> path (since 0c7991c) but /cli-auth still
8+
* appears in the CLI test mock and any old terminal scrollback /
9+
* chat transcript a user pastes. Without a route, /cli-auth fell
10+
* through to the catch-all NotFoundPage.
11+
*
12+
* The fix is App.tsx's CliAuthRedirect — a Navigate that normalizes
13+
* /cli-auth?cli_session=<id> → /login?cli_session=<id>
14+
* /cli-auth?s=<id> → /login?cli_session=<id> (test-mock shape)
15+
* /cli-auth → /login (no param)
16+
*
17+
* These tests fail closed if a future refactor drops the param
18+
* preservation, drops the s→cli_session rename, or routes to the
19+
* wrong destination.
20+
*/
21+
22+
import { describe, it, expect, afterEach } from 'vitest'
23+
import { render, cleanup } from '@testing-library/react'
24+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'
25+
import { CliAuthRedirect } from './App'
26+
27+
// LocationSink — renders the current router pathname + search so the
28+
// test can assert what CliAuthRedirect's <Navigate> landed on. We
29+
// can't read window.location for this because MemoryRouter doesn't
30+
// touch the global — it updates its internal context only. useLocation
31+
// is the right truth surface.
32+
function LocationSink() {
33+
const loc = useLocation()
34+
return <div data-testid="landed">{loc.pathname + loc.search}</div>
35+
}
36+
37+
// CliAuthRedirect reads window.location.search directly (so it can
38+
// preserve the query through Navigate). MemoryRouter doesn't update
39+
// window.location, so we stub it per test.
40+
const realLocation = window.location
41+
42+
function stubLocation(search: string) {
43+
// jsdom's window.location is read-only as a whole, but its
44+
// individual properties (.search) are configurable. Reassign just
45+
// what we need.
46+
Object.defineProperty(window, 'location', {
47+
configurable: true,
48+
value: { ...realLocation, search },
49+
writable: true,
50+
})
51+
}
52+
53+
afterEach(() => {
54+
Object.defineProperty(window, 'location', {
55+
configurable: true,
56+
value: realLocation,
57+
writable: true,
58+
})
59+
cleanup()
60+
})
61+
62+
// Helper — mount CliAuthRedirect with a sink route that renders the
63+
// landed-on pathname + search so we can assert the redirect target.
64+
function mountAt(initialEntry: string, search: string) {
65+
stubLocation(search)
66+
return render(
67+
<MemoryRouter initialEntries={[initialEntry]}>
68+
<Routes>
69+
<Route path="/cli-auth" element={<CliAuthRedirect />} />
70+
<Route path="/login" element={<LocationSink />} />
71+
</Routes>
72+
</MemoryRouter>,
73+
)
74+
}
75+
76+
describe('CliAuthRedirect — /cli-auth defensive redirect (fix/cli-auth-route)', () => {
77+
it('preserves ?cli_session=<id> into /login?cli_session=<id>', () => {
78+
const { container } = mountAt('/cli-auth?cli_session=abc123', '?cli_session=abc123')
79+
// The Navigate runs synchronously during render — by the time
80+
// the assertion runs, the second route should be mounted.
81+
const landed = container.querySelector('[data-testid="landed"]')
82+
expect(landed).not.toBeNull()
83+
expect(landed?.textContent).toContain('/login')
84+
expect(landed?.textContent).toContain('cli_session=abc123')
85+
})
86+
87+
it('rewrites ?s=<id> (test-mock shape) into /login?cli_session=<id>', () => {
88+
const { container } = mountAt('/cli-auth?s=test', '?s=test')
89+
const landed = container.querySelector('[data-testid="landed"]')
90+
expect(landed).not.toBeNull()
91+
expect(landed?.textContent).toContain('cli_session=test')
92+
// The original ?s= form must NOT leak through unchanged — that
93+
// would mean the LoginPage couldn't find the session.
94+
expect(landed?.textContent).not.toMatch(/[?&]s=/)
95+
})
96+
97+
it('falls back to /login (no param) when query is empty', () => {
98+
const { container } = mountAt('/cli-auth', '')
99+
const landed = container.querySelector('[data-testid="landed"]')
100+
expect(landed).not.toBeNull()
101+
// No cli_session param — bare /login.
102+
expect(landed?.textContent).toBe('/login')
103+
})
104+
105+
it('URL-encodes a session id containing reserved characters', () => {
106+
// /cli-auth?cli_session=a/b%20d
107+
// URLSearchParams.get('cli_session') decodes %20 → ' ', leaving 'a/b d'.
108+
// encodeURIComponent then re-encodes: '/' → '%2F', ' ' → '%20'.
109+
// Net effect: the canonical /login link is well-formed.
110+
const { container } = mountAt(
111+
'/cli-auth?cli_session=a/b%20d',
112+
'?cli_session=a/b%20d',
113+
)
114+
const landed = container.querySelector('[data-testid="landed"]')
115+
expect(landed).not.toBeNull()
116+
expect(landed?.textContent).toContain('cli_session=a%2Fb%20d')
117+
// Unencoded '/' would break a URL parser that interprets the
118+
// path/query boundary — fail fast if a future refactor drops the
119+
// encodeURIComponent call.
120+
expect(landed?.textContent).not.toMatch(/cli_session=a\/b/)
121+
})
122+
123+
it('prefers cli_session over s when both are present', () => {
124+
// Defensive: if a stale link somehow carries both, the canonical
125+
// name wins.
126+
const { container } = mountAt(
127+
'/cli-auth?cli_session=real&s=stale',
128+
'?cli_session=real&s=stale',
129+
)
130+
const landed = container.querySelector('[data-testid="landed"]')
131+
expect(landed).not.toBeNull()
132+
expect(landed?.textContent).toContain('cli_session=real')
133+
expect(landed?.textContent).not.toContain('stale')
134+
})
135+
})

src/App.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,36 @@ function LegacyDeploymentRedirect() {
208208
return <Navigate to={`/app/deployments/${id}`} replace />
209209
}
210210

211+
// CliAuthRedirect — defensive fallback for the /cli-auth path.
212+
//
213+
// The canonical CLI device-flow URL the api emits today is
214+
// /login?cli_session=<id>. /cli-auth was never a real route on
215+
// instanode.dev — but it appears as a stale URL in:
216+
// - cli/cmd/testapi_test.go (hermetic test mock — "?s=test")
217+
// - any old terminal scrollback / chat transcript a user pastes
218+
// - any external docs we missed
219+
// Until the test mock is rewritten AND every CLI binary in the wild
220+
// has rotated, /cli-auth must not 404. We normalize ?s=<id> and
221+
// ?cli_session=<id> to the canonical /login?cli_session=<id> path so
222+
// the user lands on the real login form with the session preserved.
223+
//
224+
// Note: NO query param → still redirect to /login. The LoginPage's
225+
// session_expired banner and OAuth start paths both work without a
226+
// cli_session, so this is safe.
227+
// Exported for unit testing in App.cli-auth.test.tsx — keeps the redirect
228+
// logic verifiable without mounting the full lazy-loaded route tree.
229+
export function CliAuthRedirect() {
230+
if (typeof window === 'undefined') {
231+
// SSR: emit a Navigate without a query string. The client will
232+
// re-run and pick the query up from window.location.search.
233+
return <Navigate to="/login" replace />
234+
}
235+
const params = new URLSearchParams(window.location.search)
236+
const session = params.get('cli_session') || params.get('s') || ''
237+
const dest = session ? `/login?cli_session=${encodeURIComponent(session)}` : '/login'
238+
return <Navigate to={dest} replace />
239+
}
240+
211241
// AppLoadingFallback — shown while a lazy-loaded /app/* chunk is in flight.
212242
// Tiny inline style so it renders even before the page's own CSS resolves.
213243
// In practice this fallback is on screen for ~50-150ms on a warm cache.
@@ -349,6 +379,13 @@ export function AppRoutes() {
349379
<Route path="/team" element={<Navigate to="/app/team" replace />} />
350380
<Route path="/billing" element={<Navigate to="/app/billing" replace />} />
351381
<Route path="/settings" element={<Navigate to="/app/settings" replace />} />
382+
{/* /cli-auth — defensive redirect to the canonical /login?cli_session=<s>.
383+
The api has always emitted /login?cli_session=...; /cli-auth was
384+
never a real route. But it surfaces in the CLI test mock and in
385+
any stale terminal scrollback / chat transcript a user pastes.
386+
Without this route, /cli-auth fell through to the catch-all 404.
387+
See CliAuthRedirect above for the param-preservation logic. */}
388+
<Route path="/cli-auth" element={<CliAuthRedirect />} />
352389

353390
{/* B1-P1 (2026-05-20): real 404 page replaces the silent
354391
redirect-to-homepage. The same NotFoundPage is also

0 commit comments

Comments
 (0)