Skip to content

Release: develop -> main - #38

Merged
TaprootFreak merged 1 commit into
mainfrom
develop
Sep 9, 2026
Merged

TaprootFreak merged 1 commit into
mainfrom
develop

Conversation

@github-actions

@github-actions github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

* fix(invite): serve the landing from the asset binding, not from the redirect

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.

* docs(invite): say where the landing comes from, now that it is not the redirect

CONTRIBUTING and README described the Function as a rewrite of bytes that were
already the landing. They were not: the 200-rewrites in _redirects never run on
these paths, so the answer behind context.next() is the site's 404 page. Both
now say that the shell is read from the asset binding by name, checked against
the two landing marks, injected and answered 200.

* fix(invite): answer only the case the platform gets wrong

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.

* docs(invite): name the four routed paths, the new gate, and what stands

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.

* fix(invite): build the answer's headers instead of copying them

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.

* fix(invite): drop a redirect mode the runtime will not take

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.

* fix(invite): carry the site's headers, drop only what the rewrite invalidates

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.

* docs(invite): say which paths the platform gets wrong, not all four

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.

* test(invite): pin the method the shell is asked with, and catch a rejecting 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.

* fix(invite): let a HEAD answer as the GET does on the codeless landing 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.

* fix(invite): take the answer's headers from the URL that was asked for

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.

* test(invite): the answer with no content-type at all

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.

* docs(invite): the injection also writes the store hand-off, and crawlers 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.

* fix(invite): guard the shell's body, not only the call that fetches it

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.

* docs(invite): say that a HEAD takes the other route, and drop a contradicted 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.

* fix(invite): guard the in-place read too, and hold the stale list on 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.

* docs(invite): bound the HEAD exception, and say where the hand-off applies

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.

* test(invite): assert what the injection changes, not what the shell already 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.

* docs(invite): three comments that claimed a little more than they hold

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.

* docs(invite): stop enumerating what the injection writes

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.

---------

Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com>
@TaprootFreak
TaprootFreak merged commit f6507ba into main Sep 9, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants