Skip to content

fix(levelcode): /ai 301'd to itself forever on a staging host - #420

Merged
ndemianc merged 6 commits into
developfrom
fix/levelcode-host-env
Oct 3, 2026
Merged

ndemianc merged 6 commits into
developfrom
fix/levelcode-host-env

Conversation

@ndemianc

@ndemianc ndemianc commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

A dev build pointed at an ngrok tunnel produced an endless redirect loop — /ai/login logging identical 301s to itself:

Started GET "/ai/login" ...
Redirected to https://thinly.ngrok.app/ai/login
Completed 301 Moved Permanently in 0ms

Cause

LEVELCODE_ORIGIN is ENV.fetch-able and commented "ENV-overridable for staging". LEVELCODE_HOSTS was a hardcoded frozen pair.

So you can point the origin at a tunnel, but you cannot add that tunnel to the host list. #ui then takes the non-LevelCode branch and bounces any /ai path to LEVELCODE_ORIGIN — which is now the host it is already serving. The browser follows the 301 straight back into the same action.

The staging override was only ever half-usable.

Two fixes, because either alone leaves a trap

  1. LEVELCODE_HOSTS parses a comma list from ENV, so a tunnel or preview host can be declared a LevelCode host and serve the account app properly — bare-path funnelling included.
  2. #ui refuses to redirect a host to itself. Worth having even when configured correctly: a config mistake should degrade to serves the app, not to a redirect storm that reads like an application bug.
LEVELCODE_ORIGIN=https://thinly.ngrok.app
LEVELCODE_HOSTS=levelcode.ai,www.levelcode.ai,thinly.ngrok.app

Where the policy lives

Review found that routing and the controller each decided "is this a LevelCode host?" on their own, and only the controller folded case. Rack passes the Host header through exactly as sent (only the port is stripped), so Host: LevelCode.AI was a LevelCode host to #ui and not one to the route constraint — a 7-char bare path like /pricing resolved as a short code and landed on /link-not-found before #ui ever ran.

Rather than have routes.rb reach into a controller, the policy is now its own small immutable value, Levelcode::Hosts (app/services/levelcode/hosts.rb):

#hosts, #origin the parsed list and the canonical origin
#include?(host) does this host serve the account app? — case-folded
#origin?(host) is this host the canonical origin itself? — what the self-redirect guard asks
.from_env(env = ENV) builds one from LEVELCODE_HOSTS / LEVELCODE_ORIGIN
.current the process-wide policy, read once

StaticController#ui and the route constraint both ask Levelcode::Hosts.current, so they cannot disagree about a host. The controller no longer carries constants or class methods (94 → 71 lines), and the guard is an elsif ai_path? branch rather than two early returns that each re-tested the path. The env var names and defaults are unchanged.

How it is tested

No assertions against source text. An earlier revision of this PR had two — a regex over the controller's source to prove the list came from ENV (the constant was frozen at class load, so nothing else could), and a string match on which method routes.rb called. Both are replaced by behaviour:

  • .from_env takes the environment as an argument, so reading it is directly testable.
  • .current is checked on a throwaway subclass, which has its own memo and never disturbs the real one.
  • Routing and the controller share one policy is the tunnel example: its host exists only in the overridden policy, and /pricing is seven characters.

Every request example names the shell it expects. Five of them used to assert a bare 200, which cannot tell the shortener shell from the account shell — a self-redirect guard that rendered the wrong one passed the whole file. There is also now an inverse example: on thin.ly a 7-char path is a short code. Without it, a constraint that withheld the lookup from every host satisfied everything.

spec/requests/static_spec.rb 13 examples · spec/services/levelcode/hosts_spec.rb 13 examples.

Verification

  • Full suite 1058 examples, 0 failures · rubocop clean · brakeman 0 warnings under CI's invocation · zeitwerk:check good.
  • The refactor is behaviour-preserving, and the history shows it: ca1adb6 pins the behaviour against the old code; 21d3bed moves the code, and the 13 request examples pass against it untouched — that file's only edits are one helper's body, a comment, and the unit examples that moved.
  • Each piece broken in turn, caught only by its own examples:
Mutation Fails
the policy stops folding case 2
routing builds its own policy instead of sharing the controller's 1 — the tunnel example
the controller keeps a private host list 2
origin? never matches 4
the guard line removed from #ui 1
.current hardcoded to the defaults 1
(against the old code) the guard renders the wrong shell 1 — previously 0

