Repository navigation
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
Conversation
…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.
Contributor
There was a problem hiding this comment.
🟡 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.
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.
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 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.
LEVELCODE_WEB_EDITOR_ORIGINShttps://editor.example.com). A sign-in code is only ever sent to<origin>/callback.html; also the CORS list.LEVELCODE_WEB_EDITOR_URLLEVELCODE_WEB_EXTENSION_HOST_ORIGINShttps://*.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 forlocalhost,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' ownv--<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 duplicatedvscode-*parameter is refused (the page takes the first, a reader that took the last would see another address);vscode-queryis plain pairs and cannot namecode. Both gates (Levelcode::WebController,Api::Levelcode::V1::AuthController) ask this one rule./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
redirect_uriis decoded until stable (three rounds at most, as before) and the decoded string is both what is validated and what is used.#match?does not ask it).SameSite=Lax, a site boundary, andApi::Levelcode::V1::BaseControllertakes 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 makesBaseControllerrefuse 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.
b220418From a browser, the sign-in exchange takes only a PKCE-bound code./auth/loginwithoutcode_challengeand/auth/web_handoffissue 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 carriesOrigin(a browser always sends one on a POST; Node'sfetch, the desktop editor's, never does) now gets 401invalid_verifierfor an unbound code, which is used up either way. The/aisign-in pages already bind every editor code, so the web edition's own sign-ins are unaffected. The desktop and/ai/auth/handoffare unchanged.4716e51An 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);rubocopis clean on the changed files;brakemanreports 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 nocode_challenge: the/aipages 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.brakeman,rubocopand the wholetestjob pass.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) andEditorCallback#match?: matches for the listed origin, does not for another.LEVELCODE_WEB_EDITOR_ORIGINS=http://localhost:8801, email never sent, Stripe answered locally, a throwaway database and Redis db), the real login page fromonetime#98, and the editor in a real headless Chrome. Not signed in anywhere → email code → back in the editor,POST /auth/exchange200,profileandmodels200. 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).OPTIONSanswered with the editor's origin on API paths, with nothing for another origin and nothing on/ai/auth/*.Not run
LEVELCODE_WEB_EXTENSION_HOST_ORIGINS) against this Rails app — the wildcard rule is specs-only here.