Invite and promo links never reach the landing page - #37
Conversation
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
Release: develop -> main
…edirect 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.
…e 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.
Reading the shell for every landing path also replaced two answers that were already right: /invite/ and /promo/ are real files the platform serves with 200, and /invite/index.html comes back as the 308 that canonicalises it. The first was still correct because it was rewritten anyway; the second was not — serving the landing under the explicit file name would quietly create a second URL for the same page. The platform is now asked first. A 404 is the one answer that means the 200-rewrite never resolved the path, and only that case is answered from the asset binding. Everything else stands: a redirect is handed back as it came, a non-HTML answer likewise, and an HTML answer that really is the shell keeps its status and is only rewritten in place.
Three statements no longer matched the code they sit next to: - the coverage comment still said only browser logic is measured to 100%, while the include beside it now names the Pages Function as well; - the quality-gate table did not mention the 100% gate this adds for functions/_middleware.js, which is the gate whose absence let a change that served the 404 page on every invite link through a full review; - both docs said _routes.json hands /invite/* and /promo/* to the Function, where the file names four paths and check-site.mjs requires all four. The paragraphs also still described the old shape, where the shell was read for every landing path. They now say the platform is asked first and only its 404 is replaced, and that /invite/ is rewritten in place while /invite and /invite/index.html keep their 308. A test carries the request URL through onRequest with ?lang=en. Passing the pathname alone would have produced a landing in the wrong language with nothing in this file to notice.
Copying the source response's headers carried in the ones public/_headers had already set, and Pages sets them again on the way out. Measured on the deploy, before this change and on both the old code and the new: /invite/ answers with cache-control: public, max-age=60, public, max-age=60 The answer's headers are now built: a content-type, and whatever _headers adds. That removes the duplicate and makes the list of headers to delete unnecessary, since none of them is carried in to begin with. The case that asserted their absence now asserts the whole header set, and its fixture sets a Cache-Control so the duplicate it guards against is reachable. The shell lookup asks for no redirect to be followed. A 3xx was already refused by the ok check; saying so at the call is cheaper than relying on it. Two assertions were weaker than they read. The invite case checked that the string og:title appears, which both shells carry already; it now checks the injected value. The promo case checked for the bare code, which an invitation-shaped injection would also satisfy; it now checks the kind.
Asking the asset binding for redirect: 'manual' was a guard against a 3xx the ok check already refuses. Measured on a preview deploy: with it, the Function fails and Pages falls back to serving assets directly — /invite/<code> answered 404 with the site's 404 page again, and /invite/ came back unrewritten. It is removed, and the case that suggested it stays covered by the ok check. The commit that added it also blamed the duplicated Cache-Control on /invite/ on this pass copying headers. That was wrong: public/_headers has a rule for /invite/ and another for /invite/*, and /invite/ matches both, so Pages appends the value twice. It predates this change and is untouched by it. Building the answer's headers is still right — a stale validator must not be carried into a rewritten document — and the comment now says that instead.
…alidates Building the answer's headers from scratch took the site's own headers off every landing. public/_headers is applied to the asset, not to a response this Function constructs, so the copy is what carries them. Measured on a preview deploy: without it, /invite/<code> came back with a content-type and nothing else — no Content-Security-Policy, no X-Frame-Options, no X-Content-Type-Options, no Referrer-Policy, no Cache-Control. The source's headers are carried again, minus the ones that described the bytes before the injection: the length, the content coding, both validators and the integrity digests. The case now asserts both halves — that the stale ones are gone and that a security header and the cache policy survive — and its fixture sets a Content-Security-Policy so the second half can fail.
The README said context.next() answers those paths with the site's 404 page, where those paths was the four _routes.json hands over. Only the code-bearing ones come back 404; /invite/ and /promo/ are real files answered 200, and /invite and /invite/index.html come back as a 308 — which the next sentence of the same paragraph already said. CONTRIBUTING was already narrow.
…ecting binding The asset mock ignored the request's method and its path. Both mattered: - If the shell were ever asked for with the client's method, a HEAD would come back without a body, the landing marks would not be found, and the answer would fall back to the platform's 404 — the bug this pass exists to remove. The mock now records the method and a case requires GET. - The mock returned the same body for every URL, so the promo case proved the shell was asked for but not that it was used. It is path-aware now, and the promo case reads the promo shell's own copy. The binding is also asked inside a try. A Function that throws makes Pages serve the assets directly, which is precisely the wrong answer here; that was measured the hard way earlier on this branch. A rejecting binding now leaves the platform's answer standing, and a case holds that shut.
…g too A HEAD answer carries no body, and the body is what tells the landing shell from any other page. On /invite/ and /promo/ — real files the platform answers 200 — the empty body failed the shell check, so the platform's own response was handed back with the file's ETag, Content-Length and content coding intact, beside a GET on the same URL that had all three stripped. A client could then be answered 304 against a document it never received, which is the hazard the stripping exists for. A HEAD now takes the same route as a 404: the shell is read from the binding with a GET, and the answer is what the GET gets, without a body. The status branch is explicit about which answers this pass may touch at all — 200 and 404 — so a redirect or any other status is handed back before anything is read, and a 200 that is not HTML likewise. A case holds the two methods together on the codeless landing, and the content-type check on the code path now asserts the value rather than only that GET and HEAD agree.
A HEAD on the codeless landing built its answer from the shell's own headers, where the GET beside it used the platform's. public/_headers matches on the request path, so the shell's set was matched on /invite/index.html and the platform's on /invite/ — the same values today, but two sources for one URL, and the case that holds the two methods together said so by failing. The platform's headers are used wherever it had an answer of its own, and the shell's only stand in for the 404 it could not answer.
The media-type check reads a missing header as an empty string, and no case reached it, so the branch stood uncovered and the 100% gate said so.
…ers only can Two statements claimed more than they hold. The file header said every crawler snapshots the injected tags, which is a claim about other people's software; README already says can. And the in-place branch said only the metadata goes in, where the same injection also rewrites the Play href and the android-app and ios-app alternates when the URL carries a code.
The status can be fine and the stream still fail. Reading the body outside the guard would take the Function down, and a Function that throws makes Pages serve the assets directly — which is the answer this pass exists to replace. The status check and the read are inside the try now, and a case gives back a response whose text() rejects. The mock also declared exactly the content-type the assertion expected, so dropping the line that sets it would not have shown. The fixture declares text/html without a charset and the answer is still required to say UTF-8.
…adicted only Five statements said something the code does not do, all of them about the same thing: a HEAD under these paths never takes the in-place route. Its answer carries no body, so the shell cannot be recognised in it, and it reads the shell from the binding exactly as the 404 does. CONTRIBUTING, README and the comment above the branch all said the real files are only rewritten in place, without the GET that makes it true. Two more were narrower. The comment over the header source said the shell's set stands in when the platform had nothing to say, where the platform did answer and its status was simply not 200. The comment over the response said a code-bearing path is answered 200, where the same line also serves a codeless GET keeping its own 200 and a codeless HEAD. And this PR contradicted a rule it left alone: the ground rule said public/js/lib is the only code with a unit-coverage gate, which the quality-gate table stopped being true of the moment this PR added one for the middleware. The case that holds GET and HEAD together now also asserts that they reach the same answer by the two different routes, which is what the corrected sentences describe.
…both routes The shell route reads its body inside a guard because a Function that throws makes Pages serve the assets directly — the answer this pass exists to replace. The in-place route read the platform's body without one, for no reason other than that it was written first. It has the same guard now, and a case gives it a body that rejects. The stale-header list was only asserted on the route that reads the shell. A case now sets all eight on a real 200 file and requires the in-place rewrite to drop them as well, while the site's own headers survive.
…plies Three statements were wider than the code. The comment said every HEAD under these paths takes the shell route; a redirect and a non-HTML answer leave before that branch, whatever the method, and a case now holds both shut for a HEAD. The in-place branch said the store hand-off goes in, where on /invite/ and /promo/ there is no code and the injection is a no-op. And the README's testing section still named only the browser logic as measured, while this PR added the gate for functions/_middleware.js to CONTRIBUTING — the same one-of-two-places inconsistency that was already corrected once in this branch.
…lready said Three assertions were true of the fixture before any code ran. The English case looked for en_GB anywhere, which the shell carries as its alternate locale; it now requires the main og:locale tag to have changed and the document language with it. The in-place case looked for the German title and the landing marks, neither of which the injection touches; it asks in English instead, so the locale and the language are what it reads. And the cache policy was only compared between HEAD and GET, so losing it on both would have passed. This is the class of test that let the original defect through a full review: green whether the code works or not.
The branch comment said both cases answer as the GET does, where a HEAD answers without a body — which the line two above it states and this one dropped. The JSDoc over the answer said the campaign is written into it, where the campaign metadata and the store hand-off go in only when the URL carries a code, and the URL tags, the locale and the language always. And the coverage comment said the middleware decides what every crawler sees, where it is routed to the invite and promo paths alone.
Both sentences were mine from the previous commit and both were too precise to be true. The locale is written on ?lang=en, not always; the campaign title also appears without a code when English is asked for; and a bare /invite/ can carry a code as a query parameter, which the comment said it could not. Neither sentence needs the enumeration. They now name where each decision lives instead of restating it at a distance, which is what kept going wrong.
|
EN. Ready. The double review ran to a standstill: the last round, four lanes across two vendors, reports nothing that changes what a user or a crawler gets. Every step was measured on a preview deployment before it was kept — the practice whose absence let #34 reach production and break the very links it meant to fix. DE. Freigegeben. Die letzte Prüfrunde meldet nichts, was für Nutzer oder Vorschau-Dienste einen Unterschied macht. Jeder Schritt wurde vor der Übernahme auf einer Testauslieferung gemessen — genau das hat bei #34 gefehlt. What the review found, and what it cost to find itThe defect this PR opens with was invisible from the code alone: an invite link had never reached the landing page. Three attempts did not survive the preview measurement and are in this branch's history with what they measured:
The review lanes found the rest: a How the last round was runThe four lanes ran on
|
…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. A shared invite or promo link never reached the landing page.
_routes.jsonhands those paths to the Function first, so the 200-rewrite in_redirectsnever runs and the asset lookup 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 the link looked roughly right in a browser while being the wrong document — no landing markup, no scripts, so the code lookup never ran — under a status every share crawler drops. The landing is now read from the asset binding by name, injected, and answered200.DE. Ein geteilter Einladungs- oder Promo-Link hat die Einladungsseite nie erreicht. Ausgeliefert wurde die Fehlerseite der Site, der nur der Titel der Kampagne aufgesetzt wurde — ohne die eigentliche Seite und ohne ihre Skripte, und mit einem Status, den Vorschau-Dienste verwerfen. Die Seite wird jetzt direkt bei der Ablage geholt, mit den Kampagnendaten versehen und mit Status 200 beantwortet.
What was measured
Three lookups side by side inside the Function, on a preview deployment, for
/invite/AB12CD:context.next()404, 2693 bytes — the site's 404 pagecontext.next(request for /invite/index.html)308env.ASSETS.fetch('/invite/index.html')200, 9827 bytes — the landing shellProduction before this change, on the code-bearing path:
The page a visitor got had no scripts, so the code lookup never ran. It was the 404 page with the campaign's title written into it.
A preview deployment of this change:
/invite/AB12CDRealUnit — Einladung AB12CD/promo/EVT1RealUnit — Promo-Code EVT1/invite/AB12CD?lang=enRealUnit — Invitation AB12CD/invite/RealUnit — Einladung/promo/RealUnit — Promo-Code/invite/index.html/invite//invite/invite//js/invite-banner.js,/GETandHEADon/invite/agree on content-type and cache policy and neither carries anETag. The code path carries the site's Content-Security-Policy, X-Frame-Options, X-Content-Type-Options and Referrer-Policy.The shape, and why each guard is there
200and404are touched. A redirect —/inviteand/invite/index.htmlare canonicalised with a308— is handed straight back, as is a200that is not HTML or has no content-type at all.GETon a200is rewritten in place. That is/invite/and/promo/themselves: the file is already the right one and keeps its status.404, and everyHEAD, read the shell from the asset binding. A HEAD answer carries no body, and the body is what tells the landing shell from any other page; deciding from an empty one handed back the file's own validators beside a GET that had them stripped, which is how a client could be answered304against a document it never received.id="state-loading"andaria-busy="true"— which both landings carry in one element and no other page the site ships carries together. A test walkspublic/and holds that shut. A broken deploy that puts the 404 page underinvite/index.htmlkeeps saying 404.public/_headersis applied to the asset, not to an answer this Function constructs, so it took the site's security headers off every landing.functions/_middleware.jsis undercoverage.includeat 100% in all four metrics. It was outside it while deciding which page every visitor and crawler sees, which is how the previous attempt reached production.Relation to #34 and #35
#34 tried to fix the status alone, on the premise that the body was already the landing shell. It was not. #34's own marker guard therefore refused the body it was given, and with the older unconditional injection gone, visitors were shown the bare 404 page. #35 reverted it, and production went back to the 404 page with a campaign title. This change fixes the body and the status together.
Every step here was measured on a preview deployment before it was kept, which is the practice #34 lacked. Three attempts did not survive that and are in this branch's history with what they measured: asking the asset binding for
redirect: 'manual'made the Function fail, so Pages served the assets directly and the code paths answered404again; building the answer's headers from scratch dropped the site's security headers; and taking the headers from the shell rather than from the requested URL madeHEADandGETdisagree.Carried into a follow-up
Neither reaches a request this deploy can receive today; both live on the fallback branches, which need the asset binding to be absent or to fail, and Pages always provides it.
A
304from the platform is handed back as it came. A client can only ask for one by sending a validator it was given, and a rewritten answer carries none. It could only have got one from a raw fallback answer, which is the branch that already requires the binding to have failed.The fallback on a
HEADhands back the file's own validators, where aGETon the same path has them stripped. Each answer is honest about what it describes, but the two diverge, and only on that same unreachable branch.A
HEADon a second HTML file under these paths would be answered from the shell where aGETwould hand the file back. No such file exists; the test that walkspublic/goes red the day one appears.Left alone deliberately
Cache-Controlappears twice on the landings.public/_headerscarries a rule for/invite/and another for/invite/*, and/invite/matches both, so Pages appends the value twice. It readspublic, max-age=60, public, max-age=60on production today, on the code that predates this change as well as on this one. The list parses and the firstmax-agewins; tidying the rules is its own change.scripts/dev-server.mjsserves the landings by its own routing and does not model the 404-first shape, so the Playwright suite exercises the injection but not this decision. Pinning that would mean teaching the dev server the platform's behaviour, which is a change of its own.