Commits, in reading order

  1. f48b35f fix — LEVELCODE_HOSTS from ENV, and the self-redirect guard.
  2. fb9a972 fix — review: one host predicate for routing and the controller.
  3. ca1adb6 test — pin which shell each host and path renders.
  4. 21d3bed refactor — the policy moves into Levelcode::Hosts.

develop is merged in (no overlap with these files).

Reported as an infinite redirect loop: /ai/login on a tunnel host logging identical 301s
to itself until the browser gave up.

CAUSE. LEVELCODE_ORIGIN is ENV-overridable "for staging"; LEVELCODE_HOSTS was a hardcoded
frozen pair. Point the origin at a tunnel without being able to add that tunnel to the
host list and #ui takes the non-LevelCode branch, which bounces any /ai path to
LEVELCODE_ORIGIN — now the same host it is already serving. The browser follows the 301
straight back into the same action. The override was only ever half-usable.

TWO FIXES, because either alone leaves a trap:

  1. LEVELCODE_HOSTS now parses a comma list from ENV, so a staging or tunnel host can be
     declared a LevelCode host and serve the account app properly — bare-path funnelling
     and all.

  2. #ui refuses to redirect a host to itself. Even correctly configured this is worth
     having: a config mistake should degrade to "serves the app" rather than to a redirect
     storm that looks like an application bug.

TWO SPECS THAT PROVED NOTHING, FIXED. Writing these I caught myself twice:

  - "parses a comma list the way the constant is built" re-implemented the split/strip
     expression and asserted the copy matched itself. Extracted the real parse_hosts and
     the spec now calls it.
  - "builds the constant from the environment" compared LEVELCODE_HOSTS to
     parse_hosts(default) — which is satisfied just as happily by a hardcoded array,
     because with no override the two values are identical. The constant is frozen at
     class load so no spec can rebuild it from ENV; the assertion is now against the
     SOURCE, which is the only thing that distinguishes the two.

Bypass-verified: the self-redirect guard removed (the reported loop); origin_is_self?
forced false; and the hosts hardcoded again — the last one only caught after the spec was
fixed to be capable of catching it.

12 examples, 0 failures.
Copilot AI lite review requested due to automatic review settings August 23, 2026 23:19

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.

Pull request overview

Adds environment-configurable LevelCode hosts and prevents self-redirect loops for staging deployments.

Changes:

  • Parses LEVELCODE_HOSTS from the environment.
  • Adds self-redirect protection.
  • Expands request specs for host parsing and tunnel behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Review
app/controllers/static_controller.rb Host configuration and redirect protection added. Moderate issue: routing does not apply the same host normalization, causing mixed-case hosts to bypass the bare-path funnel.
spec/requests/static_spec.rb Adds coverage for host overrides, parsing, and redirect-loop prevention.
Suppressed comments (3)

app/controllers/static_controller.rb:67

  • This check compares only hostnames, but URI#host drops the origin's port (and the scheme is ignored too). For example, with LEVELCODE_ORIGIN=https://preview.example:8443, a request to http://preview.example:3000/ai/login is not a self-redirect—the target is a different origin—yet this guard renders the shell on port 3000 instead of redirecting to the configured canonical origin. Compare the full effective origin (scheme, host, and port) before suppressing the redirect.
    URI.parse(LEVELCODE_ORIGIN).host.to_s.casecmp?(request.host.to_s)

app/controllers/static_controller.rb:69

  • ENV.fetch accepts an explicitly empty LEVELCODE_ORIGIN. For that value URI.parse yields no host, so this predicate is false and the redirect branch builds /ai/..., a relative URL on the same host; the infinite redirect loop remains. Validate the origin at boot or treat a missing/invalid host as a non-redirecting configuration before calling redirect_to.
    URI.parse(LEVELCODE_ORIGIN).host.to_s.casecmp?(request.host.to_s)
  rescue URI::InvalidURIError
    false

app/controllers/static_controller.rb:12

  • This new environment variable does not by itself make a tunnel reachable in production/staging: config/environments/production.rb:114-129 still hardcodes config.hosts and does not include LEVELCODE_HOSTS. Rails HostAuthorization rejects a host such as thinly.ngrok.app before routing reaches this controller, so the documented two-variable setup still returns 403 unless a separate host allowlist is configured. Wire the configured hosts into config.hosts or document that additional requirement.
  # ENV-overridable so a staging or tunnel host (ngrok, a preview deploy) can serve the account app.
  # MUST be set together with LEVELCODE_ORIGIN: overriding the origin alone points the bounce at a host
  # that is still not recognised as a LevelCode host, and /ai then 301s to itself forever. The
  # self-redirect guard in #ui makes that misconfiguration render instead of loop, but set both.

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

