Skip to content

feat(levelcode): signing in at levelcode.ai lands in the web editor; the API takes the session cookie only from the account site's own pages - #454

Merged
ndemianc merged 5 commits into
developfrom
feat/levelcode-login-lands-in-editor
Oct 18, 2026
Merged

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

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

What

Signing in at levelcode.ai now lands you in the LevelCode editor in the browser, the way chatgpt.com lands you in the app, instead of on /ai/account. That only happens while the web edition is on. With it off, which is production today, nothing changes.

The editor is going to app.levelcode.ai (the owner's choice, 2026-10-17). A subdomain was ruled out until now because the API accepted the account's session cookie from any page of the site. The first commit closes that.

Commits

  1. fix(levelcode): the API takes the session cookie only from the account site's own pages. It now requires Sec-Fetch-Site: same-origin, or none for an address the visitor opened themselves. An older browser that sends no Sec-Fetch-Site is judged by Origin instead: none, or this origin's own. Before, a page on any *.levelcode.ai could use the visitor's cookie to read their profile, start a checkout, open the billing portal, or spend credits at the gateway. The spec shows the profile answering 200 and the billing portal session being opened on the old code. The editors use bearer tokens and are unaffected. The account site's own calls are same-origin, in production and through the dev proxy.

  2. feat(levelcode): where a web sign-in lands.

    • the page the visitor asked for, next (a strictly checked path under /ai/);
    • else the web editor with ?signin=1, when it is on. That is the same address as "Open LevelCode": the editor bounces through /ai/login with its own PKCE challenge, finds the new session, and is signed in with nothing typed;
    • else /ai/account.

    next is taken by POST /ai/auth/verify and by GET /ai/auth/oauth/:provider, which carries it across the provider in the session. An editor that is signing in is unchanged. The editor's "Manage account" hand-off and the Stripe returns still land on /ai/account.

  3. docs(levelcode): the hosting rule in EditorCallback now allows a subdomain, and names what a subdomain can still do: plant a cookie for .levelcode.ai, and run whatever the page runs.

  4. fix(levelcode): next follows the account site's rule exactly. Encoded dot segments are refused, so are the server's own /ai/auth/… and /ai/csrf, and only URL characters are allowed. A failed OAuth sign-in keeps next on the retry. The two validators agree on 20,000 generated values.

Verified

  • bundle exec rspec: 1621 examples, 0 failures on commit 3, then 826 examples, 0 failures in the LevelCode specs on commit 4 (RAILS_ENV=test, DEV_HOST=http://localhost). Of the new examples, the five cookie-attack cases and eight of the ten landing cases fail on the previous code. The remaining ones pin behaviour that must not change.
  • The 14 existing "mints nothing for <a callback that isn't the editor's>" cases still issue no code and never redirect to that address. With the editor on, the web sign-in they fall back to now lands on the editor's own address, and they assert that.
  • rubocop is clean on the changed files; brakeman reports 0 warnings.

Not run

  • A real browser against this branch, and the full hop from login to editor.
  • Production. Nothing turns on until LEVELCODE_WEB_EDITOR_ORIGINS=https://app.levelcode.ai and LEVELCODE_WEB_EDITOR_URL=https://app.levelcode.ai/ are set after this is deployed, and the editor is hosted there.

For the owner

  • __Host- session cookie. A page on a subdomain can still set a cookie for .levelcode.ai. Renaming the Devise session cookie to __Host-… would refuse that, but signs everyone out once. Not done here.
  • Order. Deploy this before setting the two variables, so the hardening is live before anything is served from app.levelcode.ai.

Companion PRs: systemu-net/onetime#100 (the account site passes next, and sends an already-signed-in visitor to the editor) and levelcodeai/levelcode#110 (docs/WEB.md).

🤖 Generated with Claude Code

…e account site's own pages

The LevelCode API accepts the levelcode.ai session cookie as well as an editor's bearer token, and,
as a token-first API, has no CSRF token. SameSite=Lax is a site boundary, not an origin one, so a
page on another origin of the same site is sent the cookie too. That is what kept the web editor
off a levelcode.ai subdomain (docs/WEB.md in levelcode); app.levelcode.ai is where it is going.

user_from_session now counts the cookie only when the request comes from this origin's own pages
(Sec-Fetch-Site same-origin) or from the visitor (none). A browser that sends no Sec-Fetch-Site
(older than 2023) is judged by Origin: none, or this origin's own. The account site's calls are
same-origin, in production and through the dev server's proxy; the editors use bearer tokens.

Spec (session_cookie_spec.rb, the real sign-in path, no stubbed authentication): from the editor's
origin and from another site the profile, a checkout and the billing portal answer 401 and Stripe
is not called — on the previous code the profile answered 200, the checkout got past sign-in to a
400, and the billing portal session was opened; the account site's own pages, a typed address, an
old browser and a bearer token from the editor's page still work.
…r when it is on

The owner's model is chatgpt.com: signing in at levelcode.ai takes you to the work, not to your
account. A web sign-in (no editor signing in) used to end on /ai/account. It now ends on:

- the page the visitor asked for, `next` — a plain path under /ai/ (a query is kept; no scheme,
  host, `//`, backslash, percent-encoded slash, dot segment, blank, control character or
  fragment; 512 characters at most). POST /ai/auth/verify takes it in the body; GET
  /ai/auth/oauth/:provider takes it in the query and keeps it in the session across the provider,
  like `state`, and a sign-in started again without one does not inherit it;
- else the web editor, when the web edition is on: its configured address (an origin and a path,
  the first origin when LEVELCODE_WEB_EDITOR_URL is unset) with `signin=1`, the same address as the
  account site's "Open LevelCode". The editor takes the tab to /ai/login with its own PKCE
  challenge, finds the session just made here, and is back signed in with nothing typed;
- else /ai/account, as before. With the web edition off (production today) nothing changes.

An editor that is signing in (redirect_uri + code_challenge) is unchanged and `next` is not looked
at. The editor's "Manage account" hand-off and the Stripe returns still land on /ai/account.

Specs (web_spec.rb "where a web sign-in lands"): the editor address, its path kept, its origin when
none is configured; off -> /ai/account; `next` honoured editor on or off; eleven malformed `next`
values ignored; an editor sign-in untouched; through OAuth: the editor, a `next` carried across the
provider, not inherited by a restarted sign-in, a malformed one ignored. Eight fail on the previous
controller (the other two pin what must not change). The 14 existing "mints nothing for <a callback
that is not the editor's>" cases still mint nothing and never go to that address; with the editor
on, the web sign-in they fall back to now lands on the editor's own address instead of
/ai/account, and they say so.
…host now

EditorCallback's note said the editor (and the extension host's wildcard parent) must not be
hosted on a subdomain of levelcode.ai while the API took the session cookie from any request.
It takes it only from this origin's own pages now, and the /ai/* POSTs carry Rails' CSRF token,
so app.levelcode.ai — the owner's choice — is fine. The note now says so, and names the two
things a subdomain can still do that another domain cannot: plant a cookie for .levelcode.ai (a
`__Host-` session cookie would refuse it) and run whatever the page runs (LevelCode's own code
only; isolate the extension host on another domain before it runs anyone else's).
@ndemianc
ndemianc marked this pull request as ready for review October 18, 2026 01:43
Copilot AI balanced review requested due to automatic review settings October 18, 2026 01:43

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

Encoded dot segments bypass the required /ai/ destination restriction.

1 open issue
What changed in this PR

Updates LevelCode web sign-in destinations and restricts API session-cookie authentication ahead of hosting the editor on a subdomain.

Changes:

  • Limits cookie authentication using request-origin headers.
  • Adds next, web editor, and account-page landing priorities.
  • Adds request coverage and updates hosting guidance.
File Description
spec/​requests/​levelcode/​web_spec.rb Tests sign-in destinations and fallback behavior.
spec/​requests/​api/​levelcode/​v1/​session_cookie_spec.rb Tests cookie restrictions and bearer-token access.
app/​services/​levelcode/​editor_callback.rb Updates subdomain hosting documentation.
app/​controllers/​levelcode/​web_controller.rb Adds destination selection and next validation.
app/​controllers/​api/​levelcode/​v1/​base_controller.rb Restricts session-cookie authentication by request origin.

Review effort: Balanced

Comment thread app/controllers/levelcode/web_controller.rb Outdated
… failed sign-in keeps it

Found by the account-site half's review of this branch (onetime, src/levelcode/next.ts):

- an encoded dot segment got through: /ai/%2e%2e/account is /account to a browser. Dot segments
  are now refused plain or encoded;
- `next` could name the server's own endpoints (/ai/auth/handoff?code=…, /ai/auth/oauth/…,
  /ai/csrf). They are not pages; refused;
- the characters are now RFC 3986's path and query characters with well-formed escapes, as the
  SPA's, so a non-ASCII or `<` value is refused on both sides alike;
- a failed OAuth sign-in (session_expired, oauth_failed) went back to /ai/login without the page
  asked for, so trying again landed elsewhere. It carries `next` now.

The two validators were run over the same 20,000 generated values (the dot, separator, escape,
endpoint and character cases combined at random): 1,685 accepted by both, no disagreement.
Specs: six more refused values in the malformed table, and the failed-sign-in case; both fail on
the previous commit.
…takes names that only look like one

Review comment 4305315961 (on 89e4ad3): /ai/%2e%2e/users/edit passed safe_next and a browser
resolves it to /users/edit. Fixed in 4068f5c, which folds %2e before checking each segment; this
adds the mixed and single forms the comment names (.%2e, %2e., a lone %2e, two encoded steps up)
to the refused table, and pins that a name like `a..` (written a.%2e) is still a page. The table
fails on 89e4ad3 (it redirected to /ai/%2e%2e/users/edit) and passes here.
@ndemianc
ndemianc merged commit 9fa35ae into develop Oct 18, 2026
3 checks passed
@ndemianc
ndemianc deleted the feat/levelcode-login-lands-in-editor branch October 18, 2026 21:20
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