Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4118cfc
Merge pull request #2 from RealUnitCH/develop
TaprootFreak Jul 8, 2026
bc17f1b
Merge pull request #4 from RealUnitCH/develop
TaprootFreak Jul 8, 2026
1803b0c
Merge pull request #8 from RealUnitCH/develop
TaprootFreak Jul 8, 2026
e21d72a
Merge pull request #9 from RealUnitCH/develop
TaprootFreak Jul 9, 2026
5b2a697
Merge pull request #11 from RealUnitCH/develop
TaprootFreak Jul 10, 2026
daeae68
Merge pull request #14 from RealUnitCH/develop
TaprootFreak Jul 10, 2026
1bea2c9
Merge pull request #16 from RealUnitCH/develop
TaprootFreak Jul 10, 2026
5e191eb
Merge pull request #19 from RealUnitCH/develop
TaprootFreak Jul 12, 2026
ea438e6
Merge pull request #22 from RealUnitCH/develop
TaprootFreak Jul 12, 2026
a1263f9
Merge pull request #24 from RealUnitCH/develop
TaprootFreak Jul 15, 2026
9e7a84a
Merge pull request #26 from RealUnitCH/develop
TaprootFreak Aug 21, 2026
149d6c6
Merge pull request #31 from RealUnitCH/develop
TaprootFreak Sep 9, 2026
c444702
Merge pull request #33 from RealUnitCH/develop
TaprootFreak Sep 9, 2026
74f1705
Merge pull request #36 from RealUnitCH/develop
TaprootFreak Sep 9, 2026
f1b4cd9
fix(invite): serve the landing from the asset binding, not from the r…
TaprootFreakAI Sep 9, 2026
898a565
docs(invite): say where the landing comes from, now that it is not th…
TaprootFreakAI Sep 9, 2026
f4788d3
fix(invite): answer only the case the platform gets wrong
TaprootFreakAI Sep 9, 2026
3584ee0
docs(invite): name the four routed paths, the new gate, and what stands
TaprootFreakAI Sep 9, 2026
92f9c6f
fix(invite): build the answer's headers instead of copying them
TaprootFreakAI Sep 9, 2026
5f7e9ba
fix(invite): drop a redirect mode the runtime will not take
TaprootFreakAI Sep 9, 2026
61b35af
fix(invite): carry the site's headers, drop only what the rewrite inv…
TaprootFreakAI Sep 9, 2026
d59a874
docs(invite): say which paths the platform gets wrong, not all four
TaprootFreakAI Sep 9, 2026
660045c
test(invite): pin the method the shell is asked with, and catch a rej…
TaprootFreakAI Sep 9, 2026
655c877
fix(invite): let a HEAD answer as the GET does on the codeless landin…
TaprootFreakAI Sep 9, 2026
11b758b
fix(invite): take the answer's headers from the URL that was asked for
TaprootFreakAI Sep 9, 2026
3383b5e
test(invite): the answer with no content-type at all
TaprootFreakAI Sep 9, 2026
e070e62
docs(invite): the injection also writes the store hand-off, and crawl…
TaprootFreakAI Sep 9, 2026
59892b0
fix(invite): guard the shell's body, not only the call that fetches it
TaprootFreakAI Sep 9, 2026
1baf349
docs(invite): say that a HEAD takes the other route, and drop a contr…
TaprootFreakAI Sep 9, 2026
520eed7
fix(invite): guard the in-place read too, and hold the stale list on …
TaprootFreakAI Sep 9, 2026
d445251
docs(invite): bound the HEAD exception, and say where the hand-off ap…
TaprootFreakAI Sep 9, 2026
edb521c
test(invite): assert what the injection changes, not what the shell a…
TaprootFreakAI Sep 9, 2026
a846939
docs(invite): three comments that claimed a little more than they hold
TaprootFreakAI Sep 9, 2026
e068c52
docs(invite): stop enumerating what the injection writes
TaprootFreakAI Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 for a code-bearing path is served by that Function too, not by `_redirects`: `_routes.json` hands `/invite`, `/invite/*`, `/promo` 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 platform is therefore asked first and only its `404` is replaced: the shell is 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`. Every other answer stands — `/invite/` and `/promo/` are real files served `200`, rewritten in place for a `GET`, and `/invite` and `/invite/index.html` keep the `308` that canonicalises them. A `HEAD` carries no body to recognise the shell in, so it reads the shell from the binding like the `404` case does and answers as the `GET` would, without a body. Without the binding, or when the file under that name is not the shell, the platform's 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.
Expand Down Expand Up @@ -46,7 +46,8 @@ This repo is the **realunit.app** website — public, static. See the
- Inline `style="…"` attributes and `<style>` blocks are fine (`style-src`
allows `'unsafe-inline'`).
- **Put the reusable, side-effect-free JS in `public/js/lib/`.** That is the only
code with a unit-coverage gate (see below); DOM/network glue stays in the
browser code with a unit-coverage gate (see below); `functions/_middleware.js`
and `functions/lib/**` have their own. DOM/network glue stays in the
page-level scripts and is covered by the Playwright suite.
- **Don't put mutable files under `public/assets/`.** That path has an immutable,
one-year cache header — only content-hashed or otherwise stable-named assets
Expand All @@ -73,14 +74,14 @@ sanity-check anything touching scripts/images in the dev deployment.
Every pull request must pass the gates below; CI runs them as required status
checks.

| Gate | Command | What it enforces |
| ----------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Formatting | `npm run format:check` | Prettier formatting of the maintained code (the hand-written HTML pages are validated, not reformatted) |
| HTML validity | `npm run validate:html` | Valid markup on every page under `public/` |
| Site completeness | `npm run check:site` | Every `<html lang>`, every internal link/asset resolves, and each glue script loads its `js/lib` core first |
| Unit coverage | `npm run test:coverage` | 100% line/branch/function/statement coverage of the extracted browser logic (`public/js/lib/**`) |
| Functional | `npm run test:e2e` | Playwright smoke + behavior suite (every page loads, platform detection, the full confirm flow) |
| Visual regression | `npm run e2e:docker` | Every view in the visual matrix (page × viewport × language × state) matches its committed baseline, then `check:visual` |
| Gate | Command | What it enforces |
| ----------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Formatting | `npm run format:check` | Prettier formatting of the maintained code (the hand-written HTML pages are validated, not reformatted) |
| HTML validity | `npm run validate:html` | Valid markup on every page under `public/` |
| Site completeness | `npm run check:site` | Every `<html lang>`, every internal link/asset resolves, and each glue script loads its `js/lib` core first |
| Unit coverage | `npm run test:coverage` | 100% line/branch/function/statement coverage of the extracted browser logic (`public/js/lib/**`) and of `functions/_middleware.js`; `functions/lib/**` is ratcheted at the level it reaches today |
| Functional | `npm run test:e2e` | Playwright smoke + behavior suite (every page loads, platform detection, the full confirm flow) |
| Visual regression | `npm run e2e:docker` | Every view in the visual matrix (page × viewport × language × state) matches its committed baseline, then `check:visual` |

`npm run check` runs the first four locally in one go. The Playwright suites run
against a local dev server (`test:e2e`); the visual gate runs in a pinned
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,16 @@ 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 for a code-bearing path: `_routes.json` claims `/invite`, `/invite/*`,
`/promo` and `/promo/*`, so the `_redirects` 200-rewrites never run and,
on a code-bearing path, `context.next()` answers with the site's 404 page. The platform is
asked first and only its `404` is replaced — the shell is read from the asset
binding by name, checked against the two landing marks, injected and answered
`200`. `/invite/` and `/promo/` are real files, rewritten in place for a
`GET`; a `HEAD` reads the shell from the binding like the `404` case, because
its answer carries no body to recognise the shell in. `/invite` and
`/invite/index.html` keep their `308`. `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 `<head>`
is the CSP-safe JS fallback — Cloudflare Pages CSP blocks inline `<script>`.
Expand Down Expand Up @@ -92,7 +101,10 @@ From v2 a build toolchain (Astro) is introduced; the plain-image landing stays t
## Testing

The site still ships verbatim — the tooling is dev-only. Pure browser logic lives
in `public/js/lib/**` and is unit-tested to 100% (Vitest + jsdom); the pages,
in `public/js/lib/**` and is unit-tested to 100% (Vitest + jsdom), as is
`functions/_middleware.js`, which decides which page a landing path is answered
with; `functions/lib/**` is measured and ratcheted at the level it reaches
today. The pages,
platform detection and the full confirm flow are covered by Playwright
(functional + screenshot regression). See [CONTRIBUTING](CONTRIBUTING.md#quality-gates)
for the gate list and commands (`npm run check`, `npm run test:e2e`,
Expand Down
185 changes: 163 additions & 22 deletions functions/_middleware.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,173 @@
/**
* 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, the Facebook App Links are present, and a code-bearing store
* hand-off names the code. Safari, Play and share crawlers can 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 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 method = context.request.method;
if (method !== 'GET' && method !== 'HEAD') {
return context.next();
}
// The platform's own answer first, because most of what it says is right and
// only one case is not. A real file under these paths comes back 200 — that
// is /invite/ and /promo/ themselves — and /invite/index.html comes back as
// the 308 that canonicalises it. The redirect stands as it is; the real file
// needs only its metadata written in. Only the code-bearing paths come back
// 404, because the 200-rewrite that was meant to resolve them never runs.
//
// A HEAD is the exception to the middle case: its answer carries no body, so
// the shell cannot be recognised in it. A HEAD that still has a 200 or a 404
// after the two guards below therefore takes the same route as that 404. A
// redirect and a non-HTML answer leave before those guards, whatever the
// method.
const platform = await context.next();
if (platform.status !== 200 && platform.status !== 404) {
return platform;
}
if (platform.status === 200 && !isHtml(platform.headers.get('content-type'))) {
// Some other asset under these paths. Not ours to rewrite.
return platform;
}
if (method === 'GET' && platform.status === 200) {
// The file is already the right one; what the URL supports has to be
// written into it, and its own status stays. A code can reach even this
// path as a query parameter, so nothing here assumes there is none. Read inside a
// guard for the same reason as the shell below: a body stream that fails
// would take the Function down, and a Function that throws makes Pages
// serve the assets directly.
let html;
try {
html = await platform.clone().text();
} catch {
return platform;
}
if (!isLandingShell(html)) {
return platform;
}
return answer(html, platform.headers, context.request, method, platform.status);
}
// What is left is a 404 to replace, or a HEAD. A HEAD answer carries no
// body, and the body is what tells the landing shell from any other page —
// deciding from an empty one would hand back the file's own validators
// beside a GET that had them stripped. Both cases therefore read the shell
// from the binding, and both answer as the GET would — a HEAD without a
// body, which the platform expects of it anyway.
const assets = context.env && context.env.ASSETS;
if (!assets) {
// No binding, no shell to read. The platform's answer stands: it is the
// wrong page, but it is not one this pass invented.
return platform;
}
const shellPath = url.pathname.startsWith('/promo') ? LANDING_SHELL.promo : LANDING_SHELL.invite;
// Always a GET, whatever the client sent: the body is what tells the landing
// shell from any other file under that name, and a HEAD would come back
// without one. Asked in a try, because a binding that rejects would
// otherwise take the whole Function down — and a Function that throws makes
// Pages serve the assets directly, which is the very answer this replaces.
let shell;
let html;
try {
shell = await assets.fetch(new Request(new URL(shellPath, url).toString()));
if (!shell.ok) {
return platform;
}
html = await shell.text();
} catch {
return platform;
}
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 platform;
}
// The headers describe the URL that was asked for, so they come from the
// platform's answer wherever that answer was the file itself —
// public/_headers matches on the request path, and the shell's own answer was
// matched on /invite/index.html. Only when the platform answered something
// other than 200 does the shell's set stand in.
const source = platform.status === 200 ? platform.headers : shell.headers;
return answer(html, source, context.request, method);
}

/** Whether the answer is HTML at all, read as a media type and not a substring. */
function isHtml(contentType) {
return (
String(contentType || '')
.split(';')[0]
.trim()
.toLowerCase() === 'text/html'
);
}

