48a09586 - Invite and promo links answer 404 in production - #34
Conversation
Measured against the deploy, which is what #30 left open and #32 promised to settle with one curl: curl -sI https://realunit.app/invite/AB12CD -> HTTP/2 404 The body is correct — the landing shell with the rewritten meta tags — but the status is not. _routes.json sends /invite/<code> to the Function, and the asset lookup behind context.next() resolves the code-less shell through the _redirects 200-rewrite while keeping the not-found status of the path that was asked for. The middleware copied that status through. Browsers render a 404 body, so a person following the link sees the right page. Share crawlers do not: they drop a 404 before reading the tags the middleware just wrote, which is the entire reason the rewrite exists. Every invite and promo link shared today therefore previews as nothing. The promotion is guarded on the landing marker, so a broken deploy that serves the site's own 404 page on these paths keeps saying 404 instead of looking healthy, and the reason phrase is dropped rather than left reading "Not Found". functions/_middleware.js was outside coverage while it decided the status every crawler sees, which is how this shipped unnoticed. It is measured now, at 100%, with the entry point's own test file. npm run check: exit 0, 173 tests. Verified by mutation: reverting the promotion turns three tests red, and removing the marker guard turns the two that protect a genuine 404 red. The other half of the same open question is now answered too: _headers do apply to Function-served requests. CSP, X-Frame-Options and Referrer-Policy are all present on https://realunit.app/invite/AB12CD.
The conformity lane found the documentation and the config describing a narrower change than the one that landed, which is the same drift #30 had to correct in this file. - CONTRIBUTING said public/js/lib was the only code with a coverage gate and the gate table named only that path; functions/lib and the Function entry point carry one too. - The ground rule described the middleware as a pure byte rewrite with "nothing else transformed", which no longer holds now that it also decides the status a crawler sees. - The vitest comment still claimed the measured surface was side-effect-free browser logic only. - The file header of the middleware did not mention the promotion at all. Two lanes independently found the same test gap: the promoted responses asserted their status but not their reason phrase, so reverting the ternary would have stayed green at 100% branch coverage. Both promotion tests now assert the empty phrase, and the untouched-404 test asserts it keeps "Not Found". npm run check: exit 0, 173 tests, functions/_middleware.js at 100%. Verified by mutation: keeping the reason phrase on promotion turns the two promotion tests red.
The conformity lane found the middleware test building its own landing shell and its own 404 page. The promotion keys on a marker that has to be in the landings and must never be in the site's 404 page, and a synthetic fixture keeps passing after someone moves that marker — production would answer 404 again with nothing going red, which is exactly how this shipped. The test now reads public/invite/index.html, public/promo/index.html and public/404.html, and asserts the marker contract against them directly. npm run check: exit 0, 174 tests. Verified by mutation on the real files: renaming the marker in the landing turns two tests red, and adding it to the 404 page turns two others red.
The logic lane caught the fix stopping at GET. That is the method this bug was measured with: `curl -sI` sends HEAD, so the very command quoted as the proof would still have read 404 after the fix, and a link checker that sends HEAD first would still see the link as dead. HEAD has to answer with the same status as GET. HEAD is now handled alongside GET and returns no body of its own. A HEAD whose body the platform withheld keeps the status it came with: without a body there is no way to tell the landing shell from the site's own 404 page, and guessing from the path alone would make a broken deploy look healthy. The same lane found the header pass-through untested. The fixture now carries the Cache-Control rule public/_headers sets for these paths, and the promotion test asserts it survives the response being rebuilt. Also recorded in the marker comment: two other pages carry the same id. It does not matter, because the caller has already established the path is a landing, so the only open question is shell versus 404 page. npm run check: exit 0, 176 tests, functions/_middleware.js at 100%. Verified by mutation: dropping HEAD from the guard, returning a body for HEAD, and keeping content-length instead of cache-control each turn one test red.
The logic lane found the HEAD path asserting only status, reason phrase and an empty text(). Two regressions would have stayed green: keeping content-length for HEAD alone, and returning an empty body instead of no body, which text() cannot tell apart. The HEAD case now asserts body === null and the same three headers the GET case does. npm run check: exit 0, 176 tests. Verified by mutation: an empty body for HEAD and a HEAD-only content-length each turn one test red.
The conformity lane found README describing the middleware as byte injection only, and its testing section naming public/js/lib as the sole measured surface. CONTRIBUTING names README as the architecture reference, so leaving it behind recreates the drift the last PR had to correct. Both passages now match what the code does.
The logic lane found the HEAD path resting on an assumption instead of a fact. The status has to be decided from the body, because the marker in it is what tells the landing shell from the site's own 404 page — and a HEAD response has no body. The previous revision kept the status when the body was missing, so if the platform hands the Function nothing for HEAD, the same link would have read as found by GET and as dead by the HEAD a link checker sends first. That contradicted the comment right above it. HEAD now asks for the GET-equivalent through context.next() and answers with that status and those headers, without a body. Nothing is left to the platform, and the outcome no longer waits on a deploy to be known. The same lane found the fixture carrying no security headers, so a middleware that dropped one would not have failed a value assertion anywhere. The fixture now carries what public/_headers sets for these paths, and both the GET and the HEAD case assert every one of them survives. npm run check: exit 0, 176 tests, functions/_middleware.js at 100%. Verified by mutation: dropping the GET-equivalent lookup, deleting a security header, and returning the GET body to a non-HTML HEAD each turn one test red.
Four accuracy findings from the conformity lane, all of them places where a comment claimed more than it should: - The "Browser JS and unit coverage" section still read as if everything outside public/js/lib were covered by Playwright alone. Two surfaces carry their own gate and are not page scripts at all. - The test fixture called its Content-Security-Policy value "what public/_headers sets". It is a short stand-in for a much longer policy; only the cache-control value is the real one. - "The contract the two tests above rely on" sat above two tests that do not use the marker at all. - "the tags written just above" was copied out of the middleware header, where it had a referent. In the test it had none. npm run check: exit 0, 176 tests.
…HEAD on a 404 Two low findings from the conformity lane. The fixture hand-copied a shortened Content-Security-Policy while calling it what public/_headers sets. It now reads the real values out of that file, so the fixture cannot drift from production and the pass-through assertion is always about the shipped policy. Measured, and written into the comment so the next reader is not misled: this checks the pass-through, not the policy — changing _headers moves both sides together by design, and a middleware that drops a header is what turns red. The marker guard was only exercised on GET. There is now a HEAD case against the real 404 page, so a regression that evaluates the guard for GET alone has somewhere to fail. npm run check: exit 0, 177 tests. Verified by mutation: deleting referrer-policy in the middleware and weakening the marker guard each turn a test red.
The logic lane found the synthetic GET inheriting every header of the HEAD it came from, Range and If-Range included. A HEAD carrying Range would have come back as 206 Partial Content, whose body is not the landing shell: the status would neither have been promoted nor meant what the client asked for. Both headers are now dropped and everything else the client sent is kept. The test asserts exactly that, so a regression that forwards Range, or one that throws the client's headers away altogether, has somewhere to fail. The same lane found the non-HTML HEAD branch asserting status and body but not headers, though it rebuilds the response too. It now checks them. npm run check: exit 0, 178 tests, functions/_middleware.js at 100%. Verified by mutation: keeping Range, dropping the client headers, and building the body-less response with empty headers each turn one test red.
The conformity lane caught me reintroducing, in the middleware header, the exact wording I had just corrected in the test file: "the tags written just above", where nothing is above. It now says what it means. The three places that mention the functions/lib ratchet now say where it is set, instead of calling it documented and leaving the reader to find it. npm run check: exit 0, 178 tests.
The logic lane found that widening the condition from 404 to the whole 4xx range would have passed the entire suite. Only the 404 Pages produces for these paths is an artefact; a 403 or a 410 means what it says, even when the body happens to be the shell — promoting those would turn a refusal or a withdrawn code into a healthy page. landingStatus now pins 403, 410 and 451, and the middleware has a case that sends a refusal through untouched. npm run check: exit 0, 179 tests. Verified by mutation: widening the condition to `status >= 400 && status < 500` turns both new tests red.
The logic lane found the previous commit cleaning the request on one path only. A real GET carrying Range would still have been forwarded with it, and this pass rewrites the whole document: a partial representation would be injected into a fragment and returned under a Content-Range describing the bytes before the rewrite. Both methods now ask for the same thing — the whole document as a GET — so the two paths share one helper and one comment instead of drifting apart again. Measured against the deploy before changing anything: curl -sI -H 'Range: bytes=0-99' https://realunit.app/invite/AB12CD -> 404, and the body is the full 4162 bytes, not a fragment Pages answers a Range request on these paths with the whole document today, so this is a guard rather than a live fix, and the comment says so. npm run check: exit 0, 180 tests, functions/_middleware.js at 100%. Verified by mutation: cleaning only the HEAD path, and keeping Range at all, each turn tests red.
…rite The logic lane found ETag and Last-Modified passed through unchanged. They describe the document the asset server produced, not the one this pass sends, and a stale validator is worse than none: a conditional request would be answered 304 against a document the client never received. Content-Range gets the same treatment for the same reason. Measured against the deploy before changing anything: Pages sets neither ETag nor Last-Modified on these paths today, so this is a guard rather than a live fix, and the comment says so. The refusal case now also asserts the body passes through, not just the status. The conformity lane found the Range behaviour missing from both architecture documents; CONTRIBUTING and README now say that GET and HEAD are resolved internally as one full GET. The measurement note in the code also says what was actually measured — the body of a Range GET, not only its headers, which is what the previous commit message implied. npm run check: exit 0, 181 tests, functions/_middleware.js at 100%. Verified by mutation: dropping only content-length, and building the header set empty, each turn tests red.
…body with 304 The logic lane found the helper promising more than it delivered. It ruled out 206 by dropping Range, but forwarded the conditional headers untouched, so the answer could still come back as 304 or 412 — bodies that are not the landing shell either, on a path where the status is decided from the body. Worse, a 304 would have crashed the request: the previous revision paired the rewritten string with that status, and constructing a Response with a body and a null-body status throws. A conditional request would have become a 500 instead of being passed on. Both are closed. Every header that could make the answer something other than the full document is dropped, and 204, 205 and 304 are answered without a body. The same lane found that re-methoding a request that carries a body throws, which would have aborted the middleware rather than answering. That case now falls back to rebuilding from the URL, which loses the platform's request metadata but keeps the request answerable, and a test drives it. npm run check: exit 0, 184 tests, functions/_middleware.js at 100%. Verified by mutation: narrowing the header list back to Range, treating only HEAD as body-less, and removing the fallback each turn a test red.
…hing" The logic lane left this as an open question rather than a claim, and the measurement settled it: curl -sSI --compressed https://realunit.app/invite/AB12CD -> content-encoding: gzip These paths really are served gzipped. The rewrite always hands on a decoded string, so a Content-Encoding inherited from the asset response would label it as something it is not, and a body labelled gzip that is not gzip does not render at all. Whether the header is on the object the Function sees or only added by the edge afterwards is not knowable from here — dropping it costs nothing in the second case and prevents a blank page in the first. The conformity lane found the comment above that list, and the test title, saying "everything that described the bytes" while the code covers a named set. That is the same overclaim this series has now corrected four times. Both name the five headers instead, and the helper's comment names the six request headers RFC 9110 defines rather than claiming to cover every possible one. npm run check: exit 0, 184 tests, functions/_middleware.js at 100%. Verified by mutation: keeping Content-Encoding turns the test red.
…aviour The conformity lane found the README attributing the status promotion, the HEAD parity and the unconditional full GET to both injection paths, because the sentence followed a bracket naming the Function and the dev-server together. scripts/dev-server.mjs has none of it. While correcting that I checked the file rather than trusting the finding, and the first correction I wrote was wrong too: the dev-server does answer 404, on its own not-found path. What is true is narrower — it shares the injection and answers HEAD without a body, but has no promotion, no marker guard and no header stripping, because its routing serves the landings as 200 and never produces the not-found status the promotion exists to correct. Both documents now say that, and both name the conditional validators the helper strips alongside Range. The logic lane added two small ones, both taken: - The integrity digests of RFC 9530 and its predecessors describe the bytes this pass replaces, so they join the stale list. Not observed on this deploy; the same guard as the validators. - The body-carrying-GET fallback asserted the method and URL but not the headers, so rebuilding it with the client's untouched headers would have stayed green. It now checks that the cleaned set is what gets forwarded. npm run check: exit 0, 184 tests. Verified by mutation: dropping a digest from the list, and forwarding the raw headers in the fallback, each turn a test red.
Two precision findings from the conformity lane, both the same reflex this series keeps catching: a comment claiming a little more than the code holds. - The stale-header comment called ETag and Last-Modified byte descriptions. They are validators of the selected representation, and a weak ETag explicitly does not promise byte equality. The reason they go is that rewriting the representation invalidates them, which is what it now says. - A test was titled "keeps the request headers but drops Range" while the helper drops four conditional headers as well; its comment said everything else is kept. The title and comment now describe that case, and point at the case that covers the conditional ones. npm run check: exit 0, 184 tests.
… the fallback Four findings from the logic lane, after it downgraded two of its own and rejected two more as unsupported. - response.text() decodes as UTF-8 and a string body is encoded as UTF-8, so an origin charset carried through would have been a lie. The rewritten answer now declares text/html; charset=utf-8 itself. - A 206 cannot be rewritten: the body is a fragment and the range metadata would stop matching it. Range is stripped before the lookup, so this only fires for an origin that answers 206 unasked, but the guard is one line. - The body-carrying-GET fallback kept the headers that described that body, so the rebuilt request announced a body it does not carry. - The non-HTML branch deliberately drops only content-length, because nothing was rewritten there and the validators still hold. No test showed that, so a regression widening it would have passed. It is asserted now. The comment on the non-HTML branch also says what it previously left out: the request has already lost Range and the conditional headers by then, which is harmless only because these paths serve HTML alone. While verifying, one of my own mutation checks came back green and I chased it rather than accepting it. The check had silently not applied — the script's output was piped away, so "pattern not found" was invisible, and the two assertions it was meant to test had landed in the wrong test because the same line occurs twice in the file. Both are corrected, and the check now fails as it should. npm run check: exit 0, 186 tests, functions/_middleware.js at 100%. Verified by mutation: dropping the charset, removing the partial-answer guard, keeping the body headers in the fallback, and widening the non-HTML strip each turn a test red.
… instead of catch Three findings from the conformity lane. The comment on the stale-header list argued in one breath that dropping Content-Encoding is "not hypothetical" because the deploy serves gzip, and that it "costs nothing if it was never on the object". Those are two different layers, and put together they contradict each other. Split: the encoding goes because response.text() decoded the body, whatever the origin declared; whether the header was ever on the object is a separate question, and the gzip seen on the deploy is applied by the edge after this Function runs. Only the ETag and Last-Modified sentence rests on a measurement. A test titled "passed through untouched" checked status and body but not headers, while its two siblings with the same word do. It checks them now. The fallback used a bare catch, so any constructor failure would have been rerouted through it while the comment named one cause. It asks whether the request has a body instead, and anything else still surfaces. npm run check: exit 0, 186 tests, functions/_middleware.js at 100%. Verified by mutation: mangling a header on the non-HTML branch, and taking the re-method path unconditionally, each turn a test red.
The conformity lane pointed out that the helper's own name for what it drops was wrong. On the paths that reach it nothing was rewritten, so the origin's Content-Length describes exactly the representation a GET would have returned — RFC 9110 wants a HEAD to carry it. Calling it stale and deleting it lost correct information. It is kept now, and the tests assert it on both branches that use the helper. The asFullGet docstring also claimed everything else the client sent is kept, while the fallback right below it drops the body, the headers that described it and the request metadata a URL cannot rebuild. It names that exception. npm run check: exit 0, 186 tests, functions/_middleware.js at 100%. Verified by mutation: deleting the length again turns two tests red.
Three findings from the logic lane. A 304 was going through the rewrite branch, where its ETag was stripped and a Content-Type set. That is the one validator a 304 has to keep — it is what the client was matched on. 204, 205, 304 and 206 are now one set of statuses handed on as they came: none of them carries a representation this pass could rewrite, and their headers still describe what the origin meant. The special case that kept a body away from them disappears with it. The rewrite is also gated on the body being a landing shell. On a broken deploy serving the site's own 404 page under these paths, the status was already left alone, but the page's title was still rewritten into an invitation — a description of something the visitor is not looking at. The stale-header strip runs for every rewritten answer, not only a promoted one, but only the promotion test proved it. Coupling the strip to the status would have left a 403 or 410 carrying a length from before the rewrite, and no test would have noticed. The refusal case asserts it now. npm run check: exit 0, 186 tests, functions/_middleware.js at 100%. Verified by mutation: coupling the strip to 404, shrinking the pass-through set back to 206, and removing the shell guard each turn a test red.
…odings alone
Two production findings from the logic lane, and two test gaps.
The content type was matched with includes('text/html'). That is
case-sensitive, so an origin answering Text/HTML would have been treated as
something else and never rewritten; and it is a substring test, so
application/json; profile="text/html" would have been read as HTML and had an
invitation injected into it. It is parsed as a media type now.
The charset was ignored. response.text() decodes as UTF-8 whatever the header
says, so a body in another encoding would have come back as replacement
characters and left here labelled UTF-8. Such an answer is handed on untouched
instead. The old test asserted the opposite — that an ISO-8859-1 answer gets
relabelled — and it went red, correctly; it now covers what the rewrite really
guarantees, which is that a rewritten answer says UTF-8 even when the origin
left it out.
The two test gaps: the partial-answer case asserted only status and body, and
the non-HTML pass-through named a few headers by hand. The first now uses the
landing shell as its fixture, so the guard has something to protect and a
mutation that lets HEAD through goes red; the second compares the whole header
set, so a regression that drops one nobody thought to name is caught.
npm run check: exit 0, 188 tests, functions/_middleware.js at 100%. Verified by
mutation: the substring match, the missing charset guard, letting HEAD past the
partial guard, and dropping a header in the body-less branch each turn a test
red.
… its text Seven findings across both conformity and logic lanes. The branch that decides not to rewrite was rebuilding the response from the decoded string while keeping the origin's headers — the same mismatch the rewrite branch strips headers to avoid, just without the stripping. It reads from a clone now, so an answer this pass declines to touch is handed on exactly as it came, body and headers both. Accept-Ranges joins the stale list: after a rewrite there are no ranges of the original bytes left to offer. The docblock describing the status promotion had ended up above isLandingShell when that predicate was extracted. It sits above landingStatus, which is what it describes. Four test gaps, three of them the same shape — asserting that a header is absent afterwards while the fixture never set it, which proves nothing: - the refusal case now sends a real Content-Encoding before checking it is gone; - the stale-header case now sends a real Accept-Ranges; - the media-type lookalike now carries the landing shell as its body, so a substring match would actually rewrite it and be caught; - the two remaining "passed through untouched" cases assert the body, as their siblings already did. Charset parsing is exercised across the shapes it arrives in: quoted, unspaced, differently cased, and two encodings that must be handed on instead. npm run check: exit 0, 189 tests, functions/_middleware.js at 100%. Verified by mutation: reading without the clone, keeping Accept-Ranges, stripping only on a promoted status, and matching the media type as a substring each turn a test red.
The logic lane found the charset regex not quote-aware. A quoted parameter value may contain a semicolon, so `text/html; foo="x;charset=utf-8"; charset=iso-8859-1` matched the charset embedded in the quoted value and missed the real one behind it — the answer would have been decoded as UTF-8, rewritten and relabelled, which is exactly what the charset check exists to prevent. The parameters are split with quotes respected now, and every charset present has to be UTF-8: two that disagree mean hands off rather than picking one. The 206 fixture described a range that could not exist — `bytes 0-9826/4162`, because .length counts UTF-16 units while a Content-Length counts bytes, and the total had been left over from an earlier fixture. It uses real byte counts. npm run check: exit 0, 189 tests, functions/_middleware.js at 100%. Verified by mutation: dropping quote-awareness, taking the first charset instead of requiring all, and leaving the quotes on the value each turn a test red.
The logic lane found both of them unable to distinguish the current code from the one it replaced. Their fixture never set an encoding or a validator, and the page's own bytes are the same either way, so rebuilding the answer from its decoded text instead of handing on the origin's would have passed. Both now receive an answer carrying Content-Encoding and an ETag, and require the very object the origin returned — a rebuilt look-alike fails. The HEAD case compares the whole header set, since only the body may be missing. Two smaller ones from the conformity lane: a test titled for the answer being the full document checked only the forwarded request, and now checks both; and the two documents called the four request headers "conditional validators", which is the RFC's name for ETag and Last-Modified, not for these. npm run check: exit 0, 189 tests. Verified by mutation: rebuilding the untouched answer from its text turns both cases red.
The conformity lane found the test claiming more than it showed. Its body was the same JS string every other fixture uses, and a string body is always encoded as UTF-8 — so it only ever proved that the guard flips on a foreign charset label, never that a body in another encoding comes through intact. It now sends real ISO-8859-1 bytes, with 0xE9 for 'é', which is not valid UTF-8 at all, and carries the landing marker so that nothing but the charset guard stands between it and the rewrite. The answer is compared byte for byte. Verified by mutation: removing the charset guard turns it red, because the byte comes back as U+FFFD and the answer leaves as different bytes. npm run check: exit 0, 189 tests.
The logic lane found the parser toggling on every quote, escaped or not. RFC 9110 lets a backslash escape the next character inside a quoted value, so `foo="a\";charset=utf-8"; charset=iso-8859-1` was split in the wrong place and the wrong charset won. The escape is consumed now, and a quoted value has its escapes resolved rather than only its outer quotes stripped. Two test gaps from the same lane: - The disagreeing-charset case was only tried one way round, so a change from "all must be UTF-8" to "the last one wins" would have passed. Both orders are covered. - Two pass-through branches asserted their headers but not that the answer is the origin's own object, while the 404 branch beside them does. A rebuild at either would have gone unnoticed; both now require the same object and carry an encoding and a validator to prove it. npm run check: exit 0, 189 tests, functions/_middleware.js at 100%. Verified by mutation: dropping the escape handling, letting the last charset win, and rebuilding the non-HTML answer each turn a test red.
…ty checks Six findings from the conformity lane, all of them places where a comment or an assertion was looser than what it stood next to. - Two comments called a HEAD answer "handed on untouched" or "the same object". A body-less answer has to be a new object; only the GET path can be the origin's own. Both now say which is which. - The fixture builder set Content-Length from .length while a comment three lines above explained that .length counts UTF-16 units and these pages carry multi-byte characters. Every fixture and assertion uses real byte counts now. - An assertion checked for a URL string that og:url and twitter:url share, so a defect in the og:url replacement alone would have passed. It pins the whole tag. - "The German shell must be left exactly as it is" was not true: the pass adds og:site_name regardless of language. The claim is narrowed to the copy, which is what the test checks. - The last of the four pass-through branches still lacked the identity check its siblings got, and has it now. npm run check: exit 0, 189 tests. Verified by mutation: breaking the og:url replacement alone, and rebuilding the answer on the non-GET/HEAD branch, each turn a test red.
Two mutation gaps from the logic lane, which also found the quoted-pair bug independently — that one was already fixed in 9ef5048. - Every charset test used a lower-case parameter name, so dropping the case-folding would have passed while `Charset=` stopped being recognised. Both an upper-case UTF-8 and an upper-case foreign charset are covered. - The landing marker is matched as a substring, so widening it from `id="state-loading"` to `state-loading` would have passed too — and a page that merely mentions the words would then have been promoted from 404 to 200 and rewritten. A page that mentions them without the attribute is now a case. npm run check: exit 0, 190 tests. Verified by mutation: removing the case-folding and widening the marker each turn a test red.
…ranch Two test-strength findings from the decisive round; both lanes reported nothing that changes behaviour for a user or a crawler. The already-found case asserted its status but not that the rewrite ran, so a change that handed a 200 answer straight through would have passed. My first attempt at fixing that was itself trivially true — it checked for a tag the shipped shell already carries, and the mutation stayed green. Measured what the rewrite actually changes on that URL: it normalises the canonical and the two URL tags to the form without a trailing slash. That is what the test checks now. The identity check that three pass-through branches carry was missing from the other two, so a rebuild at either would have gone unnoticed. All five require the origin's own object on the GET path. npm run check: exit 0, 190 tests. Verified by mutation: skipping the rewrite on an already-found answer, and rebuilding the body-less pass-through, each turn tests red.
…implicit The decisive round came back with nothing behaviour-affecting from three of the four lanes. The fourth raised two, and this is how they stand. The first is real and is now written down where it happens rather than left for the next reader to discover: stripping the conditional request headers means a client sending `If-None-Match: *` gets 200 with the whole document where RFC 9110 §13.1 calls for 304, and `If-Match` never yields 412. These paths emit neither ETag nor Last-Modified, so a client only reaches that case by sending a precondition it was never given one for, and the answer it gets is the current representation — never wrong, only larger than it had to be. Evaluating preconditions properly means minting a validator for the rewritten document, which is a larger change than this one and belongs in its own. The second is not this PR's: shouldRewriteItunesBanner and parseLandingFromUrl carry two different lists of asset extensions, so a referral code ending in .json is skipped entirely while one ending in .html loses its code. Both functions predate this change. It is written into the PR body as a follow-up. Three test-strength points from the same round are closed: an unterminated quoted charset, the reason phrase on a passed-on HEAD, and the boundary of the marker check spelled out as a documented limit rather than left unstated. npm run check: exit 0, 190 tests. Verified by mutation: dropping the closing-quote check and blanking the reason phrase each turn a test red.
Two test-strength points from the confirmation round; it reported nothing that changes behaviour. The branch for a path the rewrite does not own was the one pass-through left without an identity check, so a rebuild there would have gone unnoticed while the body and length stayed the same. It requires the origin's own object now, like the other five. The proof that an already-found landing really is rewritten checked the canonical and og:url but not twitter:url, though the rewrite normalises all three. A regression in that one replacement alone would have passed. npm run check: exit 0, 190 tests. Verified by mutation: rebuilding the answer on the not-owned branch, and breaking the twitter:url replacement alone, each turn a test red.
… in the code Three test-strength and documentation points from the confirmation round, which again reported nothing that changes behaviour. The Range case asserted the forwarded request but discarded the answer, so a regression that stripped the header and still treated the response as partial would have passed. It asserts the answer too. The charset matrix collapsed every case to a status. It now checks what each outcome means: a rewritten answer is promoted, declares UTF-8 and carries the injected title; an untouched one keeps the origin's status, type and bytes. The deliberate deviation from RFC 9110 §13.1 was stated in the code but not in either document, though both describe the surrounding behaviour. Contributors read those first, so both now say it and why. npm run check: exit 0, 190 tests.
The confirmation round accepted the deviation but caught the justification being too broad. A tag or date condition would indeed need a validator minted for the rewritten document. A wildcard one would not: `If-Match: *` and `If-None-Match: *` ask only whether a current representation exists, which is already known here. They are dropped for uniformity, not out of necessity, and the comment now says which is which. The behaviour is unchanged; only the stated reason was wrong for half of it. npm run check: exit 0, 190 tests.
The last two mutation gaps from the confirmation round. The Android and iOS alternate links were asserted by attribute name only, so swapping the two would have passed while Android users were sent to the iOS hand-off and back. Both hrefs are pinned now. shouldRewriteItunesBanner matches its prefixes at a segment boundary, but no test used a path that merely starts with the same letters, so dropping the slash would have gone unnoticed and pulled /invitee and /promotion into the rewrite. Four negative cases cover it. npm run check: exit 0, 190 tests. Verified by mutation: swapping the two hand-off URLs, and widening the prefix match, each turn a test red.
The release round raised four points, and this closes all of them. The one that touches behaviour: the shell was recognised by a single substring, so an error document carrying `id="state-loading"` anywhere — a comment, a script, a copied snippet — would have been promoted to 200 and dressed in an invitation's metadata. The shipped 404 page does not carry it, and a contract test pinned that, but nothing covered an error document the site does not ship, such as an edge error or a challenge page. Recognition now needs both that id and `aria-busy="true"`, which the two landings carry and nothing else arrives at by accident. The case that used to be a documented boundary is now simply handled: such a page keeps its 404 and its own body. Three coverage gaps for behaviour that was already correct: - `/promo` exactly was never tested, so deleting that clause would have stopped the promo landing being rewritten without a red test. - The replace branch of the alternate-link upsert had never run: no fixture carried an existing link. Both attribute orders are covered, and the old href has to be gone rather than joined by a second tag. - The iPad Twitter app card was emitted and asserted nowhere. npm run check: exit 0, 191 tests. Verified by mutation: dropping the second mark, removing the exact `/promo` clause, giving the iPad card a wrong URL, and making the upsert always append each turn a test red.
The round on the previous commit found only its own after-effects, and this clears them. Both documents still described the guard as keying on one mark. That is the drift this series keeps correcting, one commit after introducing it: they name both now. The same singular sat in the middleware comment, in the test file's header and in two test titles. Two claims of mine were wrong rather than merely stale: - The commit that added the replace-branch test said it covered both attribute orders. Both fixtures put the attribute before the href, so the branch for the other order had still never run. A third fixture covers it, and disabling that branch now turns the test red. - The Latin-1 test said its body carried the landing marker so that only the charset guard stood between it and the rewrite. With two marks required and only one present, that was no longer true — the marks check would have blocked it as well. The body carries both now, so the test isolates what it claims to. npm run check: exit 0, 191 tests.
"Never wrong, only larger than it had to be" claimed too much. The body a client gets is always the current representation, so that half holds. The status is the part that deviates: RFC 9110 puts 304 and 412 at SHOULD, and this pass answers 200 instead. The comment now separates the two. npm run check: exit 0, 191 tests.
The last open point from the logic lane. Nothing in the suite carried both words without the attribute syntax, so loosening the marks from `id="state-loading"` / `aria-busy="true"` to bare `state-loading` / `aria-busy` would have passed — and any page mentioning both in prose would then have been promoted to 200 and dressed as an invitation. A page that does exactly that is now a case, and it keeps its 404 and its body. npm run check: exit 0, 191 tests. Verified by mutation: loosening the marks to bare words turns it red.
The last point from the round on 880311e. The comment still called the attribute-in-a-comment case a boundary the check cannot see, while the assertion three lines below asserts a 404 because the second mark closes it. It says what is true now, and what it used to be. npm run check: exit 0, 191 tests.
…nough The marker check is a substring test. Four comments and two doc paragraphs claimed more than that, or more than the code around them holds: - two comments called a pass-through "passed on as they are" and "handed on exactly as it came", which is true of a GET and not of a HEAD, whose answer is rebuilt without a body three lines below; - the landingStatus JSDoc used "rewrite" for both the tag injection that has already happened and the status promotion it performs itself; - the isLandingShell JSDoc said two marks together are "not something another page arrives at by accident", which the check does not establish: it never asks whether the marks share an element; - CONTRIBUTING and README said these paths emit no validator, which holds for a rewritten landing and not for a pass-through, where the origin's ETag is kept and a test already pins it. What actually makes two substrings enough is a property of the shipped site, so it is now asserted instead of described: of every HTML page under public/, only the two the Function is routed to read as a landing shell. The account-merge and Aktionariat shells carry the id and no aria-busy, the 404 page carries neither. The test goes red the day one of them gains the second mark, which is the day the guard would have to become a real parse.
…t rests on Three statements from the previous commit did not hold, and one measurement was missing: - CONTRIBUTING said the marks are carried by the landings and by "no page served under these paths", which contradicts itself: the landings are the pages served under these paths. It now says no other page the site ships. - The isLandingShell JSDoc credited the function with establishing that no other shipped page reaches both marks. The function performs a substring test and establishes nothing about other files; the property is held by the test, which the docstring now says. - The path gate excludes twelve asset-like suffixes, and a review read that as leaving /invite/AB.JSON without the promotion. Measured on the deploy instead of argued: such a path is answered with the site's own 404 page, not with the landing shell, so the promotion would refuse it on the marks in any case. What a code like that loses is the landing itself, which is the follow-up #30 left open. The gate now says so. The repo-wide test pinned only three of the six shipped pages by name, so a walker that quietly stopped finding the others would still have passed: the filtered list would simply have had nothing left to disagree with. It now pins the whole inventory first, and the doubled-slash and /invites path boundaries are asserted rather than reasoned about.
|
EN. 43 commits, every one re-reviewed as unreviewed code by four lanes — two vendors × conformity and logic. The last rounds report nothing that changes behaviour for a user or a crawler. Every guard here is verified by mutation rather than by assertion: the fault is put back and the test has to fail. DE. 43 Commits, jeder davon erneut als ungeprüfter Code durch vier Prüfungen — zwei Anbieter × Konformität und Logik. Die letzten Runden melden nichts, was das Verhalten für einen Nutzer oder einen Crawler ändert. Jede Absicherung ist per Mutation belegt: der Fehler wird wieder eingebaut, und der Test muss anschlagen. What the rounds found, and what it cost to find itThe bug this PR opened with was one line: the middleware copied the asset server's status through. Fixing it opened five more that only showed up under review, three of which would have reached a user:
The rest were mine to answer for rather than to be proud of: comments claiming more than the code held, and assertions that could not tell the fixed version from the broken one. Three times an assertion checked that a header was absent while the fixture never set it — always true, and only the mutation check exposed it. Deliberately not fixed, and whyA stated deviation from RFC 9110 §13.1. The conditional request headers are dropped rather than evaluated, so Two sham tests in already-merged code, found while reviewing this one: the clipboard stub in Carried into the follow-upNone of these changes what a user or a crawler gets from the deploy today. The two-mark check is a substring test and blind to HTML structure. A document carrying both marks only inside a comment would be promoted. What keeps that from mattering is a property of the site, and the property is now asserted rather than described: of every HTML page under Three statements still say more than they hold. The JSDoc on Tests worth adding. HEAD against each of 204, 205 and 304 rather than only 206; both marks together inside a comment, as the negative case for the item above; a HEAD that arrives carrying a body, which today is covered for GET only; and the single-mark cases asserted against The pass-through guard is only observable at 206. A 204, 205 or 304 carries no body by construction, so with the guard removed the answer falls through to the not-a-shell branch and comes back identical. The test for those three statuses therefore cannot fail on that mutation; 206, which does carry a body, is where the guard is actually proven. Worth saying in the test rather than leaving a reader to assume more. The page walker matches Codes that end in an asset suffix get no landing, an open item from #30. The path gate excludes twelve suffixes, A The body-carrying branch of The body-carrying fallback in |
…)" This reverts commit 1555d93.
…che, two tests that prove something Four follow-ups the reviews of #34 and #37 left open. The gate that decides whether this pass owns a path and the parser that reads the code out of it both asked whether the segment names a file, with two different lists: one carried html and not the image types, the other the reverse. /invite/AB.HTML therefore passed the gate and then lost its code, while /invite/AB.JSON never reached the gate at all. Both now read the same constant, and a case walks every suffix requiring the two answers to agree. html is deliberately not in that list. The one HTML file under these paths is the shell, which the parser names outright; putting the suffix in would take /invite/index.html out of this pass along with the redirect that canonicalises it. A code that happens to end in .HTML stays a code, on both sides. public/_headers had a rule for /invite/ and another for /invite/*, and the first matches the second. Every answer that matched both carried the value twice: measured on the deploy, /invite/ answered cache-control: public, max-age=60, public, max-age=60 The rules covered by the wildcards are gone, the bare paths keep theirs, and check-site.mjs now requires the wildcard and fails if a covered rule comes back. Two tests could not tell a working build from a broken one. The clipboard stub resolved and discarded what it was handed, so the case named after copying the canonical invite URL passed on any string, or on none; it records now, and the case requires exactly one write carrying the code and matching the canonical link. And the fifteen-second lookup budget was asserted against its own constant and nowhere else: a request that never answers now has to leave the loading state by itself, still waiting one second short of the budget and unavailable two seconds past it.
…che, two tests that prove something Four follow-ups the reviews of #34 and #37 left open. The gate that decides whether this pass owns a path and the parser that reads the code out of it both asked whether the segment names a file, with two different lists: one carried html and not the image types, the other the reverse. /invite/AB.HTML therefore passed the gate and then lost its code, while /invite/AB.JSON never reached the gate at all. Both now read the same constant, and a case walks every suffix requiring the two answers to agree. html is deliberately not in that list. The one HTML file under these paths is the shell, which the parser names outright; putting the suffix in would take /invite/index.html out of this pass along with the redirect that canonicalises it. A code that happens to end in .HTML stays a code, on both sides. public/_headers had a rule for /invite/ and another for /invite/*, and the first matches the second. Every answer that matched both carried the value twice: measured on the deploy, /invite/ answered cache-control: public, max-age=60, public, max-age=60 The rules covered by the wildcards are gone, the bare paths keep theirs, and check-site.mjs now requires the wildcard and fails if a covered rule comes back. Two tests could not tell a working build from a broken one. The clipboard stub resolved and discarded what it was handed, so the case named after copying the canonical invite URL passed on any string, or on none; it records now, and the case requires exactly one write carrying the code and matching the canonical link. And the fifteen-second lookup budget was asserted against its own constant and nowhere else: a request that never answers now has to leave the loading state by itself, still waiting one second short of the budget and unavailable two seconds past it.
…che, two tests that prove something Four follow-ups the reviews of #34 and #37 left open. The gate that decides whether this pass owns a path and the parser that reads the code out of it both asked whether the segment names a file, with two different lists: one carried html and not the image types, the other the reverse. /invite/AB.HTML therefore passed the gate and then lost its code, while /invite/AB.JSON never reached the gate at all. Both now read the same constant, and a case walks every suffix requiring the two answers to agree. html is deliberately not in that list. The one HTML file under these paths is the shell, which the parser names outright; putting the suffix in would take /invite/index.html out of this pass along with the redirect that canonicalises it. A code that happens to end in .HTML stays a code, on both sides. public/_headers had a rule for /invite/ and another for /invite/*, and the first matches the second. Every answer that matched both carried the value twice: measured on the deploy, /invite/ answered cache-control: public, max-age=60, public, max-age=60 The rules covered by the wildcards are gone, the bare paths keep theirs, and check-site.mjs now requires the wildcard and fails if a covered rule comes back. Two tests could not tell a working build from a broken one. The clipboard stub resolved and discarded what it was handed, so the case named after copying the canonical invite URL passed on any string, or on none; it records now, and the case requires exactly one write carrying the code and matching the canonical link. And the fifteen-second lookup budget was asserted against its own constant and nowhere else: a request that never answers now has to leave the loading state by itself, still waiting one second short of the budget and unavailable two seconds past it.
…two tests that prove something Four follow-ups the reviews of #34 and #37 left open. The gate that decides whether this pass owns a path and the parser that reads the code out of it both asked whether the segment names a file, with two different lists: one carried html and not the image types, the other the reverse. So /invite/AB.HTML passed the gate and then lost its code, while /invite/AB.JSON never reached the gate at all. Both read one constant now, and a case asks the two as a single question across every suffix — the sweep includes html, a suffix that names no file type, and a mixed-case spelling — so a list that grows on one side and not the other fails there rather than in production. html is deliberately not in that list. The one HTML file under these paths is the shell, which the parser names outright; putting the suffix in would take /invite/index.html out of this pass along with the redirect that canonicalises it. A code that happens to end in .HTML stays a code, on both sides. public/_headers had a rule for /invite/ and another for /invite/*, and the first matches the second. Every answer that matched both carried the value twice: measured on the deploy, /invite/ answered cache-control: public, max-age=60, public, max-age=60 Pages concatenates what every matching rule says rather than letting one win, and a parser takes the first. The rules the wildcards already cover are gone, the bare paths keep theirs, and /invite/invite.js keeps its own rule above the wildcard — the restatement below it, added when the match order was thought to be uncertain, only appended a third value nobody reads. check-site.mjs now requires the wildcard and fails if a covered rule comes back. Two tests could not tell a working build from a broken one. The clipboard stub resolved and discarded what it was handed, so the case named after copying the canonical invite URL passed on any string, or on none; it records now, and the case requires exactly one write, carrying the code, equal to the canonical link. And the fifteen-second lookup budget was asserted against its own constant and nowhere else: a request that never answers now has to leave the loading state by itself, and the case measures how long that took against the budget the page itself reports rather than bracketing it loosely.
…two tests that prove something Four follow-ups the reviews of #34 and #37 left open. The gate that decides whether this pass owns a path and the parser that reads the code out of it both asked whether a suffix names a file, with two different sets: the gate carried jpg|jpeg|webp|ico|txt|xml that the parser lacked, the parser carried html that the gate lacked. So /invite/AB.HTML passed the gate and then lost its code, while /invite/AB.JSON never reached the gate at all. Both read one constant now, and a case asks the two as a single question across every suffix — the sweep includes html, a suffix that names no file type, and a mixed-case spelling — so a set that grows on one side and not the other fails there rather than in production. The two still ask it of different things, deliberately: the gate of the whole path, because /invite/AB12CD/logo.png is an asset request whoever owns the first segment, and the parser of the code segment alone. A case pins that difference as well as the agreement. html is not in the list for one reason: a code that happens to end in .HTML stays a code. The shell needs no help from the list — the parser names index.html outright — and the 308 that canonicalises it comes from the platform and is handed on either way. public/_headers had a rule for /invite/ and another for /invite/*, and the first matches the second. Every answer that matched both carried the value twice: measured on the deploy, /invite/ answered cache-control: public, max-age=60, public, max-age=60 Pages concatenates what every matching rule says rather than letting one win, and a parser takes the first. The rules the wildcards already cover are gone, the bare paths keep theirs, and /invite/invite.js keeps its own rule above the wildcard — the restatement below it, added when the match order was thought to be uncertain, only appended a third value nobody reads. check-site.mjs now requires the wildcard, rejects a rule the wildcard covers, and rejects the same exact path declared twice, which its own header parser had been merging out of sight. Two tests could not tell a working build from a broken one. The clipboard stub resolved and discarded what it was handed, so the case named after copying the canonical invite URL passed on any string, or on none; it records now, and the case requires exactly one write, equal to the href the canonical link names. And the fifteen-second lookup budget was asserted against its own constant and nowhere else: a request that never answers now has to leave the loading state by itself, and the case measures how long that took against the budget the page itself reports.
* fix(invite): one list for a file-looking segment, one rule per path, two tests that prove something Four follow-ups the reviews of #34 and #37 left open. The gate that decides whether this pass owns a path and the parser that reads the code out of it both asked whether a suffix names a file, with two different sets: the gate carried jpg|jpeg|webp|ico|txt|xml that the parser lacked, the parser carried html that the gate lacked. So /invite/AB.HTML passed the gate and then lost its code, while /invite/AB.JSON never reached the gate at all. Both read one constant now, and a case asks the two as a single question across every suffix — the sweep includes html, a suffix that names no file type, and a mixed-case spelling — so a set that grows on one side and not the other fails there rather than in production. The two still ask it of different things, deliberately: the gate of the whole path, because /invite/AB12CD/logo.png is an asset request whoever owns the first segment, and the parser of the code segment alone. A case pins that difference as well as the agreement. html is not in the list for one reason: a code that happens to end in .HTML stays a code. The shell needs no help from the list — the parser names index.html outright — and the 308 that canonicalises it comes from the platform and is handed on either way. public/_headers had a rule for /invite/ and another for /invite/*, and the first matches the second. Every answer that matched both carried the value twice: measured on the deploy, /invite/ answered cache-control: public, max-age=60, public, max-age=60 Pages concatenates what every matching rule says rather than letting one win, and a parser takes the first. The rules the wildcards already cover are gone, the bare paths keep theirs, and /invite/invite.js keeps its own rule above the wildcard — the restatement below it, added when the match order was thought to be uncertain, only appended a third value nobody reads. check-site.mjs now requires the wildcard, rejects a rule the wildcard covers, and rejects the same exact path declared twice, which its own header parser had been merging out of sight. Two tests could not tell a working build from a broken one. The clipboard stub resolved and discarded what it was handed, so the case named after copying the canonical invite URL passed on any string, or on none; it records now, and the case requires exactly one write, equal to the href the canonical link names. And the fifteen-second lookup budget was asserted against its own constant and nowhere else: a request that never answers now has to leave the loading state by itself, and the case measures how long that took against the budget the page itself reports. * test(invite): say which of the two claims does which job The comment said the budget is read from the page rather than restated here, and the line under it restates it. Both belong there — the size is pinned outright, because bounds computed from the page's own number would follow it anywhere, and the elapsed time is measured against that number so another timer cannot pass for this one. The comment now says that instead of denying half of it. * test(invite): three comments that claimed a little more than the code The sweep's comment implied it catches any divergence between the two sides, where it walks a list of its own and catches a divergence on what it enumerates. The .PDF case called it a dot that names no file type, where PDF is a file type this rule simply does not call an asset. And the timeout case said the budget was asserted against itself, where the unit suite pinned it against a literal and went no further. * fix(invite): catch any covered rule, and start the clock where the wait does The covered-path check listed the four rules that happened to be there, so a rule added later would have slipped past it. It now rejects any exact path a landing wildcard covers, with the script's own rule kept on purpose: it wants a longer cache than the shells, and a parser takes the first of the two values. Proven by adding a rule for /invite/foo and watching it fail. The budget case took its timestamp before the navigation, so a slow page load counted against the budget and could push the upper bound over on a busy machine. It starts when the page says it is checking, which is when the timer the case is about was armed. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com>
EN. Every invite and promo link answered
HTTP 404in production. The body was right — the landing shell with the rewritten meta tags — but the status was not, so share crawlers dropped the response before reading the tags the middleware exists to write, and every shared referral link previewed as nothing. This is the open question #30 deferred and #32 promised to settle with onecurlafter deploy; the curl was run, and this is the answer plus the fix.DE. Jeder Einladungs- und Promo-Link antwortete produktiv mit
HTTP 404. Der Inhalt stimmte, der Status nicht. Vorschau-Dienste verwerfen solche Antworten, bevor sie die Metadaten lesen, für die es die ganze Umschreibung gibt — geteilte Empfehlungslinks zeigten deshalb keine Vorschau. Das ist die Frage, die #30 offenließ und #32 auf „eincurlnach der Auslieferung" vertagte. Der Aufruf ist erfolgt, hier ist die Antwort samt Korrektur.What was measured
The body is the landing shell, correctly rewritten.
/invite/without a code answers200, so only the code-bearing paths — the ones people actually share — were affected.Cause.
public/_routes.jsonsends/invite/*to the Function. The asset lookup behindcontext.next()resolves the code-less shell through the_redirects200-rewrite but keeps the not-found status of the path that was asked for, and the middleware copied that status through unchanged.Impact. A browser renders a 404 body, so a person following the link saw the right page and the lookup ran. A crawler did not: WhatsApp, iMessage, Slack, Facebook and X drop a 404 before parsing
og:*. The rewrite these pages are built around therefore reached nobody.The fix
landingStatus(status, html)promotes 404 to 200 only when the body carries both landing marks —id="state-loading"andaria-busy="true"— which both landings have in the same element and the site's own 404 page has neither of. One mark alone would have promoted any error document that happens to contain it. Without the guard, a broken deploy serving404.htmlon these paths would start answering 200 and look healthy.The check stays a substring test, so what makes two marks enough is a property of the site rather than of the parser — and that property is asserted rather than described: of every HTML page under
public/, only the two the Function is routed to read as a landing shell. The account-merge and Aktionariat shells carry the id and noaria-busy; the 404 page carries neither. The test goes red the day one of them gains the second mark, which is the day the guard would have to become a real parse.Everything around that guard exists because a review round found it missing:
curl -sIsends and the one a link checker sends first, so the command quoted above as the proof would otherwise still have read 404. A HEAD response carries no body and the body is what tells the shell from the 404 page, so HEAD asks for the GET-equivalent and answers with that status and those headers, without a body.Range,If-Rangeand the four conditional request headers are dropped, because 206, 304 and 412 are all answers this pass cannot rewrite.functions/_middleware.jswas outsidecoverage.includewhile it decided the status every crawler sees — which is how the original bug shipped through a full double review. It is measured now, at 100%, with its own test file.Verification
npm run check: exit 0, 192 tests, all thresholds met,functions/_middleware.jsat 100% in all four metrics.content-length,content-encoding,accept-rangesor a digest after a rewrite; matching the media type as a substring; dropping the charset guard; dropping quote-awareness or the closing-quote check; letting the last charset win; rebuilding a pass-through answer instead of handing it on; renaming a mark in the shipped landing; adding both marks to the shipped 404 page; giving any other shipped page the second mark; breaking theog:urlor thetwitter:urlreplacement alone; swapping the Android and iOS hand-offs; widening the path prefix.Deliberately not fixed here
A stated deviation from RFC 9110 §13.1. Dropping the conditional request headers means a client sending
If-None-Match: *is answered 200 with the whole document where 304 was called for, andIf-Matchnever produces 412. The two kinds of precondition are not equally hard. A tag or date condition would need a validator minted for the rewritten document, which none of these paths emits and which is a larger change than this one. The wildcard forms need no validator at all, so they are dropped for uniformity rather than out of necessity. Either way the client gets the current representation, so the body is never stale; the status is what diverges, and the cost is a larger answer than was called for, not a wrong one. The code, CONTRIBUTING and README all say so.Two asset-extension lists that disagree, from #30.
shouldRewriteItunesBannerexcludesjs|css|map|png|svg|json|jpg|jpeg|webp|ico|txt|xml;parseLandingFromUrltreatsjs|css|map|png|svg|json|htmlas an asset suffix. Codes may contain internal dots, so/invite/ABC.JSONskips the middleware entirely while/invite/ABC.HTMLloses its code. Measured on the deploy rather than argued:/invite/AB.JSONand/promo/EVT1.HTMLare answered with the site's own 404 page, not with the landing shell, so the promotion would refuse them on the marks in any case. What a code like that loses is the landing itself. Both functions predate this PR and the lists want reconciling in their own change.Two sham tests in already-merged code, found while reviewing this one: the clipboard stub in
tests/behavior.spec.mjsdiscards the value it receives, so a test titled "copies the canonical invite URL" would pass on any string; and the 15-second lookup timeout is asserted only as a constant, never as a timeout. Follow-up.The body-carrying fallback in
asFullGetis covered by unit tests, not by the platform. A GET or HEAD that arrives with a body cannot be re-methoded, so the request is rebuilt from its URL, which drops the platform's own request metadata. The tests pin the headers and the method; whether Pages ever delivers such a request, and what the dropped metadata would have been worth, is untested against the real runtime.