From f48b35f22c6abd8ef476de7cd7a35d832e2d6fb9 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sun, 23 Aug 2026 19:19:21 -0400 Subject: [PATCH 1/4] fix(levelcode): /ai 301'd to itself forever on a staging host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/controllers/static_controller.rb | 24 +++++++++++- spec/requests/static_spec.rb | 57 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index e8aa53da..aa0c1bc7 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -6,7 +6,15 @@ class StaticController < ApplicationController # into /ai so levelcode.ai/pricing → /ai/pricing. (Non-HTML bare paths 404, which # is correct — the account app has no non-HTML bare endpoints; the JSON API is # under /api/levelcode/v1/*.) - LEVELCODE_HOSTS = %w[levelcode.ai www.levelcode.ai].freeze + # 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. + DEFAULT_LEVELCODE_HOSTS = "levelcode.ai,www.levelcode.ai" + def self.parse_hosts(raw) + raw.to_s.split(",").map { |h| h.strip.downcase }.reject(&:empty?) + end + LEVELCODE_HOSTS = parse_hosts(ENV.fetch("LEVELCODE_HOSTS", DEFAULT_LEVELCODE_HOSTS)).freeze # Canonical origin LevelCode Cloud lives on. thin.ly bounces any /ai request here so the account app # only ever opens on a LevelCode host. ENV-overridable for staging. LEVELCODE_ORIGIN = ENV.fetch("LEVELCODE_ORIGIN", "https://levelcode.ai").freeze @@ -22,6 +30,11 @@ def ui render "static/ui_levelcode", layout: false else + # Never bounce a host to itself. If LEVELCODE_ORIGIN resolves to the host already being asked, + # a 301 here is an infinite loop — the browser follows it straight back to this action. That is + # what a half-applied staging override produces (LEVELCODE_ORIGIN set, LEVELCODE_HOSTS not), and + # a config mistake should degrade to "serves the app" rather than to a redirect storm. + return render("static/ui_levelcode", layout: false) if ai_path? && origin_is_self? return redirect_to("#{LEVELCODE_ORIGIN}#{request.fullpath}", allow_other_host: true, status: :moved_permanently) if ai_path? render "static/ui", layout: false @@ -46,7 +59,14 @@ def not_found private def levelcode_host? - LEVELCODE_HOSTS.include?(request.host) + LEVELCODE_HOSTS.include?(request.host.to_s.downcase) + end + + # True when the canonical origin IS this request's host — i.e. redirecting would target ourselves. + def origin_is_self? + URI.parse(LEVELCODE_ORIGIN).host.to_s.casecmp?(request.host.to_s) + rescue URI::InvalidURIError + false end def ai_path? diff --git a/spec/requests/static_spec.rb b/spec/requests/static_spec.rb index cf3b2b4e..db046c9c 100644 --- a/spec/requests/static_spec.rb +++ b/spec/requests/static_spec.rb @@ -41,4 +41,61 @@ expect(response).to redirect_to("/ai") end end + + # A half-applied staging override — LEVELCODE_ORIGIN pointed at a tunnel while LEVELCODE_HOSTS still + # listed only production — made /ai/login 301 to itself, forever. The browser follows the redirect + # straight back into this action and the log fills with identical 301s. + describe "when the canonical origin IS the host being asked" do + before do + stub_const("StaticController::LEVELCODE_ORIGIN", "https://thinly.ngrok.app") + host! "thinly.ngrok.app" + end + + it "serves the account app instead of redirecting to itself" do + get "/ai/login" + expect(response).to have_http_status(:ok) + expect(response).not_to have_http_status(:moved_permanently) + end + + it "still serves the shortener shell on a non-/ai path" do + get "/" + expect(response).to have_http_status(:ok) + end + end + + describe "LEVELCODE_HOSTS from the environment" do + it "treats a configured tunnel host as a LevelCode host" do + stub_const("StaticController::LEVELCODE_HOSTS", %w[levelcode.ai www.levelcode.ai thinly.ngrok.app]) + host! "thinly.ngrok.app" + get "/ai/login" + expect(response).to have_http_status(:ok) + end + + it "funnels a bare path into /ai on that host, exactly as production does" do + stub_const("StaticController::LEVELCODE_HOSTS", %w[levelcode.ai thinly.ngrok.app]) + host! "thinly.ngrok.app" + get "/pricing" + expect(response).to redirect_to("/ai/pricing") + end + + it "parses a comma list, trimming and dropping blanks" do + # The REAL parser, not a copy of it — asserting a re-implementation against itself proves + # nothing about the constant the controller actually uses. + expect(StaticController.parse_hosts("levelcode.ai, WWW.LevelCode.ai ,thinly.ngrok.app,")) + .to eq(%w[levelcode.ai www.levelcode.ai thinly.ngrok.app]) + expect(StaticController.parse_hosts("")).to eq([]) + expect(StaticController.parse_hosts(nil)).to eq([]) + end + + it "builds the constant from the environment, not from a literal" do + # Asserted against the SOURCE. The constant is frozen at class load, so nothing a spec sets in + # ENV can rebuild it — and comparing the value to parse_hosts(default) passes just as happily + # when someone hardcodes the array back, because with no override the two are identical. The + # only thing that actually distinguishes them is how the constant is written. + src = Rails.root.join("app/controllers/static_controller.rb").read + expect(src).to match(/LEVELCODE_HOSTS\s*=\s*parse_hosts\(ENV\.fetch\("LEVELCODE_HOSTS"/), + "LEVELCODE_HOSTS must be built from ENV, or a tunnel host can never serve /ai" + expect(StaticController::DEFAULT_LEVELCODE_HOSTS).to include("levelcode.ai") + end + end end From fb9a972f3e03c2f44599e84d89c6344eeaa0efba Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 2 Oct 2026 23:00:43 -0400 Subject: [PATCH 2/4] fix(levelcode): one host predicate for routing and the controller, so a mixed-case Host cannot split them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/controllers/static_controller.rb | 15 ++++++++++- config/routes.rb | 4 ++- spec/requests/static_spec.rb | 39 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index aa0c1bc7..29c95d90 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -15,6 +15,19 @@ def self.parse_hosts(raw) raw.to_s.split(",").map { |h| h.strip.downcase }.reject(&:empty?) end LEVELCODE_HOSTS = parse_hosts(ENV.fetch("LEVELCODE_HOSTS", DEFAULT_LEVELCODE_HOSTS)).freeze + + # THE host predicate — the one place that decides whether a request is on a LevelCode host. + # + # Both the route constraint that keeps the 7-char shortcode lookup off LevelCode hosts + # (config/routes.rb) and #ui's brand switch call this. They used to make the decision separately, + # and only this side normalised case: a `Host: LevelCode.AI` header passed the routing check as + # "not a LevelCode host", so a 7-char bare path such as /pricing resolved as a short code and + # landed on /link-not-found instead of funnelling into /ai. Rack hands `req.host` through exactly + # as sent (only the port is stripped), so the case is real. Normalise here, the same way + # parse_hosts normalises the list, and the two sides cannot disagree. + def self.levelcode_host?(host) + LEVELCODE_HOSTS.include?(host.to_s.downcase) + end # Canonical origin LevelCode Cloud lives on. thin.ly bounces any /ai request here so the account app # only ever opens on a LevelCode host. ENV-overridable for staging. LEVELCODE_ORIGIN = ENV.fetch("LEVELCODE_ORIGIN", "https://levelcode.ai").freeze @@ -59,7 +72,7 @@ def not_found private def levelcode_host? - LEVELCODE_HOSTS.include?(request.host.to_s.downcase) + self.class.levelcode_host?(request.host) end # True when the canonical origin IS this request's host — i.e. redirecting would target ourselves. diff --git a/config/routes.rb b/config/routes.rb index 1e1fe295..5ff0ef35 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -207,7 +207,9 @@ # account app (not a shortener), so a 7-char bare path there (e.g. /pricing) # must fall through to static#ui — which redirects it into the /ai app — instead # of resolving as a short code. - constraints(->(req) { !StaticController::LEVELCODE_HOSTS.include?(req.host) }) do + # Same predicate as StaticController#ui, so routing and dispatch agree on what a LevelCode host is + # (a mixed-case Host header used to slip past this check and resolve /pricing as a short code). + constraints(->(req) { !StaticController.levelcode_host?(req.host) }) do get "/:lookup_code" => "api/v1/links#lookup_code", as: :lookup_code, constraints: { lookup_code: /[a-zA-Z0-9]{7}/ } end match "*ui", to: "static#ui", via: :get, constraints: ->(request) { request.format.html? && !request.path.start_with?("/api/") } diff --git a/spec/requests/static_spec.rb b/spec/requests/static_spec.rb index db046c9c..648994a2 100644 --- a/spec/requests/static_spec.rb +++ b/spec/requests/static_spec.rb @@ -42,6 +42,45 @@ end end + # Rack passes `req.host` through exactly as the client sent it (only the port is stripped), and + # the route constraint that keeps the shortcode lookup off LevelCode hosts compared it raw while + # the controller downcased — so a mixed-case Host header was a LevelCode host to #ui and NOT one + # to routing. A 7-char bare path like /pricing then resolved as a short code before #ui ever ran. + describe "on a LevelCode host sent with a mixed-case Host header" do + before { host! "LevelCode.AI" } + + it "funnels a 7-char bare path into /ai — routing and the controller agree on the host" do + get "/pricing" + expect(response).to redirect_to("/ai/pricing") + expect(response).not_to redirect_to("/link-not-found") + end + + it "renders the LevelCode Cloud shell under /ai" do + get "/ai/account" + expect(response).to have_http_status(:ok) + end + end + + describe "StaticController.levelcode_host? (the ONE predicate routing and #ui share)" do + it "normalises case exactly as parse_hosts normalises the list" do + expect(StaticController.levelcode_host?("levelcode.ai")).to be(true) + expect(StaticController.levelcode_host?("LevelCode.AI")).to be(true) + expect(StaticController.levelcode_host?("WWW.LEVELCODE.AI")).to be(true) + end + + it "is false for the shortener host, nothing, and nil" do + expect(StaticController.levelcode_host?("thin.ly")).to be(false) + expect(StaticController.levelcode_host?("")).to be(false) + expect(StaticController.levelcode_host?(nil)).to be(false) + end + + it "is what the route constraint actually calls — the two sides cannot drift apart again" do + src = Rails.root.join("config/routes.rb").read + expect(src).to include("StaticController.levelcode_host?(req.host)") + expect(src).not_to match(/LEVELCODE_HOSTS\.include\?\(req\.host\)/) + end + end + # A half-applied staging override — LEVELCODE_ORIGIN pointed at a tunnel while LEVELCODE_HOSTS still # listed only production — made /ai/login 301 to itself, forever. The browser follows the redirect # straight back into this action and the log fills with identical 301s. From ca1adb6b2e05766ade9e1a9b93ac59c76ec9186d Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 2 Oct 2026 23:17:50 -0400 Subject: [PATCH 3/4] test(levelcode): pin which shell each host and path renders, before the host policy moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- spec/requests/static_spec.rb | 42 ++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/spec/requests/static_spec.rb b/spec/requests/static_spec.rb index 648994a2..275732e5 100644 --- a/spec/requests/static_spec.rb +++ b/spec/requests/static_spec.rb @@ -3,12 +3,23 @@ # Strict host <-> brand isolation (StaticController#ui): thin.ly serves ONLY the shortener, # levelcode.ai serves ONLY the LevelCode Cloud account app. RSpec.describe "Static host/brand isolation", type: :request do + # The two shells. A 200 cannot tell them apart, and telling them apart is the point of this file. + let(:shortener_shell) { "static/ui" } + let(:account_shell) { "static/ui_levelcode" } + + # Run an example under a different host policy — what LEVELCODE_HOSTS / LEVELCODE_ORIGIN would set. + def with_levelcode_hosts(hosts: StaticController::DEFAULT_LEVELCODE_HOSTS, origin: "https://levelcode.ai") + stub_const("StaticController::LEVELCODE_HOSTS", StaticController.parse_hosts(hosts)) + stub_const("StaticController::LEVELCODE_ORIGIN", origin) + end + describe "on the thin.ly (shortener) host" do before { host! "thin.ly" } it "serves the shortener shell at the root" do get "/" expect(response).to have_http_status(:ok) + expect(response).to render_template(shortener_shell) end it "does NOT open LevelCode Cloud from /ai — it 301s to the canonical LevelCode origin" do @@ -21,6 +32,13 @@ get "/ai/account?tab=usage" expect(response).to redirect_to("https://levelcode.ai/ai/account?tab=usage") end + + # The inverse of every "funnels a 7-char bare path" example below: here it IS a short code. Without + # this, a route constraint that withheld the lookup from every host would pass the whole file. + it "resolves a 7-char bare path as a short code — the lookup is withheld only from LevelCode hosts" do + get "/abc1234" + expect(response).to redirect_to("/link-not-found") + end end describe "on the levelcode.ai (account app) host" do @@ -29,6 +47,7 @@ it "renders the LevelCode Cloud shell under /ai" do get "/ai/account" expect(response).to have_http_status(:ok) + expect(response).to render_template(account_shell) end it "never serves the shortener shell — a bare path funnels into /ai" do @@ -58,6 +77,7 @@ it "renders the LevelCode Cloud shell under /ai" do get "/ai/account" expect(response).to have_http_status(:ok) + expect(response).to render_template(account_shell) end end @@ -86,7 +106,7 @@ # straight back into this action and the log fills with identical 301s. describe "when the canonical origin IS the host being asked" do before do - stub_const("StaticController::LEVELCODE_ORIGIN", "https://thinly.ngrok.app") + with_levelcode_hosts(origin: "https://thinly.ngrok.app") host! "thinly.ngrok.app" end @@ -94,29 +114,37 @@ get "/ai/login" expect(response).to have_http_status(:ok) expect(response).not_to have_http_status(:moved_permanently) + expect(response).to render_template(account_shell) end it "still serves the shortener shell on a non-/ai path" do get "/" expect(response).to have_http_status(:ok) + expect(response).to render_template(shortener_shell) end end - describe "LEVELCODE_HOSTS from the environment" do - it "treats a configured tunnel host as a LevelCode host" do - stub_const("StaticController::LEVELCODE_HOSTS", %w[levelcode.ai www.levelcode.ai thinly.ngrok.app]) + describe "on a host that LEVELCODE_HOSTS adds (a tunnel or staging host)" do + before do + with_levelcode_hosts(hosts: "levelcode.ai,www.levelcode.ai,thinly.ngrok.app") host! "thinly.ngrok.app" + end + + it "serves the account app under /ai" do get "/ai/login" expect(response).to have_http_status(:ok) + expect(response).to render_template(account_shell) end - it "funnels a bare path into /ai on that host, exactly as production does" do - stub_const("StaticController::LEVELCODE_HOSTS", %w[levelcode.ai thinly.ngrok.app]) - host! "thinly.ngrok.app" + # /pricing is 7 characters, so this only passes if ROUTING consults the same list the controller + # does: the tunnel host exists nowhere but in the overridden policy. + it "funnels a 7-char bare path into /ai, exactly as production does" do get "/pricing" expect(response).to redirect_to("/ai/pricing") end + end + describe "LEVELCODE_HOSTS from the environment" do it "parses a comma list, trimming and dropping blanks" do # The REAL parser, not a copy of it — asserting a re-implementation against itself proves # nothing about the constant the controller actually uses. From 21d3bed2ad60784d04b3c07b47470069591ffd22 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 2 Oct 2026 23:20:50 -0400 Subject: [PATCH 4/4] refactor(levelcode): the host policy moves out of StaticController into Levelcode::Hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/controllers/static_controller.rb | 65 ++++++---------- app/mailers/levelcode_billing_mailer.rb | 2 +- app/services/levelcode/hosts.rb | 69 +++++++++++++++++ config/routes.rb | 5 +- spec/requests/static_spec.rb | 54 ++------------ spec/services/levelcode/hosts_spec.rb | 98 +++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 97 deletions(-) create mode 100644 app/services/levelcode/hosts.rb create mode 100644 spec/services/levelcode/hosts_spec.rb diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index 29c95d90..e7050b96 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -1,55 +1,30 @@ class StaticController < ApplicationController - # Hosts that serve the LevelCode Cloud account app (levelcode.html shell) instead of - # the thin.ly shortener SPA. The account app always lives under /ai/* on every - # host (the /ai prefix never collides with the 7-char shortcode lookup), so the - # brand switch is PATH-based; these hosts additionally funnel bare HTML paths - # into /ai so levelcode.ai/pricing → /ai/pricing. (Non-HTML bare paths 404, which - # is correct — the account app has no non-HTML bare endpoints; the JSON API is - # under /api/levelcode/v1/*.) - # 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. - DEFAULT_LEVELCODE_HOSTS = "levelcode.ai,www.levelcode.ai" - def self.parse_hosts(raw) - raw.to_s.split(",").map { |h| h.strip.downcase }.reject(&:empty?) - end - LEVELCODE_HOSTS = parse_hosts(ENV.fetch("LEVELCODE_HOSTS", DEFAULT_LEVELCODE_HOSTS)).freeze - - # THE host predicate — the one place that decides whether a request is on a LevelCode host. - # - # Both the route constraint that keeps the 7-char shortcode lookup off LevelCode hosts - # (config/routes.rb) and #ui's brand switch call this. They used to make the decision separately, - # and only this side normalised case: a `Host: LevelCode.AI` header passed the routing check as - # "not a LevelCode host", so a 7-char bare path such as /pricing resolved as a short code and - # landed on /link-not-found instead of funnelling into /ai. Rack hands `req.host` through exactly - # as sent (only the port is stripped), so the case is real. Normalise here, the same way - # parse_hosts normalises the list, and the two sides cannot disagree. - def self.levelcode_host?(host) - LEVELCODE_HOSTS.include?(host.to_s.downcase) - end - # Canonical origin LevelCode Cloud lives on. thin.ly bounces any /ai request here so the account app - # only ever opens on a LevelCode host. ENV-overridable for staging. - LEVELCODE_ORIGIN = ENV.fetch("LEVELCODE_ORIGIN", "https://levelcode.ai").freeze - # Strict host↔brand isolation: # • a LevelCode host serves ONLY the account app (levelcode shell); bare paths funnel into /ai and # it never renders the thin.ly shortener shell. # • the thin.ly (shortener) host NEVER serves the account app; any /ai request is bounced to the # canonical LevelCode origin so LevelCode Cloud can't be opened from thin.ly/ai. + # Which hosts are LevelCode hosts, and where the canonical origin is, is Levelcode::Hosts' answer. + # + # The account app always lives under /ai/* on every host (the /ai prefix never collides with the + # 7-char shortcode lookup), so the brand switch is PATH-based; LevelCode hosts additionally funnel + # bare HTML paths into /ai so levelcode.ai/pricing → /ai/pricing. (Non-HTML bare paths 404, which + # is correct — the account app has no non-HTML bare endpoints; the JSON API is under + # /api/levelcode/v1/*.) def ui if levelcode_host? return redirect_to(ai_funnel_dest) unless ai_path? render "static/ui_levelcode", layout: false - else - # Never bounce a host to itself. If LEVELCODE_ORIGIN resolves to the host already being asked, - # a 301 here is an infinite loop — the browser follows it straight back to this action. That is - # what a half-applied staging override produces (LEVELCODE_ORIGIN set, LEVELCODE_HOSTS not), and - # a config mistake should degrade to "serves the app" rather than to a redirect storm. - return render("static/ui_levelcode", layout: false) if ai_path? && origin_is_self? - return redirect_to("#{LEVELCODE_ORIGIN}#{request.fullpath}", allow_other_host: true, status: :moved_permanently) if ai_path? + elsif ai_path? + # Never bounce a host to itself. If the canonical origin resolves to the host already being + # asked, a 301 here is an infinite loop — the browser follows it straight back to this action. + # That is what a half-applied staging override produces (LEVELCODE_ORIGIN set, LEVELCODE_HOSTS + # not), and a config mistake should degrade to "serves the app" rather than to a redirect storm. + return render("static/ui_levelcode", layout: false) if origin_is_self? + redirect_to "#{levelcode_hosts.origin}#{request.fullpath}", allow_other_host: true, status: :moved_permanently + else render "static/ui", layout: false end end @@ -71,15 +46,17 @@ def not_found private + def levelcode_hosts + Levelcode::Hosts.current + end + def levelcode_host? - self.class.levelcode_host?(request.host) + levelcode_hosts.include?(request.host) end # True when the canonical origin IS this request's host — i.e. redirecting would target ourselves. def origin_is_self? - URI.parse(LEVELCODE_ORIGIN).host.to_s.casecmp?(request.host.to_s) - rescue URI::InvalidURIError - false + levelcode_hosts.origin?(request.host) end def ai_path? diff --git a/app/mailers/levelcode_billing_mailer.rb b/app/mailers/levelcode_billing_mailer.rb index 64aedbe0..b16e31e4 100644 --- a/app/mailers/levelcode_billing_mailer.rb +++ b/app/mailers/levelcode_billing_mailer.rb @@ -7,7 +7,7 @@ class LevelcodeBillingMailer < ApplicationMailer layout false - # The one Rails host serves the account SPA under /ai (see StaticController::LEVELCODE_HOSTS). + # The one Rails host serves the account SPA under /ai (see Levelcode::Hosts). LEVELCODE_SITE = "https://levelcode.ai" # LevelCode-branded sender, overriding the thin.ly default. Point LEVELCODE_MAIL_FROM at a verified diff --git a/app/services/levelcode/hosts.rb b/app/services/levelcode/hosts.rb new file mode 100644 index 00000000..e06dca49 --- /dev/null +++ b/app/services/levelcode/hosts.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Levelcode + # Which hosts serve the LevelCode Cloud account app, and where it canonically lives. + # + # One Rails app answers for two brands: thin.ly, the link shortener, and LevelCode Cloud, the account + # app (levelcode.html shell) under /ai. This is the one answer to "which brand is this host?". The + # route constraint that keeps the 7-char shortcode lookup off LevelCode hosts and StaticController#ui + # both ask it, so routing and dispatch cannot disagree about a host. + # + # Both settings are ENV-overridable so a staging or tunnel host (ngrok, a preview deploy) can serve + # the account app: + # + # LEVELCODE_HOSTS comma list of hosts that serve the account app instead of the shortener SPA + # LEVELCODE_ORIGIN the canonical origin any /ai request on another host is bounced to + # + # Set them TOGETHER. Overriding the origin alone points the bounce at a host that is still not a + # LevelCode host, and /ai would 301 to itself forever; #origin? is how StaticController#ui notices + # that and renders instead of looping. + class Hosts + DEFAULT_HOSTS = "levelcode.ai,www.levelcode.ai" + DEFAULT_ORIGIN = "https://levelcode.ai" + + class << self + # The policy for this process, read from the environment once. + def current + @current ||= from_env + end + + def from_env(env = ENV) + new( + hosts: env.fetch("LEVELCODE_HOSTS", DEFAULT_HOSTS), + origin: env.fetch("LEVELCODE_ORIGIN", DEFAULT_ORIGIN) + ) + end + end + + attr_reader :hosts, :origin + + # hosts: a comma list — entries are trimmed and case-folded, blanks dropped. + # origin: a URL. Only its host is ever compared; the string itself is the redirect target. + def initialize(hosts:, origin:) + @hosts = hosts.to_s.split(",").map { |h| h.strip.downcase }.reject(&:empty?).freeze + @origin = origin.to_s.freeze + @origin_host = host_of(@origin) + freeze + end + + # Does `host` serve the account app? Case is folded here because Rack hands the Host header through + # exactly as the client sent it (only the port is stripped) — `LevelCode.AI` is a LevelCode host. + def include?(host) + hosts.include?(host.to_s.downcase) + end + + # Is `host` the canonical origin's own host — i.e. would bouncing it to #origin target itself? + def origin?(host) + !@origin_host.nil? && @origin_host.casecmp?(host.to_s) == true + end + + private + + # nil when the origin cannot be parsed as a URL, which then matches no host. + def host_of(url) + URI.parse(url).host.to_s + rescue URI::InvalidURIError + nil + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 5ff0ef35..ac4d65a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -207,9 +207,8 @@ # account app (not a shortener), so a 7-char bare path there (e.g. /pricing) # must fall through to static#ui — which redirects it into the /ai app — instead # of resolving as a short code. - # Same predicate as StaticController#ui, so routing and dispatch agree on what a LevelCode host is - # (a mixed-case Host header used to slip past this check and resolve /pricing as a short code). - constraints(->(req) { !StaticController.levelcode_host?(req.host) }) do + # Levelcode::Hosts is the same answer static#ui acts on, so routing and dispatch cannot disagree. + constraints(->(req) { !Levelcode::Hosts.current.include?(req.host) }) do get "/:lookup_code" => "api/v1/links#lookup_code", as: :lookup_code, constraints: { lookup_code: /[a-zA-Z0-9]{7}/ } end match "*ui", to: "static#ui", via: :get, constraints: ->(request) { request.format.html? && !request.path.start_with?("/api/") } diff --git a/spec/requests/static_spec.rb b/spec/requests/static_spec.rb index 275732e5..3d78d75a 100644 --- a/spec/requests/static_spec.rb +++ b/spec/requests/static_spec.rb @@ -8,9 +8,8 @@ let(:account_shell) { "static/ui_levelcode" } # Run an example under a different host policy — what LEVELCODE_HOSTS / LEVELCODE_ORIGIN would set. - def with_levelcode_hosts(hosts: StaticController::DEFAULT_LEVELCODE_HOSTS, origin: "https://levelcode.ai") - stub_const("StaticController::LEVELCODE_HOSTS", StaticController.parse_hosts(hosts)) - stub_const("StaticController::LEVELCODE_ORIGIN", origin) + def with_levelcode_hosts(hosts: Levelcode::Hosts::DEFAULT_HOSTS, origin: Levelcode::Hosts::DEFAULT_ORIGIN) + allow(Levelcode::Hosts).to receive(:current).and_return(Levelcode::Hosts.new(hosts: hosts, origin: origin)) end describe "on the thin.ly (shortener) host" do @@ -61,10 +60,9 @@ def with_levelcode_hosts(hosts: StaticController::DEFAULT_LEVELCODE_HOSTS, origi end end - # Rack passes `req.host` through exactly as the client sent it (only the port is stripped), and - # the route constraint that keeps the shortcode lookup off LevelCode hosts compared it raw while - # the controller downcased — so a mixed-case Host header was a LevelCode host to #ui and NOT one - # to routing. A 7-char bare path like /pricing then resolved as a short code before #ui ever ran. + # Rack passes `req.host` through exactly as the client sent it (only the port is stripped), so + # routing and the controller both have to fold case — or a 7-char bare path like /pricing + # resolves as a short code before #ui ever runs. describe "on a LevelCode host sent with a mixed-case Host header" do before { host! "LevelCode.AI" } @@ -81,26 +79,6 @@ def with_levelcode_hosts(hosts: StaticController::DEFAULT_LEVELCODE_HOSTS, origi end end - describe "StaticController.levelcode_host? (the ONE predicate routing and #ui share)" do - it "normalises case exactly as parse_hosts normalises the list" do - expect(StaticController.levelcode_host?("levelcode.ai")).to be(true) - expect(StaticController.levelcode_host?("LevelCode.AI")).to be(true) - expect(StaticController.levelcode_host?("WWW.LEVELCODE.AI")).to be(true) - end - - it "is false for the shortener host, nothing, and nil" do - expect(StaticController.levelcode_host?("thin.ly")).to be(false) - expect(StaticController.levelcode_host?("")).to be(false) - expect(StaticController.levelcode_host?(nil)).to be(false) - end - - it "is what the route constraint actually calls — the two sides cannot drift apart again" do - src = Rails.root.join("config/routes.rb").read - expect(src).to include("StaticController.levelcode_host?(req.host)") - expect(src).not_to match(/LEVELCODE_HOSTS\.include\?\(req\.host\)/) - end - end - # A half-applied staging override — LEVELCODE_ORIGIN pointed at a tunnel while LEVELCODE_HOSTS still # listed only production — made /ai/login 301 to itself, forever. The browser follows the redirect # straight back into this action and the log fills with identical 301s. @@ -143,26 +121,4 @@ def with_levelcode_hosts(hosts: StaticController::DEFAULT_LEVELCODE_HOSTS, origi expect(response).to redirect_to("/ai/pricing") end end - - describe "LEVELCODE_HOSTS from the environment" do - it "parses a comma list, trimming and dropping blanks" do - # The REAL parser, not a copy of it — asserting a re-implementation against itself proves - # nothing about the constant the controller actually uses. - expect(StaticController.parse_hosts("levelcode.ai, WWW.LevelCode.ai ,thinly.ngrok.app,")) - .to eq(%w[levelcode.ai www.levelcode.ai thinly.ngrok.app]) - expect(StaticController.parse_hosts("")).to eq([]) - expect(StaticController.parse_hosts(nil)).to eq([]) - end - - it "builds the constant from the environment, not from a literal" do - # Asserted against the SOURCE. The constant is frozen at class load, so nothing a spec sets in - # ENV can rebuild it — and comparing the value to parse_hosts(default) passes just as happily - # when someone hardcodes the array back, because with no override the two are identical. The - # only thing that actually distinguishes them is how the constant is written. - src = Rails.root.join("app/controllers/static_controller.rb").read - expect(src).to match(/LEVELCODE_HOSTS\s*=\s*parse_hosts\(ENV\.fetch\("LEVELCODE_HOSTS"/), - "LEVELCODE_HOSTS must be built from ENV, or a tunnel host can never serve /ai" - expect(StaticController::DEFAULT_LEVELCODE_HOSTS).to include("levelcode.ai") - end - end end diff --git a/spec/services/levelcode/hosts_spec.rb b/spec/services/levelcode/hosts_spec.rb new file mode 100644 index 00000000..fa9b92ac --- /dev/null +++ b/spec/services/levelcode/hosts_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Levelcode::Hosts do + subject(:policy) do + described_class.new(hosts: "levelcode.ai, WWW.LevelCode.ai ,thinly.ngrok.app,", origin: "https://LevelCode.ai") + end + + describe "the host list" do + it "is parsed from a comma list — trimmed, case-folded, blanks dropped" do + expect(policy.hosts).to eq(%w[levelcode.ai www.levelcode.ai thinly.ngrok.app]) + end + + it "is empty for an empty or missing list" do + expect(described_class.new(hosts: "", origin: "https://levelcode.ai").hosts).to eq([]) + expect(described_class.new(hosts: nil, origin: "https://levelcode.ai").hosts).to eq([]) + end + end + + describe "#include?" do + it "folds case — Rack hands the Host header through exactly as the client sent it" do + expect(policy.include?("levelcode.ai")).to be(true) + expect(policy.include?("LevelCode.AI")).to be(true) + expect(policy.include?("WWW.LEVELCODE.AI")).to be(true) + end + + it "is false for the shortener host, nothing, and nil" do + expect(policy.include?("thin.ly")).to be(false) + expect(policy.include?("")).to be(false) + expect(policy.include?(nil)).to be(false) + end + end + + describe "#origin?" do + it "is true for the canonical origin's own host, whatever the case" do + expect(policy.origin?("levelcode.ai")).to be(true) + expect(policy.origin?("LEVELCODE.AI")).to be(true) + end + + it "is false for any other host, and for nil" do + expect(policy.origin?("thin.ly")).to be(false) + expect(policy.origin?("www.levelcode.ai")).to be(false) + expect(policy.origin?(nil)).to be(false) + end + + it "compares the origin's host only — scheme, port and path are not part of the question" do + tunnel = described_class.new(hosts: "", origin: "https://thinly.ngrok.app:8443/ai") + expect(tunnel.origin?("thinly.ngrok.app")).to be(true) + expect(tunnel.origin).to eq("https://thinly.ngrok.app:8443/ai") + end + + it "is false — never an exception — when the origin is not a URL at all" do + broken = described_class.new(hosts: "levelcode.ai", origin: "http://not a url") + expect(broken.origin?("levelcode.ai")).to be(false) + expect(broken.origin?("not a url")).to be(false) + end + end + + # ENV is passed in rather than read, so "the list really comes from the environment" is an + # assertion about behaviour. The constants this replaced were frozen at class load, and the only + # way to check that was a regex over the controller's source. + describe ".from_env" do + it "reads both settings from the environment it is given" do + policy = described_class.from_env("LEVELCODE_HOSTS" => "a.test, B.test", "LEVELCODE_ORIGIN" => "https://a.test") + expect(policy.hosts).to eq(%w[a.test b.test]) + expect(policy.origin).to eq("https://a.test") + end + + it "falls back to the production defaults when a setting is absent" do + policy = described_class.from_env({}) + expect(policy.hosts).to eq(%w[levelcode.ai www.levelcode.ai]) + expect(policy.origin).to eq("https://levelcode.ai") + end + + # LEVELCODE_ORIGIN set, LEVELCODE_HOSTS not: the tunnel is the origin without being a LevelCode + # host. That gap is exactly what StaticController#ui's self-redirect guard exists for. + it "takes each setting independently, so a half-applied override is representable" do + policy = described_class.from_env("LEVELCODE_ORIGIN" => "https://thinly.ngrok.app") + expect(policy.include?("thinly.ngrok.app")).to be(false) + expect(policy.origin?("thinly.ngrok.app")).to be(true) + end + end + + describe ".current" do + it "is built from the environment, once" do + fresh = Class.new(described_class) # its own memo, so the process-wide policy is never disturbed + expect(fresh).to receive(:from_env).once.and_call_original + expect(fresh.current).to equal(fresh.current) + expect(fresh.current).to be_a(described_class) + end + end + + it "is immutable" do + expect(policy).to be_frozen + expect(policy.hosts).to be_frozen + end +end