/**
* The landing with injectLandingFromRequestUrl applied. What that writes
* depends on the URL — the campaign tags and the store hand-off on a code, the
* English copy and locale on ?lang=en — and functions/lib/itunes-banner.js is
* where each of those decisions lives.
*/
function answer(html, sourceHeaders, request, method, status = 200) {
const injected = injectLandingFromRequestUrl(html, request.url);
// The source's headers, minus the ones the rewrite invalidates. They have to
// be carried: public/_headers is applied to the asset, not to an answer this
// Function builds, so starting from an empty set drops the site's
// Content-Security-Policy, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy and Cache-Control from every landing. Measured on a
// preview deploy, with and without the copy.
//
// What must not be carried is anything describing the bytes before the
// injection: the length, the content coding — text() decoded the body and
// what leaves here is plain text — both validators, and the integrity
// digests of RFC 9530 and its predecessors. A stale validator is the one
// that does damage: a conditional request would be answered 304 against a
// document the client never received.
const headers = new Headers(sourceHeaders);
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');
// A landing that exists is answered as found. For a code-bearing path that
// is the whole point: WhatsApp, iMessage, Slack, Facebook and X drop a 404
// before they read the tags just written. A GET on a real file keeps the
// status the platform gave it, which is the same 200.
return new Response(method === 'HEAD' ? null : injected, { status, headers });
}
26 changes: 26 additions & 0 deletions functions/lib/itunes-banner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading
Loading