Comment thread app/controllers/static_controller.rb Outdated
… a mixed-case Host cannot split them

Review finding on #420 (Copilot), confirmed: the controller downcased request.host before
checking LEVELCODE_HOSTS, but the route constraint that keeps the 7-char shortcode lookup
off LevelCode hosts compared req.host raw. Rack 3.2 hands req.host through exactly as the
client sent it (only the port is stripped), so `Host: LevelCode.AI` was a LevelCode host to
#ui and NOT one to routing — and a 7-char bare path such as /pricing resolved as a short
code and landed on /link-not-found before #ui ever ran.

The decision now lives in one place, StaticController.levelcode_host?(host), which
normalises exactly as parse_hosts normalises the list. The instance predicate and the
route constraint both call it; neither compares the list on its own any more.

Pinned from both sides: a request-level example sends the mixed-case header on a 7-char
bare path and expects the /ai funnel (not /link-not-found); unit examples cover the
predicate's case folding, the shortener host, "" and nil; and a source-level example
asserts routes.rb calls the predicate and no longer touches LEVELCODE_HOSTS directly.

Verified: static_spec 17 examples; full suite 1043 examples, 0 failures; rubocop clean.
Reverted in turn: only the routes line -> exactly the two routing-dependent examples fail;
only the predicate's downcase -> exactly the two case-dependent examples fail.
…he host policy moves

Spec-only. Five examples here asserted a bare 200, which cannot tell the shortener shell
from the account shell — so a self-redirect guard that rendered the wrong one passed the
whole file. Each now names its template.

Also adds the inverse of every "funnels a 7-char bare path" example: on thin.ly the same
shape of path IS a short code. Without it, a route constraint that withheld the lookup from
every host satisfied every example.

The two overrides of the host policy go through one helper, with_levelcode_hosts, so the
examples do not have to change when the seam underneath them does.

Verified against the current, unrefactored code: 18 examples, 0 failures. Then broken four
ways, each caught only by the examples it should be: the guard rendering the wrong shell
-> 1 (previously 0); a LevelCode host rendering the shortener shell -> 3; the shortener
host rendering the account shell -> 2; the lookup withheld from every host -> the new
inverse example.
…to Levelcode::Hosts

No behaviour change. The 13 request examples pinned in the previous commit pass against
this one untouched — the only edits to that file are the body of one helper, a comment,
and the five unit examples that moved.

What was wrong with the shape. "Which hosts are LevelCode hosts, and where is the
canonical origin" had accreted inside a controller as two constants and two class methods,
interleaved because one constant needed a method defined above it. config/routes.rb
reached into that controller for its constraint, a mailer comment pointed at it, and
because the constants were frozen at class load the only way to test "the list really
comes from the environment" was a regex over the controller's source. A second
source-text assertion checked which method routes.rb called.

Levelcode::Hosts is that policy as a small immutable value: hosts, origin, #include?(host)
and #origin?(host), built by .from_env(env) and memoised as .current. The controller and
the route constraint both ask .current, so they cannot disagree about a host — which is
the property the review on this PR was about.

The two source-text assertions are gone, replaced by behaviour:
  - .from_env takes the environment as an argument, so reading it is directly testable;
  - .current is checked on a throwaway subclass, which has its own memo;
  - "routing and the controller share one policy" is the tunnel example: its host exists
    only in the overridden policy, and /pricing is seven characters.

In #ui the self-redirect guard becomes an `elsif ai_path?` branch instead of two early
returns that each re-tested the path. origin_is_self? no longer re-parses a boot-time
constant on every request. The mailer comment named a constant that no longer exists.

Semantics are carried over exactly, including where the list is trimmed (parse) and where
it is not (lookup). LEVELCODE_HOSTS and LEVELCODE_ORIGIN keep their names and defaults.

Verified: static 13 + hosts 13 examples; full suite 1058 examples, 0 failures; rubocop
clean; brakeman 0 warnings under CI's invocation; zeitwerk:check good. Six mutations, each
caught only by its own examples: no case folding -> 2; routing building its own policy
-> 1; the controller keeping a private list -> 2; origin? blind -> 4; the guard removed
from #ui -> 1; .current hardcoded to the defaults -> 1.
@ndemianc
ndemianc merged commit 9ae8c13 into develop Oct 3, 2026
3 checks passed
@ndemianc
ndemianc deleted the fix/levelcode-host-env branch October 3, 2026 03:29
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