From 4452aee919c39cbbc41ab4dd875138b29ca3f104 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 21:45:55 -0400 Subject: [PATCH 1/6] refactor(levelcode): the OAuth start link and the verify body are built in one testable module oauthStartUrl moves from ProviderSignIn.tsx to signInRequests.ts unchanged, apart from taking the API base and the attribution as arguments instead of reading them itself; the verify body EmailSignIn posts is built there too. Nothing they send changes. `npm test` is new: node's own test runner over src/**/*.test.ts, with Node 24's type stripping, so no dependency is added. signInRequests.test.ts pins what the two requests carry today: the redirect_uri + code_challenge pair (both or neither on the OAuth link), the attribution allowlist without the referrer, the API base. Six one-edit mutations of the module, each caught. --- package.json | 1 + src/levelcode/pages/EmailSignIn.tsx | 5 +- src/levelcode/pages/ProviderSignIn.tsx | 43 ++------------ src/levelcode/signInRequests.test.ts | 73 ++++++++++++++++++++++++ src/levelcode/signInRequests.ts | 78 ++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 src/levelcode/signInRequests.test.ts create mode 100644 src/levelcode/signInRequests.ts diff --git a/package.json b/package.json index 84430d3..c83f57d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prebuild": "npm run gen:llms", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "node --test \"src/**/*.test.ts\"", "preview": "vite preview", "sync:thinly": "node scripts/sync-to-thinly.mjs", "build:thinly": "npm run build && npm run sync:thinly" diff --git a/src/levelcode/pages/EmailSignIn.tsx b/src/levelcode/pages/EmailSignIn.tsx index bf08f75..32d4726 100644 --- a/src/levelcode/pages/EmailSignIn.tsx +++ b/src/levelcode/pages/EmailSignIn.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { api, ApiError, navigateTo } from "../api"; import { getAttribution } from "../attribution"; +import { verifyBody } from "../signInRequests"; // Passwordless email + one-time-code sign-in (OpenRouter/Cursor style): // 1. enter email → POST /ai/auth/email (Rails emails a 6-digit code) @@ -40,9 +41,7 @@ export default function EmailSignIn({ setError(null); try { const d = await api<{ redirect?: string }>("/ai/auth/verify", { - // attribution: first-touch campaign channel (null for organic sign-ins) so the backend can record - // which channel acquired a new account. Harmless if the backend doesn't consume it yet. - body: { email, code, redirect_uri: redirectUri, code_challenge: codeChallenge, attribution: getAttribution() }, + body: verifyBody(email, code, { redirectUri, codeChallenge }, getAttribution()), }); if (!d.redirect) throw new ApiError(422, "That code is invalid or has expired."); diff --git a/src/levelcode/pages/ProviderSignIn.tsx b/src/levelcode/pages/ProviderSignIn.tsx index 35d0c25..5d5e8bd 100644 --- a/src/levelcode/pages/ProviderSignIn.tsx +++ b/src/levelcode/pages/ProviderSignIn.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { API_BASE } from "../api"; import { getAttribution } from "../attribution"; +import { oauthStartUrl } from "../signInRequests"; // Social sign-in for the LevelCode Cloud login: Google + GitHub. // @@ -10,8 +11,8 @@ import { getAttribution } from "../attribution"; // to open in a new tab, works without JS. // // The editor's redirect_uri + code_challenge are a PKCE PAIR, forwarded only when BOTH are present (see -// oauthStartUrl) so a partial handoff never reaches the backend. Both providers share the exact same -// oauth_start/oauth_callback path — the callback dispatches on the stashed provider. +// oauthStartUrl in signInRequests.ts) so a partial handoff never reaches the backend. Both providers share +// the exact same oauth_start/oauth_callback path — the callback dispatches on the stashed provider. export default function ProviderSignIn({ redirectUri, codeChallenge, @@ -46,7 +47,7 @@ function ProviderLink({ }) { return ( {children} @@ -55,42 +56,6 @@ function ProviderLink({ ); } -function oauthStartUrl(provider: string, redirectUri?: string, codeChallenge?: string): string { - const qs = new URLSearchParams(); - // Both or neither — a lone redirect_uri has no PKCE binding and must not be forwarded (defensive; the - // login page already pairs them, but this component shouldn't trust its caller to). - if (redirectUri && codeChallenge) { - qs.set("redirect_uri", redirectUri); - qs.set("code_challenge", codeChallenge); - } - // First-touch campaign attribution. The email flow posts this in the verify body; OAuth is a top-level - // navigation with no body, so it rides the query string — oauth_start moves it straight into the session - // (like the CSRF `state`), and oauth_callback stamps it on the user IF this sign-in creates one. Without - // it every GitHub/Google signup is attributed to nothing, which quietly undercounts whichever channel - // sends the most developers. - // - // Only the fields the funnel actually uses are carried, as an explicit allowlist rather than the whole - // record. A query string ends up in browser history and server access logs, and `referrer` is the one - // attribution field that can itself be a third-party URL carrying its own query params — so it stays out - // of the URL entirely. Listing the fields (instead of deleting one) also means a field added to - // Attribution later cannot silently start appearing in logs. - const attribution = getAttribution(); - if (attribution) { - qs.set( - "attribution", - JSON.stringify({ - source: attribution.source, - params: attribution.params, - landing: attribution.landing, - ts: attribution.ts, - }), - ); - } - - const q = qs.toString(); - return `${API_BASE}/ai/auth/oauth/${provider}${q ? `?${q}` : ""}`; -} - function GoogleG() { return ( diff --git a/src/levelcode/pages/ProviderSignIn.tsx b/src/levelcode/pages/ProviderSignIn.tsx index 5d5e8bd..849c46b 100644 --- a/src/levelcode/pages/ProviderSignIn.tsx +++ b/src/levelcode/pages/ProviderSignIn.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { API_BASE } from "../api"; import { getAttribution } from "../attribution"; -import { oauthStartUrl } from "../signInRequests"; +import { oauthStartUrl, type SignInContext } from "../signInRequests"; // Social sign-in for the LevelCode Cloud login: Google + GitHub. // @@ -13,19 +13,14 @@ import { oauthStartUrl } from "../signInRequests"; // The editor's redirect_uri + code_challenge are a PKCE PAIR, forwarded only when BOTH are present (see // oauthStartUrl in signInRequests.ts) so a partial handoff never reaches the backend. Both providers share // the exact same oauth_start/oauth_callback path — the callback dispatches on the stashed provider. -export default function ProviderSignIn({ - redirectUri, - codeChallenge, -}: { - redirectUri?: string; - codeChallenge?: string; -}) { +// `next`, the page the visitor asked for, rides the link only on a web sign-in and only when valid. +export default function ProviderSignIn(context: SignInContext) { return (
- + - +
@@ -35,19 +30,17 @@ export default function ProviderSignIn({ function ProviderLink({ provider, label, - redirectUri, - codeChallenge, + context, children, }: { provider: string; label: string; - redirectUri?: string; - codeChallenge?: string; + context: SignInContext; children: ReactNode; }) { return (
{children} diff --git a/src/levelcode/signInRequests.test.ts b/src/levelcode/signInRequests.test.ts index 4eaaac9..e70511a 100644 --- a/src/levelcode/signInRequests.test.ts +++ b/src/levelcode/signInRequests.test.ts @@ -52,6 +52,29 @@ describe("oauthStartUrl", () => { }); assert.equal(oauthStartUrl("", "github", {}, ATTRIBUTION).includes("linkedin.example"), false); }); + + test("a valid next rides a web sign-in", () => { + const q = query(oauthStartUrl("", "github", { next: "/ai/pricing?plan=pro" }, null)); + assert.equal(q.get("next"), "/ai/pricing?plan=pro"); + }); + + test("a next that is not valid is left out", () => { + for (const next of ["//evil.example", "/ai//x", "https://evil.example/ai/", "/ai/%2F%2Fevil", "/other/path", ""]) { + assert.equal(query(oauthStartUrl("", "github", { next }, null)).has("next"), false, next); + } + }); + + test("an editor signing in never carries next", () => { + const q = query(oauthStartUrl("", "github", { ...EDITOR, next: "/ai/account" }, null)); + assert.equal(q.has("next"), false); + assert.equal(q.get("redirect_uri"), EDITOR.redirectUri); + }); + + test("half an editor pair is a web sign-in, so next rides it", () => { + const q = query(oauthStartUrl("", "github", { redirectUri: EDITOR.redirectUri, next: "/ai/account" }, null)); + assert.equal(q.get("next"), "/ai/account"); + assert.equal(q.has("redirect_uri"), false); + }); }); describe("verifyBody", () => { @@ -70,4 +93,18 @@ describe("verifyBody", () => { assert.equal(body.code_challenge, EDITOR.codeChallenge); assert.equal(body.attribution, null); }); + + test("a valid next is sent on a web sign-in", () => { + assert.equal(verifyBody("a@example.test", "123456", { next: "/ai/account" }, null).next, "/ai/account"); + }); + + test("a next that is not valid is left out", () => { + for (const next of ["//evil.example", "/ai//x", "javascript:alert(1)", "/ai/%2F%2Fevil", "/ai/account#x", null, undefined]) { + assert.equal("next" in verifyBody("a@example.test", "123456", { next }, null), false, String(next)); + } + }); + + test("an editor signing in never sends next", () => { + assert.equal("next" in verifyBody("a@example.test", "123456", { ...EDITOR, next: "/ai/account" }, null), false); + }); }); diff --git a/src/levelcode/signInRequests.ts b/src/levelcode/signInRequests.ts index 82a89ab..7853a53 100644 --- a/src/levelcode/signInRequests.ts +++ b/src/levelcode/signInRequests.ts @@ -1,4 +1,6 @@ import type { Attribution } from "./attribution"; +// With its extension so `node --test` can load this file as it is (signInRequests.test.ts). +import { safeNext } from "./next.ts"; // The two requests that finish a sign-in, built in one place so what they carry can be tested: // the OAuth start link (ProviderSignIn) and the email-code verify body (EmailSignIn). @@ -9,8 +11,19 @@ export type SignInContext = { /** The editor's callback; meaningful only together with its code_challenge (a PKCE pair). */ redirectUri?: string; codeChallenge?: string; + /** The page to land on afterwards (see next.ts). Sent only when valid, and never with an editor sign-in. */ + next?: string | null; }; +/** + * `next` as a request should carry it: only on a web sign-in (an editor signing in wins, and the backend + * ignores `next` then), and only a valid one. + */ +function nextFor({ redirectUri, codeChallenge, next }: SignInContext): string | null { + if (redirectUri && codeChallenge) return null; + return safeNext(next); +} + /** * GET /ai/auth/oauth/:provider, the link each provider button is. * @@ -30,6 +43,10 @@ export function oauthStartUrl( qs.set("redirect_uri", redirectUri); qs.set("code_challenge", codeChallenge); } + // The page to land on after a web sign-in; oauth_start keeps it in the session across the provider + // round-trip, like the CSRF `state`. + const next = nextFor(context); + if (next) qs.set("next", next); // First-touch campaign attribution. The email flow posts this in the verify body; OAuth is a top-level // navigation with no body, so it rides the query string — oauth_start moves it straight into the session // (like the CSRF `state`), and oauth_callback stamps it on the user IF this sign-in creates one. Without @@ -59,8 +76,9 @@ export function oauthStartUrl( /** * The body of POST /ai/auth/verify. redirect_uri and code_challenge go as the login page passed them - * (it pairs them). `attribution` is the first-touch campaign channel (null for organic sign-ins) so the - * backend can record which channel acquired a new account; harmless if the backend doesn't consume it. + * (it pairs them); `next` only on a web sign-in, and only when valid. `attribution` is the first-touch + * campaign channel (null for organic sign-ins) so the backend can record which channel acquired a new + * account; harmless if the backend doesn't consume it. */ export function verifyBody( email: string, @@ -68,11 +86,14 @@ export function verifyBody( context: SignInContext, attribution: Attribution | null, ): Record { - return { + const body: Record = { email, code, redirect_uri: context.redirectUri, code_challenge: context.codeChallenge, attribution, }; + const next = nextFor(context); + if (next) body.next = next; + return body; } From 073d295f388db3df098710bc2459b67913cf789a Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 21:50:39 -0400 Subject: [PATCH 3/6] feat(levelcode): a signed-in visit to the login page goes to next, else the browser edition, else the account An already signed-in visitor with no editor signing in used to be sent to /account. Now, in the order a sign-in lands: a valid `next` (a route under /ai, at once); else, when useWebEditor says the browser edition is on, its address (`?signin=1`), as a full-page navigation that replaces the login page so Back does not bounce through it; else /account. While the edition's answer is loading the existing blank frame stays, so neither the form nor the account page flashes first. With the edition off (production today) this is /account, as before. An editor signing in is unchanged; the code it mints is still minted once (the effect sees plain values that do not change in that flow). Copy: with no `next` and the edition on, the line under "Sign in to LevelCode" says "LevelCode opens in this tab when you're done." instead of "Manage your plan, keys, and usage." (still shown with a `next` or the edition off). For that line the form waits for the edition's answer too, but only on a plain sign-in; useWebEditor gives up after 10 s. The decision is signedInDestination in next.ts, tested: an editor wins over everything, `next` over the edition and without waiting for it, wait while loading, the edition's address as given, enabled with no address counts as off, an invalid `next` counts as absent. Eight one-edit mutations, each caught. --- src/levelcode/next.test.ts | 66 ++++++++++++++++++++++++++++++- src/levelcode/next.ts | 36 +++++++++++++++++ src/levelcode/pages/LoginPage.tsx | 30 ++++++++++---- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/levelcode/next.test.ts b/src/levelcode/next.test.ts index 91fb437..a0d324b 100644 --- a/src/levelcode/next.test.ts +++ b/src/levelcode/next.test.ts @@ -1,7 +1,7 @@ // node --test (npm test). `next`: which pages a sign-in may land on, and which it ignores. import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { NEXT_MAX, safeNext } from "./next.ts"; +import { NEXT_MAX, safeNext, signedInDestination, type WebEditorAnswer } from "./next.ts"; describe("safeNext", () => { const accepted: Array<[string, string]> = [ @@ -108,3 +108,67 @@ describe("safeNext", () => { }); } }); + +describe("signedInDestination", () => { + const EDITOR_URL = "https://app.levelcode.example/?signin=1"; + const ON: WebEditorAnswer = { enabled: true, url: EDITOR_URL, loading: false }; + const OFF: WebEditorAnswer = { enabled: false, url: null, loading: false }; + const LOADING: WebEditorAnswer = { enabled: false, url: null, loading: true }; + + test("an editor signing in wins over next and the browser edition", () => { + for (const webEditor of [ON, OFF, LOADING]) { + for (const next of [null, "/ai/pricing"]) { + assert.deepEqual(signedInDestination({ editorSigningIn: true, next, webEditor }), { kind: "editor" }); + } + } + }); + + test("next wins over the browser edition, as a route under the /ai basename", () => { + for (const webEditor of [ON, OFF]) { + assert.deepEqual(signedInDestination({ editorSigningIn: false, next: "/ai/pricing?plan=pro", webEditor }), { + kind: "route", + to: "/pricing?plan=pro", + }); + } + }); + + test("next does not wait for the browser edition's answer", () => { + assert.deepEqual(signedInDestination({ editorSigningIn: false, next: "/ai/account", webEditor: LOADING }), { + kind: "route", + to: "/account", + }); + }); + + test("with no next, it waits for the browser edition's answer", () => { + assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor: LOADING }), { kind: "wait" }); + }); + + test("with no next and the browser edition on, it is the editor's address as given", () => { + assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor: ON }), { + kind: "href", + to: EDITOR_URL, + }); + }); + + test("with no next and the browser edition off, it is the account page", () => { + assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor: OFF }), { + kind: "route", + to: "/account", + }); + }); + + test("enabled with no address counts as off", () => { + const webEditor = { enabled: true, url: null, loading: false }; + assert.deepEqual(signedInDestination({ editorSigningIn: false, next: null, webEditor }), { + kind: "route", + to: "/account", + }); + }); + + test("a next that is not valid counts as absent", () => { + for (const next of ["//evil.example", "https://evil.example/", "/ai/%2F%2Fevil", "/other"]) { + assert.deepEqual(signedInDestination({ editorSigningIn: false, next, webEditor: ON }), { kind: "href", to: EDITOR_URL }, next); + assert.deepEqual(signedInDestination({ editorSigningIn: false, next, webEditor: OFF }), { kind: "route", to: "/account" }, next); + } + }); +}); diff --git a/src/levelcode/next.ts b/src/levelcode/next.ts index 629c56a..5a6fe14 100644 --- a/src/levelcode/next.ts +++ b/src/levelcode/next.ts @@ -66,3 +66,39 @@ function isDotSegment(segment: string): boolean { const s = segment.replace(/%2e/gi, "."); return s === "." || s === ".."; } + +export type WebEditorAnswer = { enabled: boolean; url: string | null; loading: boolean }; + +export type SignedInDestination = + /** An editor is signing in: mint it a code (the login page's existing flow). */ + | { kind: "editor" } + /** Not known yet: the browser edition's answer is still loading. */ + | { kind: "wait" } + /** A page of this app, as a route relative to the /ai basename. */ + | { kind: "route"; to: string } + /** The browser edition, on its own origin: a full-page navigation. */ + | { kind: "href"; to: string }; + +/** + * Where the login page sends a visitor who is ALREADY signed in, in the same order as a sign-in + * lands (see the top of this file). `webEditor` is useWebEditor's answer, which has already refused + * any address it would not link to. + */ +export function signedInDestination({ + editorSigningIn, + next, + webEditor, +}: { + editorSigningIn: boolean; + next: string | null; + webEditor: WebEditorAnswer; +}): SignedInDestination { + if (editorSigningIn) return { kind: "editor" }; + + const page = safeNext(next); + if (page) return { kind: "route", to: page.slice(PREFIX.length - 1) }; + + if (webEditor.loading) return { kind: "wait" }; + if (webEditor.enabled && webEditor.url) return { kind: "href", to: webEditor.url }; + return { kind: "route", to: "/account" }; +} diff --git a/src/levelcode/pages/LoginPage.tsx b/src/levelcode/pages/LoginPage.tsx index 9c59de4..619d3e5 100644 --- a/src/levelcode/pages/LoginPage.tsx +++ b/src/levelcode/pages/LoginPage.tsx @@ -3,7 +3,8 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { api, navigateTo } from "../api"; import { useSession } from "../auth"; import ClassicShell, { ChevronMark } from "../components/classic/ClassicShell"; -import { safeNext } from "../next"; +import { safeNext, signedInDestination } from "../next"; +import { useWebEditor } from "../useWebEditor"; import EmailSignIn from "./EmailSignIn"; import ProviderSignIn from "./ProviderSignIn"; @@ -33,9 +34,16 @@ export default function LoginPage() { const next = safeNext(params.get("next")); const { loading: sessionLoading, profile } = useSession(); + const webEditor = useWebEditor(); // idle: probing session / about to redirect · completing: minting an editor code · form: show sign-in. const [phase, setPhase] = useState<"idle" | "completing" | "form">("idle"); + // Where a visitor who is already signed in goes (see next.ts). Taken apart into plain values for the + // effect below: while an editor is signing in they never change, so the code it mints is minted once. + const destination = signedInDestination({ editorSigningIn: fromEditor, next, webEditor }); + const destinationKind = destination.kind; + const destinationTo = "to" in destination ? destination.to : null; + useEffect(() => { if (sessionLoading) return; @@ -67,9 +75,13 @@ export default function LoginPage() { }; } - // Signed in on a plain web visit → the login page is pointless; go straight to the dashboard. - navigate("/account", { replace: true }); - }, [sessionLoading, profile, redirectUri, codeChallenge, navigate]); + // Signed in on a plain web visit → the login page is pointless. Go to the page they asked for, else to + // the browser edition when it is on, else to the dashboard. The browser edition is another origin: a + // full-page navigation that replaces this page, so Back does not land here and bounce again. Until its + // answer is in ("wait"), the blank frame stays. + if (destinationKind === "route" && destinationTo) navigate(destinationTo, { replace: true }); + else if (destinationKind === "href" && destinationTo) window.location.replace(destinationTo); + }, [sessionLoading, profile, redirectUri, codeChallenge, destinationKind, destinationTo, navigate]); if (phase === "completing") { return ( @@ -89,8 +101,10 @@ export default function LoginPage() { } // Idle — probing the session, or redirecting an already-signed-in user — keep a blank frame so the - // sign-in form never flashes before the redirect. - if (phase !== "form") { + // sign-in form never flashes before the redirect. The form also waits for the browser edition's answer + // when the line under its heading depends on it (no editor signing in, no `next`). + const landingKnown = fromEditor || !!next || !webEditor.loading; + if (phase !== "form" || !landingKnown) { return (
@@ -112,7 +126,9 @@ export default function LoginPage() {

{fromEditor ? "Connect the editor to your account — you'll be sent back to LevelCode when you're done." - : "Manage your plan, keys, and usage."} + : !next && webEditor.enabled + ? "LevelCode opens in this tab when you're done." + : "Manage your plan, keys, and usage."}

