Skip to content

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
ndemianc merged 6 commits into
developfrom
feat/levelcode-login-lands-in-editor
Oct 18, 2026
Merged

ndemianc merged 6 commits into
developfrom
feat/levelcode-login-lands-in-editor

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

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/login to the same place. With the web edition off, which is production today, nothing changes.

Commits

  1. refactor: the OAuth start link and the verify request body move into signInRequests.ts, unchanged, with tests pinning what they send today. Adds npm test (Node's built-in runner, no new dependency); the repo had no tests before.

  2. 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.

  3. feat: an already-signed-in visit to /ai/login goes to next, 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.

  4. feat: pages that send a signed-out visitor to sign in name themselves in next:

    • the Account, Admin and Referrals gates;
    • Pricing's "Sign in to start", which only lands back on pricing and never starts a checkout from a link.

    Plain "Sign in" links carry no next, so they land in the editor.

  5. feat: next never names the server's own endpoints (/ai/auth/…, /ai/csrf).

  6. 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 for next, where the signed-in visitor goes in each case, and that verify and OAuth carry next only when valid.
  • tsc -b passes; npm run lint has 0 errors (the 8 warnings were there before); npm run build passes.
  • Mutation run on the new module: 39 one-line mutations, 38 caught. The survivor is a final URL-parse guard that the earlier rules make unreachable.
  • In a browser, against a local stand-in backend that applies the contract:
    • edition off and edition on;
    • next through the email code;
    • an already-signed-in visitor going to next, to the editor, or to the account;
    • an editor sign-in while signed in (one authorize_editor, without next);
    • the Account gate's next, and a refused //evil.example.

Not run

  • The real backend, a real OAuth round trip, Safari, Firefox and phones.
  • CI: the site's workflow (update-ui.yml) does not run npm test yet.

For the owner

  • On a device that can't run the Mac app, a plain "Sign in", phones included, now lands in the browser editor whenever the edition is on.
  • Merge order: thin.ly#454 (merged) and #456 first, then this. It's harmless either way while the edition is off.

🤖 Generated with Claude Code

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 next destinations 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

Comment thread src/levelcode/next.ts Outdated
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants