From f1b4cd903504941421c1e184ac7e58a109773f73 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:29:39 +0200 Subject: [PATCH 01/20] fix(invite): serve the landing from the asset binding, not from the redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invite or promo link never reached the landing page. _routes.json hands the request to the Function first, so the 200-rewrite in _redirects never runs, and the asset lookup behind context.next() answers for the path as asked: 404, with the site's own 404 page. The older rewrite injected the campaign's title into that page, which is why a shared link looked roughly right in a browser while being the wrong document with the wrong status — and why every share crawler dropped it before reading a tag. Measured on a preview deploy, three lookups side by side on /invite/AB12CD: context.next() 404, 2693 bytes (the 404 page) context.next(request for index.html) 308 env.ASSETS.fetch(index.html) 200, 9827 bytes (the shell) So the shell is read from the asset binding by name, injected, and answered 200 — for HEAD as well as GET. Without the binding, or if the file under that name is not the shell any more, the platform's own answer stands rather than an invented one. functions/_middleware.js is under coverage at 100%. It was outside it while it decided which page every visitor and crawler sees, which is how the previous attempt reached production. --- functions/_middleware.js | 90 +++++++++--- functions/lib/itunes-banner.js | 26 ++++ test/middleware.test.mjs | 244 +++++++++++++++++++++++++++++++++ vitest.config.mjs | 12 +- 4 files changed, 351 insertions(+), 21 deletions(-) create mode 100644 test/middleware.test.mjs diff --git a/functions/_middleware.js b/functions/_middleware.js index b1d59d2..8c2d4e6 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -1,32 +1,82 @@ /** - * Rewrite invite/promo HTML so apple-itunes-app already carries - * app-argument, og:url / canonical / twitter:url already name the - * landing URL, og:title / twitter:title / og:description already name - * the campaign code, ?lang=en already sets html lang / og:locale, and - * Facebook App Links are present. Safari and share - * crawlers snapshot those from the HTML bytes before - * /js/invite-banner.js and invite.js run. + * Serve the invite and promo landings for the paths people actually share, and + * rewrite their metadata so a share crawler sees the campaign in the HTML + * bytes: apple-itunes-app already carries app-argument, og:url / canonical / + * twitter:url already name the landing URL, og:title / twitter:title / + * og:description already name the code, ?lang=en already sets html lang and + * og:locale, and the Facebook App Links are present. Safari and every crawler + * snapshot those before /js/invite-banner.js and invite.js run. + * * public/ stays generic; this is not a site-wide renderer. */ -import { injectLandingFromRequestUrl, shouldRewriteItunesBanner } from './lib/itunes-banner.js'; +import { + injectLandingFromRequestUrl, + isLandingShell, + shouldRewriteItunesBanner, +} from './lib/itunes-banner.js'; + +/** + * The shell each landing path is served from. `_redirects` names the same two + * files, but its 200-rewrites never run for these paths: `_routes.json` hands + * the request to this Function first, and the asset lookup behind + * context.next() resolves the path as asked rather than as rewritten. + * + * Measured on the deploy, with the three lookups side by side on + * /invite/AB12CD: context.next() answers 404 with the site's own 404 page, + * context.next() handed a request for /invite/index.html answers 308, and + * env.ASSETS.fetch() of that same file answers 200 with the shell. So the + * shell is read from the asset binding, by name. + */ +const LANDING_SHELL = { invite: '/invite/index.html', promo: '/promo/index.html' }; export async function onRequest(context) { const url = new URL(context.request.url); if (!shouldRewriteItunesBanner(url.pathname)) { return context.next(); } - const response = await context.next(); - const type = response.headers.get('content-type') || ''; - if (context.request.method !== 'GET' || !type.includes('text/html')) { - return response; + const method = context.request.method; + if (method !== 'GET' && method !== 'HEAD') { + return context.next(); + } + const assets = context.env && context.env.ASSETS; + if (!assets) { + // No binding, no shell to read. Hand the request on rather than invent an + // answer: the platform's own is wrong on these paths, but it is honest. + return context.next(); + } + const shellPath = url.pathname.startsWith('/promo') ? LANDING_SHELL.promo : LANDING_SHELL.invite; + const shell = await assets.fetch(new Request(new URL(shellPath, url).toString())); + if (!shell.ok) { + return context.next(); + } + const html = await shell.text(); + if (!isLandingShell(html)) { + // The file under that name is not the landing shell any more. Serving it + // as one would describe something the visitor is not looking at. + return context.next(); } - const html = await response.text(); const injected = injectLandingFromRequestUrl(html, context.request.url); - const headers = new Headers(response.headers); - headers.delete('content-length'); - return new Response(injected, { - status: response.status, - statusText: response.statusText, - headers, - }); + const headers = new Headers(shell.headers); + // Everything that described the bytes before the rewrite: the length, the + // content coding — the body was decoded by text() and leaves here as plain + // text — both validators, and the integrity digests of RFC 9530 and its + // predecessors. A stale validator is worse than none: a conditional request + // would be answered 304 against a document the client never received. + for (const stale of [ + 'content-length', + 'content-encoding', + 'etag', + 'last-modified', + 'content-digest', + 'repr-digest', + 'digest', + 'content-md5', + ]) { + headers.delete(stale); + } + headers.set('content-type', 'text/html; charset=utf-8'); + // The shell exists, so the answer is 200 — for HEAD as well as for GET. That + // is the whole point: WhatsApp, iMessage, Slack, Facebook and X drop a 404 + // before they read the tags this pass just wrote. + return new Response(method === 'HEAD' ? null : injected, { status: 200, headers }); } diff --git a/functions/lib/itunes-banner.js b/functions/lib/itunes-banner.js index 6711830..8458f47 100644 --- a/functions/lib/itunes-banner.js +++ b/functions/lib/itunes-banner.js @@ -81,6 +81,32 @@ function capCode(raw) { return code; } +/** + * The two marks the landing shells carry in one element and no other page the + * site ships carries together: the loading section's id and its aria-busy. + */ +const LANDING_MARKS = ['id="state-loading"', 'aria-busy="true"']; + +/** + * Whether these bytes are a landing shell rather than some other page. + * + * Two marks rather than one, because a single substring is a thin thing to + * serve a page on: an error document that happened to carry the id — in a + * comment, in a script, in a copied snippet — would be dressed up as an + * invitation. + * + * This stays a substring test, so it does not require the two marks to sit in + * the same element, or in an element at all. What makes two of them enough is + * a property of the site rather than of this function, and the property is + * held by a test: of every page the repo ships, only the two landings reach + * both marks. The 404 page carries neither, and the two other shells that + * carry the id — account-merge and confirm-aktionariat — carry no aria-busy. + * test/middleware.test.mjs walks public/ and pins exactly that. + */ +export function isLandingShell(html) { + return typeof html === 'string' && LANDING_MARKS.every((mark) => html.includes(mark)); +} + export function shouldRewriteItunesBanner(pathname) { const path = String(pathname || ''); if (/\.(js|css|map|png|svg|json|jpg|jpeg|webp|ico|txt|xml)$/i.test(path)) { diff --git a/test/middleware.test.mjs b/test/middleware.test.mjs new file mode 100644 index 0000000..919b32f --- /dev/null +++ b/test/middleware.test.mjs @@ -0,0 +1,244 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { onRequest } from '../functions/_middleware.js'; +import { isLandingShell } from '../functions/lib/itunes-banner.js'; + +// The real files, not a hand-written stand-in. The pass keys on marks that +// live in the landing shells and must never appear in the site's 404 page; a +// synthetic fixture would keep passing after someone moved one of them, and +// production would go back to serving the 404 page with nothing going red. +const page = (name) => readFileSync(resolve('public', name), 'utf8'); + +// Every page the site ships, named the way page() wants them. +const shippedHtml = (dir = 'public') => + readdirSync(resolve(dir), { withFileTypes: true }).flatMap((entry) => { + const path = `${dir}/${entry.name}`; + if (entry.isDirectory()) return shippedHtml(path); + return entry.name.endsWith('.html') ? [path.slice('public/'.length)] : []; + }); + +const SHELL = page('invite/index.html'); +const PROMO_SHELL = page('promo/index.html'); +const NOT_FOUND_PAGE = page('404.html'); +// .length counts UTF-16 units; a Content-Length counts bytes, and these pages +// carry multi-byte characters. +const bytes = (text) => new TextEncoder().encode(text).length; + +// The platform's own answer on a landing path, which is what this pass exists +// to replace: `_routes.json` hands the request here first, so the 200-rewrite +// in `_redirects` never runs and the asset lookup answers with the 404 page. +function platformAnswer() { + return new Response(NOT_FOUND_PAGE, { + status: 404, + statusText: 'Not Found', + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(bytes(NOT_FOUND_PAGE)), + etag: 'W/"the-404-page"', + }), + }); +} + +function context({ + url, + method = 'GET', + shell = SHELL, + shellStatus = 200, + shellHeaders = {}, + withAssets = true, +} = {}) { + const nextCalls = []; + const assetFetches = []; + let platform; + const ctx = { + request: new Request(url, { method }), + next: (request) => { + nextCalls.push(request); + platform = platformAnswer(); + return Promise.resolve(platform); + }, + nextCalls, + assetFetches, + platform: () => platform, + }; + if (withAssets) { + ctx.env = { + ASSETS: { + fetch: (request) => { + assetFetches.push(request.url); + return Promise.resolve( + new Response(shellStatus === 200 ? shell : 'no such asset', { + status: shellStatus, + headers: new Headers({ + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(bytes(shell)), + ...shellHeaders, + }), + }), + ); + }, + }, + }; + } + return ctx; +} + +describe('the landing middleware', () => { + test('an invite link is answered 200 with the landing and its code', async () => { + // The reason this pass exists. Before it, `/invite/` was answered + // with the site's 404 page — dressed in the campaign's title by the older + // rewrite, but still the wrong page and still a 404, which WhatsApp, + // iMessage, Slack, Facebook and X drop before reading any of the tags. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD' }); + const res = await onRequest(ctx); + expect(res.status).toBe(200); + expect(ctx.assetFetches).toEqual(['https://realunit.app/invite/index.html']); + // context.next() is never reached on this path: its answer is the wrong + // page, which is the bug rather than the fallback. + expect(ctx.nextCalls).toEqual([]); + const html = await res.text(); + expect(html).toContain('RealUnit — Einladung AB12CD'); + expect(html).toContain('og:title'); + // The shell it was built from, not the 404 page. + expect(isLandingShell(html)).toBe(true); + }); + + test('a promo link is served from the promo shell, not the invite one', async () => { + const ctx = context({ url: 'https://realunit.app/promo/EVT1', shell: PROMO_SHELL }); + const res = await onRequest(ctx); + expect(ctx.assetFetches).toEqual(['https://realunit.app/promo/index.html']); + expect(res.status).toBe(200); + expect(await res.text()).toContain('EVT1'); + }); + + test('the codeless landings are answered from their own shells too', async () => { + const invite = context({ url: 'https://realunit.app/invite' }); + await onRequest(invite); + expect(invite.assetFetches).toEqual(['https://realunit.app/invite/index.html']); + const promo = context({ url: 'https://realunit.app/promo', shell: PROMO_SHELL }); + await onRequest(promo); + expect(promo.assetFetches).toEqual(['https://realunit.app/promo/index.html']); + }); + + test('a HEAD gets the same status and no body', async () => { + // A link checker sends HEAD first. It has to read what a GET would, or the + // same link is found by one and dead by the other. + const head = await onRequest( + context({ url: 'https://realunit.app/invite/AB12CD', method: 'HEAD' }), + ); + const get = await onRequest(context({ url: 'https://realunit.app/invite/AB12CD' })); + expect(head.status).toBe(get.status); + expect(head.status).toBe(200); + // No body at all, not an empty one: `new Response('')` still carries a + // stream, and text() cannot tell the two apart. + expect(head.body).toBeNull(); + expect(head.headers.get('content-type')).toBe(get.headers.get('content-type')); + }); + + test('the headers the rewrite invalidates are dropped', async () => { + // Each of these described the bytes before the injection. A length that no + // longer matches, a coding the decoded body no longer has, a validator for + // a document the client never received, a digest of other bytes. + const ctx = context({ + url: 'https://realunit.app/invite/AB12CD', + shellHeaders: { + 'content-encoding': 'gzip', + etag: 'W/"the-shell"', + 'last-modified': 'Tue, 09 Sep 2026 00:00:00 GMT', + 'content-digest': 'sha-256=:abc:', + 'repr-digest': 'sha-256=:abc:', + digest: 'sha-256=abc', + 'content-md5': 'abc', + }, + }); + const res = await onRequest(ctx); + for (const name of [ + 'content-length', + 'content-encoding', + 'etag', + 'last-modified', + 'content-digest', + 'repr-digest', + 'digest', + 'content-md5', + ]) { + expect(res.headers.get(name)).toBeNull(); + } + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + }); + + test('a path the pass does not own is handed straight on', async () => { + const ctx = context({ url: 'https://realunit.app/js/invite-banner.js' }); + const res = await onRequest(ctx); + expect(res).toBe(ctx.platform()); + expect(ctx.assetFetches).toEqual([]); + // Handed on as it came, with no argument of ours. + expect(ctx.nextCalls).toEqual([undefined]); + }); + + test('a method other than GET or HEAD is handed straight on', async () => { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', method: 'POST' }); + const res = await onRequest(ctx); + expect(res).toBe(ctx.platform()); + expect(ctx.assetFetches).toEqual([]); + expect(ctx.nextCalls).toEqual([undefined]); + }); + + test('without the asset binding the request is handed on, not invented', async () => { + // The binding is what reaches the shell. Without it there is nothing + // truthful to answer with, so the platform's own answer stands. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', withAssets: false }); + const res = await onRequest(ctx); + expect(res).toBe(ctx.platform()); + expect(res.status).toBe(404); + expect(ctx.nextCalls).toEqual([undefined]); + }); + + test('a shell that cannot be read leaves the platform answer standing', async () => { + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', shellStatus: 404 }); + const res = await onRequest(ctx); + expect(res).toBe(ctx.platform()); + expect(res.status).toBe(404); + expect(await res.text()).toBe(NOT_FOUND_PAGE); + }); + + test('a file under the shell name that is not the shell is refused', async () => { + // A broken deploy that puts the 404 page at invite/index.html must not be + // served as an invitation: the visitor would be told about something they + // are not looking at. + const ctx = context({ url: 'https://realunit.app/invite/AB12CD', shell: NOT_FOUND_PAGE }); + const res = await onRequest(ctx); + expect(res).toBe(ctx.platform()); + expect(res.status).toBe(404); + }); + + test('the marks the pass keys on live where they have to', () => { + for (const mark of ['id="state-loading"', 'aria-busy="true"']) { + expect(SHELL).toContain(mark); + expect(PROMO_SHELL).toContain(mark); + expect(NOT_FOUND_PAGE).not.toContain(mark); + } + }); + + test('of everything the site ships, only the two landings read as a shell', () => { + // The check is a substring test: it does not require the two marks to + // share an element, or to be in an element at all. What keeps that from + // mattering is this property, and it is worth asserting rather than + // describing. It goes red the day another page gains the second mark, + // which is the day the guard would have to become a real parse. + const files = shippedHtml().sort(); + expect(files).toEqual([ + '404.html', + 'account-merge/index.html', + 'confirm-aktionariat/index.html', + 'index.html', + 'invite/index.html', + 'promo/index.html', + ]); + expect(files.filter((file) => isLandingShell(page(file)))).toEqual([ + 'invite/index.html', + 'promo/index.html', + ]); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs index 2369248..0830dd8 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -9,7 +9,7 @@ export default defineConfig({ // Only the extracted, side-effect-free browser logic is unit-tested to // 100%. The DOM/network glue in public/*.js is covered by the Playwright // functional suite instead (see CONTRIBUTING.md). - include: ['public/js/lib/**/*.js', 'functions/lib/**/*.js'], + include: ['public/js/lib/**/*.js', 'functions/lib/**/*.js', 'functions/_middleware.js'], // Report every matched file even if no test imports it, so a new, untested // public/js/lib/*.js drops coverage below 100% instead of silently passing. all: true, @@ -34,6 +34,16 @@ export default defineConfig({ branches: 87, statements: 98, }, + // The middleware decides which page and which status every crawler + // sees. It was outside `include` while it did so, which is how a + // change that served the 404 page on every invite link passed a full + // review. It is measured at 100% and stays there. + 'functions/_middleware.js': { + lines: 100, + functions: 100, + branches: 100, + statements: 100, + }, }, }, }, From 898a5656055675ffd4e69ffa80be894e06eda715 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:31:55 +0200 Subject: [PATCH 02/20] docs(invite): say where the landing comes from, now that it is not the redirect CONTRIBUTING and README described the Function as a rewrite of bytes that were already the landing. They were not: the 200-rewrites in _redirects never run on these paths, so the answer behind context.next() is the site's 404 page. Both now say that the shell is read from the asset binding by name, checked against the two landing marks, injected and answered 200. --- CONTRIBUTING.md | 2 +- README.md | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2719b1..454779a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ This repo is the **realunit.app** website — public, static. See the `public/` ships verbatim to Cloudflare Pages — what you commit is what gets served. The one exception is the invite/promo HTML: `functions/_middleware.js` rewrites those bytes on the way out so crawlers see the code in - `apple-itunes-app`, `og:*` and the App Links before any script runs. Nothing + `apple-itunes-app`, `og:*` and the App Links before any script runs. The landing itself is served by that Function too, not by `_redirects`: `_routes.json` hands `/invite/*` and `/promo/*` to it first, so the 200-rewrites never run and the asset lookup behind `context.next()` answers the path as asked — `404`, with the site's own 404 page. The shell is therefore read from the asset binding by name (`env.ASSETS.fetch`), checked against the two landing marks `id="state-loading"` and `aria-busy="true"`, injected, and answered `200`, for `HEAD` as well as `GET`. Without the binding, or when the file under that name is not the shell, the platform's own answer stands rather than an invented one. Nothing else is transformed, and there is no server-side rendering. The dev dependencies exist **only** for the quality gates below (formatting, HTML validation, unit tests, screenshots); nothing compiles or bundles the site. diff --git a/README.md b/README.md index ba7c321..eb5328c 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ uploaded to Cloudflare Pages. custom scheme; `twitter:app:country` is CH) are injected into the HTML bytes from the request URL (`functions/_middleware.js` on Cloudflare Pages, and the local dev-server) so Safari, Play, WhatsApp, X, - and share crawlers can snapshot them before JS. `og:title`, `og:description`, + and share crawlers can snapshot them before JS. The Function also serves the + landing itself: `_routes.json` claims `/invite/*` and `/promo/*`, so the + `_redirects` 200-rewrites never run and `context.next()` answers those paths + with the site's 404 page. The shell is read from the asset binding by name, + checked against the two landing marks, injected, and answered `200`. `og:title`, `og:description`, and image alt name the campaign code; `?lang=en` sets English copy and `og:locale=en_GB`; invitee names wait for lookup JS. `/js/invite-banner.js` in `` is the CSP-safe JS fallback — Cloudflare Pages CSP blocks inline `