Skip to content

feat(levelcode): the web edition of the editor — a callback it may be sent a code, CORS for its page, and where it lives - #447

Merged
ndemianc merged 12 commits into
developfrom
feat/levelcode-web-editor-origin
Oct 14, 2026
Merged

ndemianc merged 12 commits into
developfrom
feat/levelcode-web-editor-origin

Conversation

@ndemianc

@ndemianc ndemianc commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

What

The backend half of LevelCode in your browser (the editor as a static page, signed in with the user's LevelCode account). Companion PRs: the editor + browser build (levelcodeai/levelcode#105) and the account page's entry points (systemu-net/onetime#98).

Everything here is off until configured. With none of the three settings set, the callback rule, CORS and the new endpoint answer exactly as they did.

Setting Meaning
LEVELCODE_WEB_EDITOR_ORIGINS Comma list of the origins the editor is served from, exactly (https://editor.example.com). A sign-in code is only ever sent to <origin>/callback.html; also the CORS list.
LEVELCODE_WEB_EDITOR_URL Where the account page links to: one of those origins and, optionally, a path. First origin when unset.
LEVELCODE_WEB_EXTENSION_HOST_ORIGINS Comma list (one wildcard each, https://*.ext.example.com) of origins the editor's extension host runs on, when the deployment isolates it. CORS only — never a callback.
  • Levelcode::WebEditor — the grammar of an origin and nothing else: https (http only for localhost, 127.0.0.1, [::1], *.localhost), no path/query/userinfo, no wildcard, no numeric-looking hosts. The one wildcard form (extension host) is compared by parts, never by a text test: the star is one whole leftmost label, the sessions' own v--<letters/digits> label, then the entry's domain, scheme and port exactly.
  • Levelcode::EditorCallback — a second accepted shape besides the desktop deep link: https://<listed origin>/callback.html?vscode-reqid=N&vscode-scheme=levelcode&vscode-authority=levelcode.levelcode-ai&vscode-path=/auth/callback. Exact path; the page is told to hand the code to this extension and no other; no userinfo, no fragment; a duplicated vscode-* parameter is refused (the page takes the first, a reader that took the last would see another address); vscode-query is plain pairs and cannot name code. Both gates (Levelcode::WebController, Api::Levelcode::V1::AuthController) ask this one rule.
  • CORS — a block of its own, not the catch-all: the editor's origins (and the extension host's) get /api/levelcode/v1/ and nothing else; authorization, content-type, accept; get, post, options; no credentials, nothing exposed.
  • GET /api/levelcode/v1/web_editor → { enabled, url }; public, cacheable for five minutes.

Security notes

  • A code goes only to a listed origin's exact callback page, over https (or loopback http for development). The redirect_uri is decoded until stable (three rounds at most, as before) and the decoded string is both what is validated and what is used.
  • The extension host's wildcard is not a callback and never becomes one (#match? does not ask it).
  • Deployment constraint, not enforceable here: do not host the editor — or the extension host's wildcard parent — on a subdomain of the account host. The session cookie is SameSite=Lax, a site boundary, and Api::Levelcode::V1::BaseController takes the cookie when there is no bearer token and has no CSRF/Origin check, so a page that runs other people's extensions on a sibling subdomain could cause credentialed requests (CORS stops it reading the answer, not causing the effect). The class comment says so. A follow-up that makes BaseController refuse cookie auth on cross-site requests (Sec-Fetch-Site) would close it for good; not in this PR.

Review round (2026-10-14)

Both review threads are answered in place and resolved.

  • b220418 From a browser, the sign-in exchange takes only a PKCE-bound code. /auth/login without code_challenge and /auth/web_handoff issue codes the exchange redeemed with any verifier. The web edition's callback is a page anyone can link to, so such a code, slipped in during someone's own sign-in, signed them in to the code's account. A request that carries Origin (a browser always sends one on a POST; Node's fetch, the desktop editor's, never does) now gets 401 invalid_verifier for an unbound code, which is used up either way. The /ai sign-in pages already bind every editor code, so the web edition's own sign-ins are unaffected. The desktop and /ai/auth/handoff are unchanged.
  • 4716e51 An IPv6 origin is stored the way a browser writes it. Before, https://[::FFFF:127.0.0.1] was stored as [::ffff:127.0.0.1], which never matched the browser's [::ffff:7f00:1].

Every new attack and spelling test fails on the previous code. Verified on b220418: the whole suite passes, 1588 examples, 0 failures (RAILS_ENV=test, DEV_HOST=http://localhost); rubocop is clean on the changed files; brakeman reports 0 warnings.

Left as it was (owner's call): the desktop editor still exchanges an unbound code, so a crafted levelcode:// link could do to the desktop what this closes for the tab. Requiring a bound code for every exchange is a one-line change. As far as I can see, it would affect only sign-ins through the API endpoints that are sent no code_challenge: the /ai pages already refuse to issue an editor code without one. I have not checked older desktop releases against it.

Verified

  • bundle exec rspec spec/services/levelcode spec/requests/levelcode spec/requests/api/levelcode → 785 examples, 0 failures (RAILS_ENV=test, DEV_HOST=http://localhost); the six files this PR adds or extends are 571 of them.
  • CI on this PR: brakeman, rubocop and the whole test job pass.
  • The address the editor actually sends — encodeURI(uri.toString(true)) from the real URI class, as the workbench's own opener produces — run through the controller's decoding (decode_deep_link) and EditorCallback#match?: matches for the listed origin, does not for another.
  • The editor's end-to-end checks (headless Chrome, sign-in across origins, CORS answered for the page and for an isolated extension host) pass against a stand-in that applies these same rules.
  • This branch, run for real: Rails in test mode (LEVELCODE_WEB_EDITOR_ORIGINS=http://localhost:8801, email never sent, Stripe answered locally, a throwaway database and Redis db), the real login page from onetime#98, and the editor in a real headless Chrome. Not signed in anywhere → email code → back in the editor, POST /auth/exchange 200, profile and models 200. Signed in on the account site → "Open in browser" → POST /ai/authorize_editor → exchange 200, nothing typed. A gateway chat from the editor's origin answered 200 (past the preflight). OPTIONS answered with the editor's origin on API paths, with nothing for another origin and nothing on /ai/auth/*.

Not run

  • Production: a deployed account site, the real mailer and Stripe, and the isolated extension-host origin (LEVELCODE_WEB_EXTENSION_HOST_ORIGINS) against this Rails app — the wildcard rule is specs-only here.
  • Any production environment: set the three variables and restart (they are read once per process, and logged at boot if an entry is unusable).

…d URL look like

The editor is getting a web edition: the same editor built as a static page and
served from an origin of its own. Three things will need that origin — the rule
for where a sign-in code may be sent, CORS for the page's calls to the API, and
the endpoint that tells the account page where the editor is — and they must
agree on what one is. This is that one reading, and only the grammar; no ENV,
no logging, no rule about what an unusable entry costs. The callback rule asks
it in the next commit.

An origin is scheme://host[:port] and nothing else: no path, query, fragment or
userinfo, no wildcard. The wildcard is the one a deployment is tempted to write
(https://*.levelcode.ai) and the one that cannot be allowed — the code is posted
to whatever matches, so every host a wildcard names is a place it can be sent.

The scheme is https. http is for the machine in front of you: localhost,
127.0.0.1, [::1] and *.localhost. Anything else over http would put the code on
a network in the clear.

Entries are written the way browsers serialise an origin, so an exact string
comparison is the whole of "is this the origin": lower case, default port
dropped. The case fold is ASCII only — a full Unicode fold turns U+212A (Kelvin
sign) into "k" and would quietly accept a host that was never ASCII. A
trailing-dot host, an empty label, an underscore, an encoded host, a port of 0
or past 65535 are all refused; they are other origins to a browser or no origin
at all.

A URL, for the account page's link, is an origin and an optional path made only
of plain path characters.

Specs: the table of what is and is not an origin (each refusal named), the http
exception both ways, the default-port and case folding, URL parsing, and that
none of it raises for what is not a string.
…n origins the server was told of

The web edition of the editor cannot be reached by a custom scheme. It is a page
on an origin of its own, and the callback its extension produces
(vscode.env.asExternalUri) is that page's /callback.html carrying the deep link
it stands for:

  https://<origin>/callback.html?vscode-reqid=N&vscode-scheme=levelcode
    &vscode-authority=levelcode.levelcode-ai&vscode-path=%2Fauth%2Fcallback

Levelcode::EditorCallback is the one gate both sign-in controllers ask, so this
is where it goes. Two settings, both unset in production:

  LEVELCODE_WEB_EDITOR_ORIGINS   comma list of the origins it is served from
  LEVELCODE_WEB_EDITOR_URL       where the account page links to it (an origin on
                                 that list and, optionally, a path)

Unset, the feature is off and the rule is exactly what it was: web_enabled? is
false and no web address is looked at. Entries the setting names but this will
not take (a path, a wildcard, http for a host that is not local, a URL on an
origin that is not listed) are dropped and named once in the log, as the extra
schemes are — a typo is otherwise invisible, its only symptom being a sign-in
that ends on the account page.

What passes, all of it:
  - the origin is on the list, compared whole. Not a prefix, a suffix, a
    pattern or a case-fold: userinfo, a look-alike host, another port, http for
    an https origin and a trailing dot are not on it;
  - the path is exactly /callback.html — the code is appended to the query, so
    the page is what receives it;
  - no userinfo, no fragment;
  - the page is told to hand the code to THIS extension: vscode-authority is the
    extension id, vscode-path its auth route, vscode-scheme one of the schemes
    the deep link takes, vscode-reqid a small integer. callback.html dispatches
    whatever it is given to whichever extension registered that address, so
    without this the code could be sent to another extension's URI handler;
  - a vscode-* parameter given twice is refused — the page takes the first, a
    reader that took the last would see another address than the one that passed;
  - vscode-query, if present, is plain name=value pairs that do not name `code`.
    callback.html merges it into the query it hands the extension and lets a
    name there REPLACE the top-level one, so one naming `code` (however many
    times encoded) would displace the code this server appends. The editor's own
    callback has none; only plain pairs are taken so nothing in it can be read
    twice into something else.

Parameters are read as the page reads them (`&`-separated, percent-decoded
once). The rule reads the address it is given and does not decode: the /ai gate
decodes the extension's address until stable before it asks, and what it asks
about is what it uses.

The settings are read once per process, and the extra-schemes entries are now
scrubbed like the others: a setting that is not valid UTF-8 comes out as an
entry that is not taken rather than an exception, which matters now that the
rule is read at boot.

Specs:
  - the rule: what is taken (any parameter order, other parameters, the
    percent-encoded or plain path, each scheme, several origins, local http), and
    one example per way it must not be: another origin, userinfo either way,
    look-alike, port, downgrade, trailing dot, case, encoded host, empty
    authority, backslash/tab/newline, scheme-relative, javascript:/data:, another
    page, traversal, encoded slash/dot, doubled slash, fragment, no query, the
    wrong authority/path/scheme/reqid, every vscode-* key twice (right value
    first and last, and spelled through percent-encoding), and a vscode-query
    that names code plain, encoded 1-3 times, or behind an encoded ampersand.
  - production: nothing is taken, nothing is on for CORS, nothing for the URL,
    and the deep link is the rule it was.
  - through both gates (/ai/auth/verify, /ai/authorize_editor, the OAuth round
    trip, the API's auth/login): the code goes to the page bound to the PKCE
    challenge with the callback as the editor built it and the request id kept;
    the address arrives encoded the way vscode.Uri#toString() sends it; a code
    in the address is replaced by ours; no challenge still fails closed;
    anything else is a web sign-in and mints nothing. A whole sign-in, with a
    real one-time code, is redeemed with its verifier and only with it.
…S for exactly the origins on the list

The web edition's extension calls the API from the page: the sign-in exchange
and refresh, the account reads, and the metered gateway (SSE included), each
with `Authorization: Bearer` — a cross-origin fetch, so a preflight.

One new allow block in config/initializers/cors.rb, deliberately NOT the
catch-all (that is thin.ly's own app; an editor origin is a page that runs other
people's extensions and gets the LevelCode API and nothing else — not /api/v1/*,
not the /ai account flows):

  origins    the callback rule's own list (LEVELCODE_WEB_EDITOR_ORIGINS)
  resource   /api/levelcode/v1/
  headers    authorization, content-type, accept
  methods    get, post, options
  max_age    600
  credentials  false

No credentials, and nothing exposed. The editor authenticates with the Bearer
header alone, so the browser has no reason to send a cookie and must never be
told it may: without Access-Control-Allow-Credentials a page on that origin
cannot read an answer that the visitor's account session authorised. The editor
reads the status and the body and no header.

The origins are asked of Levelcode::EditorCallback.current per request rather
than listed at boot, for two reasons: one parser, so CORS and the sign-in
redirect cannot disagree about which origin is the editor's; and an initializer
cannot autoload the app's constants. The rule reads the environment once per
process and is read at boot (after_initialize) so a typo in the setting is in the
boot log rather than found by the first sign-in; a change to the setting needs a
restart. With it unset the list is empty and the block never matches.

The resource is a pattern, not "/api/levelcode/v1/*": Rack::Cors makes the slash
before `*` optional, which would also take /api/levelcode/v1evil.

Specs run the real Rack::Cors with the real config and a real OPTIONS and POST:
  - preflight: allow-origin, methods, headers echoed, max-age 600, no
    credentials, nothing exposed; every gateway/auth path; a header or a method
    it does not name, or any other origin (look-alike, port, http, case, null),
    gets nothing at all.
  - the editor's origin gets NOTHING on /api/v1/*, the /ai flows, devise, the
    update feed or a path that only looks like the namespace.
  - an actual POST to the exchange, an unauthorised gateway call (the editor
    keys on its code), a cookie-bearing request, and a streamed SSE response all
    carry the allow-origin header and never the credentials one.
  - thin.ly's own origins keep what they had on both /api/v1 and the LevelCode
    API (every method, exposed headers, 7200), the track beacon is unchanged,
    and /up stays public.
  - unset (production): the editor's origin gets nothing; thin.ly's is as was.
…ition of the editor, and where

The account page offers "Open in browser" only where there is a web editor, and
sends the visitor there. Where it lives is configuration, never a constant of
the page, so the page asks:

  { "enabled": true,  "url": "https://editor.levelcode.ai" }
  { "enabled": false, "url": null }

enabled is whether the server was told of an origin to hand sign-in codes to
(LEVELCODE_WEB_EDITOR_ORIGINS). url is LEVELCODE_WEB_EDITOR_URL when that is an
origin on the list and, optionally, a path; otherwise the first origin. It is
null whenever the feature is off, so the response can never advertise an editor
whose sign-ins the server would refuse — and a URL on an origin that is not on
the list is ignored (and logged once) for the same reason.

Public and the same for everyone, so a shared cache may hold it: no auth, no
cookie (nothing touches the session), Cache-Control public, max-age 300. It
follows PricingController: a BaseController sibling that skips the Bearer gate.

Specs: off by default (also for a setting that names nothing usable), on, the
first of several origins, the named URL with a path, case/port as the list
writes them, local http, every kind of unusable URL falling back to the first
origin, no cookie, cacheable, exactly two keys, the same for a caller with a
token, GET only.
…ck is only a deep link

Comments only. Levelcode::WebController#safe_redirect_uri said the gate accepts
"ONLY the editor deep-link ... no https", and the API's AuthController called its
check the frozen deep link. Both are Levelcode::EditorCallback's rule, which now
also takes a web editor's page on a server told of one. Said where a reader of
either controller will look, with the one fact worth knowing about the two gates:
the /ai gate decodes the extension's address until stable before it asks (and
uses what it asked about); the API's asks about the address as sent.
…decoded, whatever the URI library let through

The mutation that removed the ArgumentError rescue from EditorCallback#match?
survived: the URI library rejects a malformed percent-escape today, so no
example reached URI.decode_www_form with one. The rescue is what keeps the
promise that the rule never raises on a library that does not (older URI
versions parsed the query and left the escape to the decoder), so the spec
makes the decoder raise and asks for false.
…ite it, and reads broken text as no origin

Two things the grammar let through that no browser would ever send as an Origin,
which a setting would then carry silently — an entry that is accepted and can
never match is the worst kind of typo, since nothing in the log names it:

  - an IPv6 literal was any run of hex digits, colons and dots between brackets
    ([:::], [1.2.3.4]). It is now parsed as IPv6.
  - a host that ends in a number is an IPv4 address to a browser however it is
    spelled (1.2.3 is 1.2.0.3, 0x7f.1 is 127.0.0.1, 2130706433 is 127.0.0.1).
    Only the canonical dotted quad is taken, so 127.0.0.1 stays and the rest are
    reported.

And an entry that is not valid UTF-8 is now an entry that is not taken from
Levelcode::WebEditor itself, not only from EditorCallback's read of the setting:
the regex would otherwise raise on it.

Specs: each of the above, and the addresses that must keep working (127.0.0.1,
[2001:db8::1], [::ffff:127.0.0.1], names with a digit at either end).
…th one wildcard form

Where the web editor's extension host is isolated (the web build's
webEndpointUrlTemplate), it is not a web worker of the editor's page but a
cross-origin iframe on a subdomain of its own per session —

  https://v--<52 characters [a-z0-9]>.ext.example.com
  http://v--<hash>.localhost:8801                          (locally)

— and every call an extension makes to the API comes from THAT origin. A list of
exact origins cannot name it: there is one per session. This is the grammar for
what can, and for how an Origin header is compared with it. Nothing consults it
yet; the callback rule is not among its readers, and will not be.

An entry of the list is an origin, exactly as .origin takes one, or one wildcard:

  scheme://*.parent[:port]

The star is exactly one DNS label, whole and leftmost: not a part of a label
(v--*), not twice, not anywhere but the front, not in the scheme or the port.
The parent is a domain with at least two labels, so that *.com names no one —
localhost is the one exception, for development — and it is not an address (an
IP has no subdomains; a host that ends in a number is one). Over http the parent
is localhost or ends in .localhost, as for any origin. A public suffix like
co.uk is not detected and cannot be from here: the parent has to be a domain the
deployment owns.

What a wildcard stands for is narrower than its star: a first label that is the
editor's own marker, v-- and then one to sixty-three letters or digits (a DNS
label is sixty-three in all, so sixty in practice). A subdomain the same
operator uses for something else is not an extension host.

The comparison is by parts, on the origin as a browser writes it. The Origin
header is parsed with the same grammar as an entry, taken apart into scheme,
host and port, and the host split at its first dot: the label must be the marker
and EVERYTHING after it must equal the entry's parent. Never a suffix or a
substring test on the text — v--abc.ext.example.com.evil.com,
v--abc.evil.ext.example.com and x.v--abc.ext.example.com each fail one
comparison of parts. A host that is not written as a browser writes it (upper
case, a default port spelled out, a leading zero in the port, a trailing dot)
is not a browser's and matches nothing; so does anything with a path, a query,
userinfo or an encoded character.

Specs: the grammar entry by entry (each refusal named: the star in every wrong
place, the parent too short, an address, http for a domain that is not local —
including two labels that only END like localhost), the marker pattern itself,
and the matcher against the accepted forms and the full list of refusals above,
the port and scheme compared both ways, several entries, and no exception for
anything that is not text.
…sion host call the API (CORS only, never a callback)

Found in a real browser: with the extension host isolated, every call the editor
makes to /api/levelcode/v1/* — auth/exchange, auth/refresh, account/*, ai/* —
comes from the host's per-session origin (https://v--<hash>.ext.example.com;
http://v--<hash>.localhost:8801 locally), not from the editor's page. With only
the list of exact editor origins, that deployment can neither sign in nor chat.

  LEVELCODE_WEB_EXTENSION_HOST_ORIGINS   comma list; each entry an origin, as
                                         LEVELCODE_WEB_EDITOR_ORIGINS takes one,
                                         or one wildcard: https://*.ext.example.com

The grammar and the comparison are Levelcode::WebEditor's (previous commit): the
star is one whole leftmost label, the parent two labels or more (or localhost),
and it stands only for a label the editor itself put there, v--<letters|digits>.
The Origin header is compared by parts, never by suffix or substring.

Two questions, kept apart on the rule:
  #extension_host_origin?(source)  is it one the list names — exactly, or by a wildcard
  #web_cors_origin?(source)        may a page there call the API: an editor origin,
                                   or — while the web edition is on — an extension host

CORS asks the second, in the same block as before: the same resource, headers
(authorization, content-type, accept), methods (get, post, options) and max-age
(600), and still no credentials, nothing exposed, and nothing for /api/v1/* or
the /ai flows. The extension host is CORS-only. #match? does not consult it, so
no extension-host origin — wildcard or exact, however it is spelled — is ever a
valid callback, and the code is never sent there. An origin that is on both lists
is a callback because the editor list says so, and for no other reason.

The web edition being off turns the extension host's CORS off with it. The
setting alone, without LEVELCODE_WEB_EDITOR_ORIGINS, grants nothing: a
half-applied configuration must not open a wildcard on a server that is
otherwise as it was. It is named once in the log, as an entry that is not taken
is: a setting that does nothing, silently, is the kind of typo that is
discovered by the first user.

Unset — production — nothing changes. Checked, not only specced: a transcript of
154 request/origin combinations (extension-host-shaped origins included) and 20
callback probes is byte-identical between origin/develop and this branch.

Specs, added and none edited:
  - the rule: the accepted session and the whole list of refusals (another
    domain, the domain as the prefix of another, an extra label in front of the
    domain, the marker one label too deep, http for the https entry, a port the
    entry had not, a label that only ends like the marker, v--, no marker, upper
    case in the label, the domain and the scheme, a trailing dot, a default port
    written out, userinfo either way, a path, "null"); the local http host with
    its port; exact entries beside wildcards; gating on the web edition.
  - never a callback: the editor's own callback page on every one of those
    origins, exact or wildcard, with the extension host told — false — while the
    editor's own origin and the deep link are as ever.
  - CORS, with a real OPTIONS and POST: allowed for a session (all gateway, auth
    and account paths), nothing for every refusal above, nothing for /api/v1/* or
    /ai/* or thin.ly's paths on a preflight or an actual request, no credentials
    header, the 401 readable; thin.ly's own origins unchanged; and nothing at all
    for a half-applied or unset configuration.
  - through both sign-in gates: a callback on an extension-host origin mints
    nothing and the sign-in ends on the web account.
…account host

Said where the three settings are documented, so that whoever sets
LEVELCODE_WEB_EDITOR_ORIGINS and LEVELCODE_WEB_EXTENSION_HOST_ORIGINS reads it
before choosing the domain:

The editor, and the extension host's wildcard parent, MUST NOT be hosted on a
subdomain of the account host unless cookie auth on /api/levelcode/v1 is refused
for same-site requests.

Why: the account session cookie is SameSite=Lax. That is a SITE boundary, not an
origin one — a request from editor.<account domain> to the account host carries
it. Api::Levelcode::V1::BaseController accepts that cookie when no Bearer token
is sent, and has no CSRF or Origin check (it is an ActionController::API). A page
that runs other people's extensions, on such a subdomain, can therefore send
credentialed requests the visitor's session authorises: a plan change at
POST /checkouts (an immediate prorated charge), a billing-portal session, credits
at /ai. CORS stops it from reading the answer; it does not stop the effect.

The fix is the choice of domain — another registrable domain, as other hosted
editors use — or refusing cookie auth there for same-site requests. Neither is
done here: user_from_session is not touched.

The examples in the header were editor.levelcode.ai and *.ext.levelcode.ai, which
is exactly the shape this warns against; they are example.com now.
@ndemianc
ndemianc marked this pull request as ready for review October 14, 2026 19:09
Copilot AI balanced review requested due to automatic review settings October 14, 2026 19:09

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

Browser exchanges accept unbound authentication codes, and noncanonical IPv6 settings fail browser-origin checks.

2 open findings
What changed in this PR

Adds opt-in backend support for LevelCode’s browser editor alongside existing desktop sign-in.

Changes:

  • Adds web callback validation and configurable editor origins.
  • Enables scoped API CORS for editor and extension-host origins.
  • Exposes a public configuration endpoint with service and request coverage.
File Description
spec/​support/​auth_helpers.rb Adds web-editor configuration helper.
spec/​services/​levelcode/​web_editor_spec.rb Tests origin and wildcard parsing.
spec/​services/​levelcode/​editor_callback_spec.rb Tests web callback policies.
spec/​requests/​levelcode/​web_spec.rb Covers browser-editor sign-in flows.
spec/​requests/​api/​levelcode/​v1/​web_editor_spec.rb Tests configuration responses and caching.
spec/​requests/​api/​levelcode/​v1/​web_editor_cors_spec.rb Tests CORS permissions and boundaries.
spec/​requests/​api/​levelcode/​v1/​auth_spec.rb Covers API web callback redirects.
config/​routes.rb Routes the configuration endpoint.
config/​initializers/​cors.rb Adds scoped web-editor CORS.
app/​services/​levelcode/​web_editor.rb Parses origins, URLs, and wildcards.
app/​services/​levelcode/​editor_callback.rb Extends shared callback and origin policies.
app/​controllers/​levelcode/​web_controller.rb Updates callback documentation only.
app/​controllers/​api/​levelcode/​v1/​web_editor_controller.rb Returns public editor configuration.
app/​controllers/​api/​levelcode/​v1/​auth_controller.rb Updates callback documentation only.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread config/initializers/cors.rb
Comment thread app/services/levelcode/web_editor.rb
Review comment 4275186882. WebEditor.origin lower-cased an IPv6 entry and kept its spelling, so
`https://[::FFFF:127.0.0.1]` was listed as `https://[::ffff:127.0.0.1]` while the browser sends
`https://[::ffff:7f00:1]`: the editor counted as enabled, and its callback and its CORS requests
were refused. An expanded spelling (`[2001:0db8:0000:…:0001]`) had the same problem.

An IPv6 host is now written with the URL Standard's serializer (lower-case hex without leading
zeros, the first longest run of two or more zero pieces as `::`, an embedded IPv4 in hex), before
the local-over-http check, so `http://[0:0:0:0:0:0:0:1]:3000` is the local `[::1]` it is.

Tests: a table of spellings whose expected values are what Node's WHATWG URL writes, the mapped
address's corrected expectation, and a server told of `https://[::FFFF:127.0.0.1]:8443` answering
CORS for, and taking the callback page of, `https://[::ffff:7f00:1]:8443`. All four fail on the
previous web_editor.rb.
…e bound to a PKCE challenge

Review comment 4275186786. OneTimeCode.redeem checks the verifier only when the code was issued
with a challenge, and two endpoints issue codes without one: /auth/login (and signup, oauth's
mint) when no code_challenge is sent, and /auth/web_handoff. The web edition's callback is a page
anyone can link to; such a code, handed to it while someone was signing in, was exchanged with
their verifier and signed them in to the account the code was issued for.

redeem now takes `require_challenge:`, which refuses an unbound code (UnboundCode, an
InvalidVerifier: the answer stays 401 invalid_verifier) after it has been used up. The exchange
asks for it when the request carries Origin, which a browser always sends on a POST and the
desktop editor's fetch never does (checked: Node's fetch sends none). Every sign-in the /ai pages
run for an editor already binds its code, so the web edition's own sign-ins are unaffected. The
desktop and the server-side handoff (/ai/auth/handoff) are unchanged.

Tests, through the real endpoints: an unbound code from /auth/login without code_challenge and
one from /auth/web_handoff, each exchanged from a browser with the victim's verifier, are refused
and burned (both fail on the previous controller); a bound code from a browser and an unbound one
from the desktop still redeem.

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.

🔵 Needs a closer look

Cross-origin authentication depends on deployment-domain isolation that requires final human security review.

0 open findings

2 resolved since last review

🧠 Review effort: Balanced

@ndemianc
ndemianc merged commit bcbbe27 into develop Oct 14, 2026
4 checks passed
@ndemianc
ndemianc deleted the feat/levelcode-web-editor-origin branch October 14, 2026 20:32
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