From f41af1966e5fc46ff3fe37019c30826304997b67 Mon Sep 17 00:00:00 2001 From: Jeffrey Hales Date: Sun, 25 Oct 2026 13:13:46 -0700 Subject: [PATCH] fix(routing): enforce slashless public URLs --- www/reports/nginx-rewrites.conf | 14 +++++++++ ...-slashless-url-normalization-2026-10-25.md | 27 +++++++++++++++++ www/tools/phase10-artifact-verify.mjs | 14 +++++++++ www/tools/phase10-artifact.test.mjs | 8 +++++ www/tools/phase10-release-rehearsal.mjs | 29 +++++++++++++++---- 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 www/reports/phase-10-slashless-url-normalization-2026-10-25.md diff --git a/www/reports/nginx-rewrites.conf b/www/reports/nginx-rewrites.conf index f410a3d..1ddd080 100644 --- a/www/reports/nginx-rewrites.conf +++ b/www/reports/nginx-rewrites.conf @@ -1,3 +1,17 @@ +## Include these directives inside the public HTTPS server block, before the +## generic static `location /` handler. Root (`/`) is the only trailing-slash +## public URL; every other terminal slash receives a query-preserving 308. +location ~ ^(.+?)/+$ { + return 308 $1$is_args$args; +} + +## The static handler must serve the generated directory artifact at its +## slashless public path. Do not use `$uri/`, which permits nginx's automatic +## directory-slash redirect before `$uri/index.html` can be served. +location / { + try_files $uri $uri/index.html $uri.html =404; +} + rewrite ^/aba$ /aba-therapy permanent; rewrite ^/autismevaluations$ /autism-evaluations permanent; rewrite ^/learnersocialclub$ /learner-social-club permanent; diff --git a/www/reports/phase-10-slashless-url-normalization-2026-10-25.md b/www/reports/phase-10-slashless-url-normalization-2026-10-25.md new file mode 100644 index 0000000..0d86b30 --- /dev/null +++ b/www/reports/phase-10-slashless-url-normalization-2026-10-25.md @@ -0,0 +1,27 @@ +# Phase 10 Slashless URL Normalization — 2026-10-25 + +## Scope + +This local-only change aligns static-serving behavior with the live site's URL convention: root remains `/`; every other public page is served at a slashless path such as `/aba-therapy` or `/lp/aba-therapy`. + +Astro was already configured with `trailingSlash: 'never'`. Generated canonicals, hreflang URLs, sitemap entries, and source links therefore use slashless paths. The remaining deployment requirement is that a static web server must serve a generated `path/index.html` artifact for a slashless request without first redirecting to a directory URL. + +## Required server contract + +The reviewed nginx template in `reports/nginx-rewrites.conf` must be included inside the public HTTPS server block before general static routing. It requires the server to: + +- return a query-preserving `308` from `/path/` to `/path`; +- serve `/path` from `path/index.html` without nginx's automatic directory-slash redirect; +- preserve query strings, for example `/search/?term=aba` redirects to `/search?term=aba`; +- keep root `/` unchanged; and +- retain explicit legacy route redirects after URL normalization. + +This is a deployment template only. No Contabo, nginx, DNS, TLS, Cloudflare, or live-site configuration was changed. + +## Regression protection + +`tools/phase10-artifact-verify.mjs` now rejects generated root-relative `href` or `action` URLs with a terminal slash, excluding root `/`. `tools/phase10-release-rehearsal.mjs` models the required static behavior on loopback: slashless routes return `200`, trailing variants return `308`, and query strings survive the redirect. + +## Acceptance still required + +The template must be applied and tested by the approved hosting owner on the actual Contabo staging topology. Phase 10 must verify HTTPS, canonical host behavior, cache headers, redirects, assets, real 404s, forms/counter isolation, and rollback before any production cutover. This change does not authorize deployment. diff --git a/www/tools/phase10-artifact-verify.mjs b/www/tools/phase10-artifact-verify.mjs index 13a4223..86a40b9 100644 --- a/www/tools/phase10-artifact-verify.mjs +++ b/www/tools/phase10-artifact-verify.mjs @@ -24,6 +24,18 @@ function formActions(html) { match[1].match(/\baction=["']([^"']*)["']/i)?.[1] ?? null); } +function rootRelativeUrls(html) { + const urls = []; + for (const match of html.matchAll(/\b(?:href|action)=["']([^"']+)["']/gi)) urls.push(match[1]); + return urls; +} + +function hasTerminalSlash(url) { + if (!url.startsWith('/') || url.startsWith('//')) return false; + const pathname = url.split(/[?#]/, 1)[0]; + return pathname !== '/' && pathname.endsWith('/'); +} + function unsafeSignals(html) { const signals = []; if (/]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe'); @@ -52,10 +64,12 @@ export async function verifyArtifact(root, mode = 'staging') { const title = html.match(/([^<]*)<\/title>/i)?.[1]?.trim() ?? ''; const actions = formActions(html); const nonSearchActions = actions.filter((action) => action && action !== '/search'); + const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash); const signals = unsafeSignals(html); if (!title) errors.push(`${route}: missing title`); if (!canonical) errors.push(`${route}: missing canonical`); if (!robots) errors.push(`${route}: missing robots directive`); + if (trailingSlashUrls.length) errors.push(`${route}: root-relative URL(s) end in a slash: ${trailingSlashUrls.join(', ')}`); if (mode === 'staging' && robots !== 'noindex,nofollow') errors.push(`${route}: staging robots is ${robots ?? 'missing'}`); if (canonical) { const prior = canonicalMap.get(canonical); diff --git a/www/tools/phase10-artifact.test.mjs b/www/tools/phase10-artifact.test.mjs index 51d019b..b99f166 100644 --- a/www/tools/phase10-artifact.test.mjs +++ b/www/tools/phase10-artifact.test.mjs @@ -49,6 +49,14 @@ test('artifact rejects live form mode and production API origin', async () => { assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals'))); }); +test('artifact rejects root-relative URLs with terminal slashes', async () => { + const root = await fixture('staging'); + await fs.appendFile(path.join(root, 'index.html'), '<a href="/lp/aba-therapy/">Campaign</a>'); + const result = await verifyArtifact(root, 'staging'); + assert.equal(result.passed, false); + assert.ok(result.errors.some((error) => error.includes('root-relative URL(s) end in a slash'))); +}); + test('artifact digest is deterministic for unchanged files', async () => { const root = await fixture('staging'); const first = await verifyArtifact(root, 'staging'); diff --git a/www/tools/phase10-release-rehearsal.mjs b/www/tools/phase10-release-rehearsal.mjs index 6d0e8d6..fd0b1f1 100644 --- a/www/tools/phase10-release-rehearsal.mjs +++ b/www/tools/phase10-release-rehearsal.mjs @@ -23,9 +23,15 @@ await fs.symlink(releaseA, current); const contentTypes = { '.html': 'text/html', '.xml': 'application/xml', '.txt': 'text/plain', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' }; const server = http.createServer(async (request, response) => { try { - const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://127.0.0.1').pathname); - const clean = pathname.replace(/^\/+/, ''); - const candidates = [clean, clean.endsWith('/') ? `${clean}index.html` : `${clean}/index.html`, clean === '' ? 'index.html' : null].filter(Boolean); + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + const pathname = requestUrl.pathname; + if (pathname !== '/' && /\/+$/u.test(pathname)) { + response.writeHead(308, { location: `${pathname.replace(/\/+$/u, '')}${requestUrl.search}`, 'cache-control': 'no-store' }); + response.end(); + return; + } + const clean = decodeURIComponent(pathname).replace(/^\/+/, ''); + const candidates = [clean, clean === '' ? 'index.html' : `${clean}/index.html`, clean === '' ? null : `${clean}.html`].filter(Boolean); let target; for (const candidate of candidates) { const candidatePath = path.resolve(current, candidate); @@ -41,7 +47,7 @@ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const origin = `http://127.0.0.1:${address.port}`; const request = (pathname) => new Promise((resolve, reject) => { - const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], body: Buffer.concat(chunks).toString('utf8') })); }); + const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], location: res.headers.location, body: Buffer.concat(chunks).toString('utf8') })); }); req.on('error', reject); }); const checks = []; @@ -49,7 +55,20 @@ const check = async (name, fn) => { await fn(); checks.push(name); }; await check('staging artifact verifier', async () => assert.equal((await verifyArtifact(releaseA, 'staging')).passed, true)); await check('root route', async () => assert.equal((await request('/')).status, 200)); await check('locale route', async () => assert.equal((await request('/es')).status, 200)); -await check('slashless and trailing-slash routes', async () => { assert.equal((await request('/library')).status, 200); assert.equal((await request('/library/')).status, 200); }); +await check('slashless routes serve and trailing-slash variants redirect', async () => { + assert.equal((await request('/library')).status, 200); + const libraryTrailing = await request('/library/'); + assert.equal(libraryTrailing.status, 308); + assert.equal(libraryTrailing.location, '/library'); + const landingTrailing = await request('/lp/aba-therapy/'); + assert.equal(landingTrailing.status, 308); + assert.equal(landingTrailing.location, '/lp/aba-therapy'); +}); +await check('trailing-slash redirects preserve query strings', async () => { + const response = await request('/search/?term=aba&lang=es'); + assert.equal(response.status, 308); + assert.equal(response.location, '/search?term=aba&lang=es'); +}); await check('query strings preserved by static serving', async () => assert.equal((await request('/search?term=aba&lang=es')).status, 200)); await check('asset MIME type', async () => assert.match((await request('/assets/images/favicon.svg')).type, /image\/svg/)); await check('robots and empty staging sitemap', async () => { const robots = await request('/robots.txt'); const sitemap = await request('/sitemap.xml'); assert.equal(robots.status, 200); assert.equal((sitemap.body.match(/<url>/g) ?? []).length, 0); });