From d3e20df1d53720c3baac773b9436adff8ae73f02 Mon Sep 17 00:00:00 2001 From: Luca Martial <48870843+luca-martial@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:05:22 -0400 Subject: [PATCH] fix(docs-site): stop doubling the /ktx basePath on alias-host redirects (#263) ktx.sh/ and docs.ktx.sh/ redirected to https://docs.kaelio.com/ktx/ktx/docs/... (note the doubled /ktx) and 404'd. The host-agnostic `source: "/"` redirect ran before the alias-host canonicalizers, so it injected the /ktx basePath into the path on the alias domains, which the alias catch-all then prepended a second time. Reorder redirects() so alias-host canonicalization runs first, leaving the generic root/docs rules for the local/canonical host only. The /stars exclusion stays because redirects run before beforeFiles rewrites. Add Host-spoofing regression tests (the prior tests only used localhost, which never exercised the alias-host rules) and remove the vestigial website/vercel.json, which the live ktx.sh routing already bypasses. Co-authored-by: Claude Opus 4.8 --- docs-site/next.config.mjs | 51 ++++++++------ docs-site/tests/docs-index-route.test.mjs | 81 +++++++++++++++++++++++ website/vercel.json | 10 --- 3 files changed, 110 insertions(+), 32 deletions(-) delete mode 100644 website/vercel.json diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs index 380dba85..e47a0cc7 100644 --- a/docs-site/next.config.mjs +++ b/docs-site/next.config.mjs @@ -30,7 +30,36 @@ const config = { }; }, async redirects() { + // Alias-host canonicalization MUST come before the generic root/docs + // redirects below. Those generic rules have no host guard, so if they ran + // first they would inject a "/ktx" basePath into the path on the alias + // hosts, which the alias catch-alls would then prepend a second time — + // producing https://docs.kaelio.com/ktx/ktx/docs/... Redirects also run + // before beforeFiles rewrites, so the ktx.sh catch-all must exclude + // /stars* to let the stars dashboard rewrite proxy through. return [ + { + source: "/slack", + has: [{ type: "host", value: "ktx.sh" }], + destination: + "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ", + permanent: false, + basePath: false, + }, + { + source: "/:path*", + has: [{ type: "host", value: "docs.ktx.sh" }], + destination: "https://docs.kaelio.com/ktx/:path*", + permanent: true, + basePath: false, + }, + { + source: "/:path((?!stars(?:/|$)).*)", + has: [{ type: "host", value: "ktx.sh" }], + destination: "https://docs.kaelio.com/ktx/:path", + permanent: true, + basePath: false, + }, { source: "/", destination: "/ktx/docs/getting-started/introduction", @@ -43,28 +72,6 @@ const config = { permanent: false, basePath: false, }, - { - source: "/:path*", - has: [{ type: "host", value: "docs.ktx.sh" }], - destination: "https://docs.kaelio.com/ktx/:path*", - permanent: true, - basePath: false, - }, - { - source: "/slack", - has: [{ type: "host", value: "ktx.sh" }], - destination: - "https://join.slack.com/t/ktxcommunity/shared_invite/zt-3y9b44m1x-LVyNNJD5nwaZHq4XS29LMQ", - permanent: false, - basePath: false, - }, - { - source: "/:path((?!stars(?:/|$)).*)", - has: [{ type: "host", value: "ktx.sh" }], - destination: "https://docs.kaelio.com/ktx/:path", - permanent: true, - basePath: false, - }, ]; }, }; diff --git a/docs-site/tests/docs-index-route.test.mjs b/docs-site/tests/docs-index-route.test.mjs index fdd8ec81..6fac0e3c 100644 --- a/docs-site/tests/docs-index-route.test.mjs +++ b/docs-site/tests/docs-index-route.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFile, writeFile } from "node:fs/promises"; +import http from "node:http"; +import https from "node:https"; import { dirname, join } from "node:path"; import { createServer } from "node:net"; import { after, before, test } from "node:test"; @@ -100,6 +102,37 @@ after(async () => { } }); +// Node's fetch (undici) overwrites the Host header with the connection host, +// so the alias-host redirect rules never match. The low-level http(s) client +// sends Host verbatim, which is what the alias canonicalization keys off of. +function requestWithHost(hostHeader, path) { + const target = new URL(docsSiteUrl); + const client = target.protocol === "https:" ? https : http; + const port = + target.port || (target.protocol === "https:" ? "443" : "80"); + + return new Promise((resolve, reject) => { + const request = client.request( + { + hostname: target.hostname, + port, + path, + method: "GET", + headers: { Host: hostHeader }, + }, + (response) => { + response.resume(); + resolve({ + status: response.statusCode, + location: response.headers.location, + }); + }, + ); + request.on("error", reject); + request.end(); + }); +} + test("/ktx/docs redirects to the docs introduction", async () => { const response = await fetch(`${docsSiteUrl}${docsBasePath}/docs`, { redirect: "manual", @@ -141,3 +174,51 @@ test("/ktx/api/search returns docs search results", async () => { "search should return at least one docs result", ); }); + +test("ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => { + const root = await requestWithHost("ktx.sh", "/"); + assert.equal(root.status, 308); + assert.equal(root.location, "https://docs.kaelio.com/ktx/"); + assert.ok( + !root.location.includes("/ktx/ktx"), + "the basePath must not be doubled", + ); + + const page = await requestWithHost( + "ktx.sh", + "/docs/getting-started/quickstart", + ); + assert.equal(page.status, 308); + assert.equal( + page.location, + "https://docs.kaelio.com/ktx/docs/getting-started/quickstart", + ); +}); + +test("docs.ktx.sh canonicalizes to a single /ktx basePath on the docs host", async () => { + const root = await requestWithHost("docs.ktx.sh", "/"); + assert.equal(root.status, 308); + assert.equal(root.location, "https://docs.kaelio.com/ktx"); + assert.ok( + !root.location.includes("/ktx/ktx"), + "the basePath must not be doubled", + ); + + const page = await requestWithHost("docs.ktx.sh", "/llms.txt"); + assert.equal(page.status, 308); + assert.equal(page.location, "https://docs.kaelio.com/ktx/llms.txt"); +}); + +test("ktx.sh keeps the /slack and /stars exceptions", async () => { + const slack = await requestWithHost("ktx.sh", "/slack"); + assert.equal(slack.status, 307); + assert.match(slack.location, /^https:\/\/join\.slack\.com\//); + + // /stars is proxied by a beforeFiles rewrite, so the apex catch-all must not + // canonicalize it to the docs host. + const stars = await requestWithHost("ktx.sh", "/stars"); + assert.ok( + !(stars.location ?? "").startsWith("https://docs.kaelio.com"), + "the stars dashboard must not be redirected to the docs host", + ); +}); diff --git a/website/vercel.json b/website/vercel.json deleted file mode 100644 index 7aa86301..00000000 --- a/website/vercel.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "redirects": [ - { - "source": "/:path*", - "has": [{ "type": "host", "value": "ktx.sh" }], - "destination": "https://docs.ktx.sh/:path*", - "permanent": true - } - ] -}