From 7e9df613f60fd5a16ed8330757e7fb823a88439b Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 21:51:56 -0400 Subject: [PATCH 4/6] feat(levelcode): a page that sends a signed-out visitor to sign in names itself in `next` Now that a plain sign-in may land in the browser edition, a visitor who was on their way somewhere must be brought back there: - /account, /admin and /admin/referrals, signed out: /login?next=. - Pricing, "Sign in to start" while signed out: /login?next=/ai/pricing. The visitor lands back on the plans and presses the button again; nothing is bought or changed on the way back. A plain "Sign in" carries no `next`, so it lands where any sign-in does (the editor when the browser edition is on, else the account): the classic nav, the download page's nav, the landing's "Sign in" on devices that cannot run the Mac app, and the sign-in page shown after "Sign out". loginRoute(page) in next.ts builds the route and drops a page that is not a valid `next`; currentPage() is the address bar's path and query (the app always runs under /ai). Tested: no `next` for a plain sign-in, a page round-trips through the login page's query exactly, an invalid one is dropped. Also a comment on the landing that said signing in never gets anyone the editor, which is no longer so when the browser edition is on. --- src/levelcode/next.test.ts | 26 +++++++++++++++++++++++++- src/levelcode/next.ts | 15 +++++++++++++++ src/levelcode/pages/AccountPage.tsx | 8 +++++--- src/levelcode/pages/AdminPage.tsx | 3 ++- src/levelcode/pages/LandingPage.tsx | 2 +- src/levelcode/pages/PricingCards.tsx | 5 ++++- src/levelcode/pages/ReferralsPage.tsx | 3 ++- 7 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/levelcode/next.test.ts b/src/levelcode/next.test.ts index a0d324b..e0b32a4 100644 --- a/src/levelcode/next.test.ts +++ b/src/levelcode/next.test.ts @@ -1,7 +1,7 @@ // node --test (npm test). `next`: which pages a sign-in may land on, and which it ignores. import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { NEXT_MAX, safeNext, signedInDestination, type WebEditorAnswer } from "./next.ts"; +import { loginRoute, NEXT_MAX, safeNext, signedInDestination, type WebEditorAnswer } from "./next.ts"; describe("safeNext", () => { const accepted: Array<[string, string]> = [ @@ -109,6 +109,30 @@ describe("safeNext", () => { } }); +describe("loginRoute", () => { + /** The `next` the login page will read from a route loginRoute built. */ + const nextOf = (route: string) => new URL(route, "https://levelcode.ai/ai/").searchParams.get("next"); + + test("a plain sign-in carries no next", () => { + assert.equal(loginRoute(), "/login"); + assert.equal(loginRoute(null), "/login"); + }); + + test("a page comes back to the login page exactly as it went in", () => { + for (const page of ["/ai/account", "/ai/pricing?plan=pro", "/ai/admin/referrals", "/ai/account?checkout=changed&x=1"]) { + const route = loginRoute(page); + assert.ok(route.startsWith("/login?next="), route); + assert.equal(nextOf(route), page); + } + }); + + test("a page that is not a valid next is dropped", () => { + for (const page of ["/ai/", "/ai//x", "/ai/%2F%2Fevil", "/ai/account#billing", "/web", ""]) { + assert.equal(loginRoute(page), "/login", page); + } + }); +}); + describe("signedInDestination", () => { const EDITOR_URL = "https://app.levelcode.example/?signin=1"; const ON: WebEditorAnswer = { enabled: true, url: EDITOR_URL, loading: false }; diff --git a/src/levelcode/next.ts b/src/levelcode/next.ts index 5a6fe14..1917f6f 100644 --- a/src/levelcode/next.ts +++ b/src/levelcode/next.ts @@ -67,6 +67,21 @@ function isDotSegment(segment: string): boolean { return s === "." || s === ".."; } +/** The page the visitor is on now, as a `next` would name it (`/ai/account?checkout=changed`). */ +export function currentPage(): string { + return window.location.pathname + window.location.search; +} + +/** + * The sign-in page's route (relative to the router's /ai basename), bringing the visitor back to + * `page` afterwards. A `page` that is not a valid `next` is dropped, and the visitor lands wherever + * any sign-in does. Leave `page` out for a plain "Sign in". + */ +export function loginRoute(page?: string | null): string { + const next = safeNext(page); + return next ? `/login?${new URLSearchParams({ next })}` : "/login"; +} + export type WebEditorAnswer = { enabled: boolean; url: string | null; loading: boolean }; export type SignedInDestination = diff --git a/src/levelcode/pages/AccountPage.tsx b/src/levelcode/pages/AccountPage.tsx index 4b52d96..e0e25fe 100644 --- a/src/levelcode/pages/AccountPage.tsx +++ b/src/levelcode/pages/AccountPage.tsx @@ -5,10 +5,12 @@ import { useSession } from "../auth"; import ClassicShell from "../components/classic/ClassicShell"; import ContributionsHeatmap from "./ContributionsHeatmap"; import { fmtCredits, fmtCreditAmount, toCredits, LOCALE } from "../credits"; +import { currentPage, loginRoute } from "../next"; import { useWebEditor } from "../useWebEditor"; // Usage dashboard (SPEC §8 / §3 GET /account/usage). Session-gated: redirects to -// /ai/login when the Devise session probe comes back empty. +// /ai/login when the Devise session probe comes back empty, with `next` naming this +// page so the sign-in comes back here. export default function AccountPage() { const navigate = useNavigate(); const { loading: sessionLoading, profile } = useSession(); @@ -20,7 +22,7 @@ export default function AccountPage() { const [error, setError] = useState(null); useEffect(() => { - if (!sessionLoading && !profile) navigate("/login", { replace: true }); + if (!sessionLoading && !profile) navigate(loginRoute(currentPage()), { replace: true }); }, [sessionLoading, profile, navigate]); useEffect(() => { @@ -85,7 +87,7 @@ export default function AccountPage() { } catch { /* ignore — clear locally regardless */ } - navigate("/login", { replace: true }); + navigate("/login", { replace: true }); // a plain sign-in page: no `next` back to the account just left } // While the session resolves (or is absent, pre-redirect), keep the frame. diff --git a/src/levelcode/pages/AdminPage.tsx b/src/levelcode/pages/AdminPage.tsx index dac0d40..38773af 100644 --- a/src/levelcode/pages/AdminPage.tsx +++ b/src/levelcode/pages/AdminPage.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from "react-router-dom"; import { api, type AdminSummary, type AdminUsers } from "../api"; import { useSession } from "../auth"; import ClassicShell from "../components/classic/ClassicShell"; +import { currentPage, loginRoute } from "../next"; type SortKey = "input" | "output" | "requests" | "cost" | "auth_attempts" | "auth_failures" | "last_seen" | "email" | "plan"; @@ -25,7 +26,7 @@ export default function AdminPage() { // Non-admins (or signed-out) never see this surface. useEffect(() => { if (sessionLoading) return; - if (!profile) navigate("/login", { replace: true }); + if (!profile) navigate(loginRoute(currentPage()), { replace: true }); else if (!isAdmin) navigate("/account", { replace: true }); }, [sessionLoading, profile, isAdmin, navigate]); diff --git a/src/levelcode/pages/LandingPage.tsx b/src/levelcode/pages/LandingPage.tsx index 12cac3b..f0a5e3b 100644 --- a/src/levelcode/pages/LandingPage.tsx +++ b/src/levelcode/pages/LandingPage.tsx @@ -206,7 +206,7 @@ export default function LandingPage() { {isMac ? ( ) : ( - // Signing in does not get anyone the editor, so on a device that cannot run it + // Signing in does not install the editor, so on a device that cannot run the Mac app // this card points at how to get started rather than repeating the hero's action. Read the quickstart diff --git a/src/levelcode/pages/PricingCards.tsx b/src/levelcode/pages/PricingCards.tsx index b019be8..bc4035e 100644 --- a/src/levelcode/pages/PricingCards.tsx +++ b/src/levelcode/pages/PricingCards.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { api, ApiError, navigateTo, type PricingTier } from "../api"; +import { currentPage, loginRoute } from "../next"; // 4-card grid. Checkout goes through /ai/checkout (CSRF-protected, session-authed). // tier.key IS the Stripe lookup_key (server truth) — send it verbatim. The server @@ -32,7 +33,9 @@ export default function PricingCards({ async function checkout(tier: PricingTier) { setErr(null); if (!authed) { - navigate("/login"); + // Sign in, then back to these plans to choose one. Nothing is bought on the way back: the visitor + // presses the button again (a link must never be able to start a checkout or a plan change). + navigate(loginRoute(currentPage())); return; } setBusy(tier.key); diff --git a/src/levelcode/pages/ReferralsPage.tsx b/src/levelcode/pages/ReferralsPage.tsx index e062f24..cb40403 100644 --- a/src/levelcode/pages/ReferralsPage.tsx +++ b/src/levelcode/pages/ReferralsPage.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from "react-router-dom"; import { api, type ReferralFunnel } from "../api"; import { useSession } from "../auth"; import ClassicShell from "../components/classic/ClassicShell"; +import { currentPage, loginRoute } from "../next"; // Admin-only referral funnel: clicks -> signups -> paid, per marketing channel. // @@ -70,7 +71,7 @@ export default function ReferralsPage() { // Non-admins (or signed-out) never see this surface. Enforced server-side too. useEffect(() => { if (sessionLoading) return; - if (!profile) navigate("/login", { replace: true }); + if (!profile) navigate(loginRoute(currentPage()), { replace: true }); else if (!isAdmin) navigate("/account", { replace: true }); }, [sessionLoading, profile, isAdmin, navigate]); From 080ded147c2790b54b8e3420f47d359b16c2d833 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 17 Oct 2026 22:06:09 -0400 Subject: [PATCH 5/6] =?UTF-8?q?feat(levelcode):=20`next`=20never=20names?= =?UTF-8?q?=20the=20server's=20own=20endpoints=20(/ai/auth/=E2=80=A6,=20/a?= =?UTF-8?q?i/csrf)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They are not pages to land on. The backend refuses them too (thin.ly #454), and the two rules were run over the same 20,000 generated values with no disagreement. --- src/levelcode/next.test.ts | 4 ++++ src/levelcode/next.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/levelcode/next.test.ts b/src/levelcode/next.test.ts index e0b32a4..950feeb 100644 --- a/src/levelcode/next.test.ts +++ b/src/levelcode/next.test.ts @@ -74,6 +74,10 @@ describe("safeNext", () => { ["/ai/%2E%2E/account", "an encoded parent segment, upper case"], ["/ai/.%2e/account", "half encoded"], ["/ai/%2e/account", "an encoded current segment"], + // The server's own endpoints: not pages + ["/ai/auth/handoff?code=x", "the editor's account hand-off"], + ["/ai/auth/oauth/github", "an OAuth start"], + ["/ai/csrf", "the CSRF token endpoint"], // Fragments ["/ai/account#billing", "a fragment"], ["/ai/account#", "an empty fragment"], diff --git a/src/levelcode/next.ts b/src/levelcode/next.ts index 1917f6f..cca2794 100644 --- a/src/levelcode/next.ts +++ b/src/levelcode/next.ts @@ -35,7 +35,8 @@ const ENCODED_SEPARATOR = /%2f|%5c/i; * - a character RFC 3986 does not allow unescaped (`<`, `"`, `|`, non-ASCII, …) or a broken `%` escape; * - a percent-encoded slash or backslash (`%2F`, `%5C`, any case); * - a dot segment, plain or percent-encoded (`/ai/../x`, `/ai/%2e%2e/x`): a browser resolves both, - * and the page would no longer be the one under /ai/ that was checked. + * and the page would no longer be the one under /ai/ that was checked; + * - the server's own endpoints, /ai/auth/… and /ai/csrf: they are not pages (the backend refuses them too). * A double-encoded value such as `/ai/%252F` is accepted: it is a literal path under /ai/, and since * the value must begin with the literal `/ai/`, no decoding of what follows can make it start `//`. */ @@ -48,6 +49,7 @@ export function safeNext(raw: unknown): string | null { const path = raw.split("?", 1)[0]; if (path.split("/").some((segment) => isDotSegment(segment))) return null; + if (path.startsWith("/ai/auth/") || path === "/ai/csrf") return null; // The contract's own words: parsed against this site, it stays on this site, under /ai/. The rules above // already make that so (a literal /ai/ prefix, no //, backslash or dot segment); this guards them. From 14d2fba3b6d4d91902ab26551fae822348f07819 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sun, 18 Oct 2026 17:33:28 -0400 Subject: [PATCH 6/6] fix(levelcode): `next` refuses the server's endpoints in every spelling its router answers Review comment 4308831990: the endpoint check matched the literal spellings only, so /ai/csrf.json, /ai/csrf/ and /ai/%63srf passed, and Rails answers each (a format suffix and a trailing slash are part of every route), so a sign-in could land on the CSRF token's JSON instead of a page. The first segment after /ai/ is now read as the router reads a name: in lower case, up to the first character that is not a letter, digit or underscore (`csrf.json`, `csrf;x` -> `csrf`), both as written and percent-decoded (`%63srf` -> `csrf`; `checkout%A9`, which does not decode, -> `checkout`), and refused when it is one of the backend's own /ai/ routes: auth, authorize_editor, checkout, billing, signout, csrf (thin.ly config/routes.rb). A name that only starts like one (`/ai/csrfx`) or an endpoint's name further down (`/ai/docs/auth`) is still a page. Tests: eleven refused spellings and two accepted pages added to the table; the ten refusals that are not literal endpoint paths fail on the previous commit. The backend applies the same rule (thin.ly #454), and the two validators agree on 40,000 generated values. --- src/levelcode/next.test.ts | 16 +++++++++++++++- src/levelcode/next.ts | 23 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/levelcode/next.test.ts b/src/levelcode/next.test.ts index 950feeb..d73733b 100644 --- a/src/levelcode/next.test.ts +++ b/src/levelcode/next.test.ts @@ -17,6 +17,8 @@ describe("safeNext", () => { ["/ai/pricing?next=/ai/account", "a single slash inside the query"], ["/ai/%252F%252Fevil.example", "double-encoded: a literal path under /ai/; nothing after /ai/ can make it start //"], ["/ai/" + "a".repeat(NEXT_MAX - 4), "exactly the longest"], + ["/ai/csrfx", "a name that starts like an endpoint's is not that endpoint"], + ["/ai/docs/auth", "an endpoint's name further down is a page's"], ]; for (const [raw, why] of accepted) { test(`takes ${JSON.stringify(raw.length > 40 ? raw.slice(0, 40) + "…" : raw)}: ${why}`, () => { @@ -74,10 +76,22 @@ describe("safeNext", () => { ["/ai/%2E%2E/account", "an encoded parent segment, upper case"], ["/ai/.%2e/account", "half encoded"], ["/ai/%2e/account", "an encoded current segment"], - // The server's own endpoints: not pages + // The server's own endpoints: not pages — in every spelling its router answers ["/ai/auth/handoff?code=x", "the editor's account hand-off"], ["/ai/auth/oauth/github", "an OAuth start"], ["/ai/csrf", "the CSRF token endpoint"], + ["/ai/csrf.json", "the endpoint with a format suffix (Rails routes take one)"], + ["/ai/csrf/", "the endpoint with a trailing slash"], + ["/ai/csrf?x=1", "the endpoint with a query"], + ["/ai/%63srf", "the endpoint with an encoded letter"], + ["/ai/CSRF", "the endpoint in capitals"], + ["/ai/auth.json/handoff", "auth with a format suffix"], + ["/ai/%61uth/handoff?code=x", "auth with an encoded letter"], + ["/ai/checkout%A9", "an endpoint's name before an escape that does not decode"], + ["/ai/authorize_editor", "a POST endpoint"], + ["/ai/checkout", "a POST endpoint"], + ["/ai/billing", "a POST endpoint"], + ["/ai/signout", "a POST endpoint"], // Fragments ["/ai/account#billing", "a fragment"], ["/ai/account#", "an empty fragment"], diff --git a/src/levelcode/next.ts b/src/levelcode/next.ts index cca2794..99218bd 100644 --- a/src/levelcode/next.ts +++ b/src/levelcode/next.ts @@ -25,6 +25,11 @@ const PATH_AND_QUERY = /^(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})+$/; // that decodes the value once would turn `/ai/%2F%2Fevil.example` into `/ai///evil.example`. const ENCODED_SEPARATOR = /%2f|%5c/i; +// What the backend serves under /ai/ itself (thin.ly config/routes.rb, the "ai" scope): not pages to land on. +// Rails answers each with a format suffix (`.json`) and a trailing slash too, so a name is compared after +// decoding it, in lower case, and cut at the first character a route name does not have. +const SERVER_NAMES = new Set(["auth", "authorize_editor", "checkout", "billing", "signout", "csrf"]); + /** * `raw` if it is a page of this site to land on after signing in, else null (treated as absent). * @@ -36,7 +41,8 @@ const ENCODED_SEPARATOR = /%2f|%5c/i; * - a percent-encoded slash or backslash (`%2F`, `%5C`, any case); * - a dot segment, plain or percent-encoded (`/ai/../x`, `/ai/%2e%2e/x`): a browser resolves both, * and the page would no longer be the one under /ai/ that was checked; - * - the server's own endpoints, /ai/auth/… and /ai/csrf: they are not pages (the backend refuses them too). + * - the server's own endpoints (SERVER_NAMES), in any spelling its router answers: /ai/csrf, /ai/csrf.json, + * /ai/csrf/, /ai/%63srf, /ai/CSRF, /ai/auth/…: they are not pages (the backend refuses them too). * A double-encoded value such as `/ai/%252F` is accepted: it is a literal path under /ai/, and since * the value must begin with the literal `/ai/`, no decoding of what follows can make it start `//`. */ @@ -49,7 +55,7 @@ export function safeNext(raw: unknown): string | null { const path = raw.split("?", 1)[0]; if (path.split("/").some((segment) => isDotSegment(segment))) return null; - if (path.startsWith("/ai/auth/") || path === "/ai/csrf") return null; + if (isServerName(path.split("/")[2] ?? "")) return null; // The contract's own words: parsed against this site, it stays on this site, under /ai/. The rules above // already make that so (a literal /ai/ prefix, no //, backslash or dot segment); this guards them. @@ -64,6 +70,19 @@ export function safeNext(raw: unknown): string | null { return raw; } +/** Is this first segment after /ai/ one of the backend's own endpoints, as written or decoded? A name is read as + * the router would: in lower case, up to the first character that is not a letter, digit or underscore + * (`csrf.json` and `csrf;x` are `csrf`). Read both ways, `%63srf` and `checkout%A9` are endpoints too. */ +function isServerName(segment: string): boolean { + const name = (s: string) => (/^[a-z0-9_]*/.exec(s.toLowerCase()) ?? [""])[0]; + if (SERVER_NAMES.has(name(segment))) return true; + try { + return SERVER_NAMES.has(name(decodeURIComponent(segment))); + } catch { + return false; // not decodable: it is read as written, above + } +} + function isDotSegment(segment: string): boolean { const s = segment.replace(/%2e/gi, "."); return s === "." || s === "..";