Repository navigation
feat(levelcode): signing in lands in the web editor — the site passes next, and a signed-in visit to /ai/login goes to the editor - #100
Merged
Conversation
…lt 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.
…sked for, to the sign-in `/ai/login?next=/ai/pricing?plan=pro` now reaches the backend: in the body of POST /ai/auth/verify and in the query of the OAuth start link, which oauth_start keeps across the provider. The backend lands a web sign-in there instead of its default. next.ts holds the one check, safeNext, that the login page and both requests use: 5 to 512 characters, a literal `/ai/` prefix, only the characters RFC 3986 allows in a path and a query (so no whitespace, control character, backslash, fragment or non-ASCII), no `//` anywhere, no percent-encoded slash or backslash (`%2F`, `%5C`, any case), no dot segment, plain or encoded (`/ai/%2e%2e/x` resolves to /x in a browser). Anything else is ignored as if absent. A double-encoded `%252F` is accepted: the value starts with a literal /ai/, so no decoding of what follows can make it start `//`. An editor signing in (redirect_uri + code_challenge) carries no `next`; the requests are what they were. The comments that said a web sign-in ends on /ai/account now say where it may end: a page of this site, or the browser edition on its own origin, both a full-page navigation (EmailSignIn already treats an http(s) address that way). Tests: an accept/reject table of 73 values for safeNext, and that each request carries `next` only when valid and never with an editor pair. Each rule of safeNext was mutated alone and caught, except the final URL-parse guard, which the earlier rules make unreachable.
…se 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.
…mes 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=<that page, query included>. - 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.
…uth/…, /ai/csrf) 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.
This was referenced Oct 18, 2026
ndemianc
marked this pull request as ready for review
October 18, 2026 21:20
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Endpoint aliases bypass destination validation and can send successful sign-ins to JSON rather than a site page.
1 open issue
What changed in this PR
Updates the account site's sign-in flow to preserve requested destinations and send plain sign-ins to the web editor when enabled.
Changes:
- Passes validated
nextdestinations through email and OAuth sign-in. - Adds signed-in login redirects and return paths from gated pages.
- Extracts request builders and adds validation and routing tests.
| File | Description |
|---|---|
| src/levelcode/signInRequests.ts | Centralizes sign-in request construction. |
| src/levelcode/signInRequests.test.ts | Tests request fields and destination handling. |
| src/levelcode/pages/ReferralsPage.tsx | Preserves the return destination. |
| src/levelcode/pages/ProviderSignIn.tsx | Uses the shared OAuth request builder. |
| src/levelcode/pages/PricingCards.tsx | Returns visitors to pricing after sign-in. |
| src/levelcode/pages/LoginPage.tsx | Selects destinations and updates login messaging. |
| src/levelcode/pages/LandingPage.tsx | Clarifies an installation comment only. |
| src/levelcode/pages/EmailSignIn.tsx | Includes validated destinations in verification requests. |
| src/levelcode/pages/AdminPage.tsx | Preserves the admin return destination. |
| src/levelcode/pages/AccountPage.tsx | Preserves the account return destination. |
| src/levelcode/next.ts | Adds destination validation and routing helpers. |
| src/levelcode/next.test.ts | Tests validation and destination precedence. |
| package.json | Adds the test command. |
Review effort: Balanced
…ng 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What
The account-site half of "signing in at levelcode.ai lands you in the editor", the way chatgpt.com lands you in the app. The backend decides where a sign-in lands (systemu-net/thin.ly#454). This PR makes the site pass
next, the page a visitor asked for, so that page still wins over the editor. It also sends an already-signed-in visitor at/ai/loginto the same place. With the web edition off, which is production today, nothing changes.Commits
refactor: the OAuth start link and the verify request body move intosignInRequests.ts, unchanged, with tests pinning what they send today. Addsnpm test(Node's built-in runner, no new dependency); the repo had no tests before.feat: the login page reads?next=and passes it on, only when it is valid and never alongside an editor sign-in. It goes in the verify body (email code) and on the OAuth start link.feat: an already-signed-in visit to/ai/logingoes tonext, else to the browser edition (?signin=1, replacing the history entry), else to/account. While the edition answer loads, the blank frame stays. The subtitle says "LevelCode opens in this tab when you're done" when that is where a plain sign-in will land.feat: pages that send a signed-out visitor to sign in name themselves innext:Plain "Sign in" links carry no
next, so they land in the editor.feat:nextnever names the server's own endpoints (/ai/auth/…,/ai/csrf).fix(review): it refuses them in every spelling the backend's router answers:/ai/csrf.json,/ai/csrf/,/ai/%63srf,/ai/CSRF, and the other/ai/routes (checkout,billing,signout,authorize_editor). The backend's same rule is fix(levelcode): sign-in next refuses the /ai/ endpoints in every spelling the router answers thin.ly#456, and the two agree on 40,000 generated values.safeNext(src/levelcode/next.ts) accepts a path under/ai/written in URL path and query characters. It refuses//, encoded separators, dot segments (plain or encoded) and those endpoints. The backend's rule is the same: both were run over 20,000 generated values with no disagreement.Verified
npm test: 101 pass. That includes a 76-value accept/refuse table fornext, where the signed-in visitor goes in each case, and that verify and OAuth carrynextonly when valid.tsc -bpasses;npm run linthas 0 errors (the 8 warnings were there before);npm run buildpasses.nextthrough the email code;next, to the editor, or to the account;authorize_editor, withoutnext);next, and a refused//evil.example.Not run
update-ui.yml) does not runnpm testyet.For the owner
🤖 Generated with Claude Code