Correct the engine claims, and stop narrating the document's own history

A review pass found the measured section had introduced a contradiction while
fixing one. It said Chromium, WebKit and Safari all refuse the server pair
because the default emits no SameSite attribute, then said two paragraphs later
that Safari blocks third-party cookies whatever the attribute says. Only the
refusal was measured on all three; the cause is the attribute on Chromium and
third-party blocking on WebKit and Safari, and the section now says which is
which.

Three more places stated a single engine's behaviour as every engine's: the
claim that the browser drops the server pair, which Firefox does not; the
Firefox session described as riding on an unpartitioned cookie, when Total
Cookie Protection stores it partitioned despite the missing attribute, which is
why it survives; and the flat claim that master satisfies the criterion, which
Firefox's block-all mode is the exception to.

OAuth off-domain is no longer called impossible outright, since an unpartitioned
cookie is still delivered on a permissive browser with AUTH_SAME_SITE=none; it
fails wherever third-party cookies are blocked or partitioned, and under the
recommended recipe everywhere. The Storage Access route is marked as reasoned
from browser policy instead of measured, because nothing in the campaign called
requestStorageAccess, and Firefox is added to the list of implementers.

Also drops four passages narrating earlier drafts of this document, which the
reader never saw, and refreshes counts that had gone stale against 7de51ad2:
143 and 158 source files, twenty-one and not twenty, and one anchor commit
instead of two.
This commit is contained in:
Dmitry Verkhoturov
2026-08-24 01:31:54 +01:00
parent 10c5b158db
commit ca7f55d659
@@ -1,9 +1,9 @@
# Frontend direction: two viable paths, and what has to be true for either
Written 2026-08-19, revised 2026-08-22. Every file reference below was re-checked against master
`a82dc8d3` plus #2196, #2197 and #2198, which are treated here as landed: they change the e2e net,
the manifest layout, the fallback page, the asset path and the instance URL, and costing either
direction against the state before them would be costing a world that no longer exists.
Written 2026-08-19, revised 2026-08-24. Every file reference below was re-checked against master
`7de51ad2`. The frontend work merged since 2026-08-22 is treated here as landed: it changes the e2e
net, the manifest layout, the fallback page, the asset path and the instance URL, and costing either
direction against the state before it would be costing a world that no longer exists.
## Overview
@@ -40,15 +40,17 @@ page, so a test that signs in and posts without reloading passes while the persi
broken.
**Two things about this criterion are decisions, not findings, and they belong to the
maintainer.** They are marked because an earlier version of this section made both silently.
maintainer.** They are marked so neither is settled by implication.
The first is that it excludes OAuth, where the requirement as originally written said any
configured provider. That is a narrowing of product scope, not a correction of fact. OAuth
off-domain is impossible for the flow as built, but the routes priced below would change that, and
off-domain fails for the flow as built on any browser that blocks or partitions third-party
cookies, which is Safari's default and the recommended recipe everywhere, but the routes priced
below would change that, and
the narrowing sits awkwardly beside `fixing #1139 must not get harder` in the Path B constraints,
which is the same subject. Either the requirement excludes OAuth and #1139 is a separate goal
carrying its own timeline, or the requirement keeps OAuth and is not met today. This document
assumes the first and is not entitled to.
assumes the first and is not entitled to assume it.
The second is what counts as evidence. The criterion is met for anonymous and email by measurement,
and for Telegram by inference from the client writer keying off `X-JWT` and not off the provider. A
@@ -70,11 +72,14 @@ mandatory.
The first is the Storage Access API, which exists for exactly this shape: an embedded frame asks
the browser, on a user gesture, for permission to send its own unpartitioned first-party cookies,
and Safari and Chrome both implement it. Note what the grant is and is not. It lets the frame's
and Safari, Chrome and Firefox all implement it. Note what the grant is and is not. It lets the
frame's
requests carry that cookie; it does not make the cookie script-readable, and the JWT cookie is
`HttpOnly` in any case, which is the same point the OAuth paragraph above makes.
It is also not available to remark42 as the flow stands, which is the part worth pricing. A browser
It is also not available to remark42 as the flow stands, which is the part worth pricing. That much
is read off documented browser policy and the code, not measured: nothing in the campaign called
`requestStorageAccess`. A browser
denies the request outright when the embedded origin has no recent first-party interaction to
grant against, and remark42 never acquires one: the reader interacts on the provider's origin, and
the callback returns to the remark42 origin at a document whose first statement is `window.close()`
@@ -83,7 +88,7 @@ under `?selfClose` in `iframe.ejs`. Nothing happens there that a browser counts
So the cost is not a permission prompt bolted onto the existing flow. Either the callback stops
closing itself and collects a click first, or something else establishes first-party interaction on
the instance's own origin before the frame ever asks. That is a change to the first-party
experience, which is a different order of cost from the other two routes and has to be compared as
experience, which is a different order of cost from either handoff shape and has to be weighed as
one.
The second is a server-mediated handoff, where the popup posts a one-time code to its opener and
@@ -97,7 +102,9 @@ route is not, and is the one worth pricing first for that reason. Until somethin
operator who needs OAuth off-domain serves remark42 from the same registrable domain as the site,
or accepts that OAuth readers sign in on the instance's own origin.
**Where that stands.** Master satisfies the criterion today, provided `AUTH_SEND_JWT_HEADER` is on.
**Where that stands.** Master satisfies the criterion today on every browser measured except one,
provided `AUTH_SEND_JWT_HEADER` is on. The exception is Firefox with "block all third-party cookies"
chosen, which discards partitioned cookies too and which no configuration reaches.
The server returns the token in `X-JWT` and `fetcher.ts` writes the `JWT` and `XSRF-TOKEN` cookies
itself through `authCookieOptions`, which marks them `SameSite=None; Secure; Partitioned` in a
third-party context. The attribute is what carries the reload, not the fact that the write happened
@@ -114,34 +121,33 @@ code and verified on Chromium. Driving the real thing on two genuinely different
with real certificates, across Chromium, Firefox, WebKit and Safari 27, shows the recommended
arrangement working everywhere except one case, and working for two different reasons.
On Chromium, WebKit and Safari the server's own pair is refused, because `AUTH_SAME_SITE` defaults
to
emitting no `SameSite` attribute at all and a cross-site cookie without one is rejected. That
refusal
is what clears the field for the widget's partitioned pair, which is what survives. The absence of
the attribute is doing work here, which is worth stating because it reads like an unset default.
On Chromium, WebKit and Safari the server's own pair is refused, and that refusal is what clears
the field for the widget's partitioned pair. The reason differs by engine, which matters to anyone
reasoning forward from it. On Chromium the cause is the attribute: `AUTH_SAME_SITE` defaults to
emitting no `SameSite` at all, and Chromium treats a missing attribute as `Lax`, so the cookie is
not sent cross-site. On WebKit and Safari the cause is third-party blocking, which applies whatever
the attribute says. Only the refusal itself was measured on all three; the causes are read off
documented browser behaviour.
On Firefox the server's pair is accepted, and since the `JWT` it sets is `HttpOnly`, the browser
then
forbids the widget's script from replacing a cookie of that name. So Firefox never holds a
partitioned
copy and the session rides on an ordinary unpartitioned third-party cookie even with the flag on. It
works, and it stops working the moment the reader blocks those. Firefox's own default, Total Cookie
Protection, partitions rather than blocks and is therefore fine; its opt-in "block all third-party
cookies" discards partitioned cookies too, so no configuration survives it and nothing in remark42
can change that.
then forbids the widget's script from replacing a cookie of that name. So Firefox never holds a
partitioned copy of its own, and the session rides on the server's cookie instead. Under Firefox's
default, Total Cookie Protection, that cookie carries no `Partitioned` attribute but the browser
stores it in a per-site partition anyway, which is why it survives. Its opt-in "block all
third-party cookies" discards partitioned cookies as well, so nothing survives it and nothing in
remark42 can change that.
Safari is the sharp end: it blocks third-party cookies with nothing configured, so
`AUTH_SAME_SITE=none`
on its own has already stopped working there for every reader. The old recipe is not deprecated, it
is broken, which is the strongest argument for treating R1 as live work rather than a documented
is broken, which is the strongest argument for treating R1 as live work and not a documented
limitation.
**That changes what the upstream `Partitioned` work is worth.** Building `go-pkgz/auth` with a
`PartitionedCookies` option and running the same matrix gives the same persistence with the server's
`JWT` still `HttpOnly`: on Safari, `document.cookie` inside the frame returns the token under the
header flag and does not under the partitioned build, while both keep the reader signed in. So the
two routes are not equivalent, and the header path's XSS cost is avoidable rather than inherent.
two routes are not equivalent, and the header path's token exposure is avoidable, not inherent.
`go-pkgz/auth` #318 carries that change with the measurements behind it.
The documentation gap this section named is closed by #2218. The separate-domain manual now
@@ -153,34 +159,30 @@ unpartitioned `HttpOnly` JWT to third-party delivery and contributes nothing to
the cookie jar shows directly.
The e2e suite covers the rendering half through `TestCrossOrigin_WidgetRendersOnAnotherOrigin`,
which
proves the document loads on another origin and reports itself through postMessage across the
which proves the document loads on another origin and reports itself through postMessage across the
boundary, and `ALLOWED_HOSTS` refusal through `TestCrossOrigin_DisallowedHostNeverReportsInited`.
#2214 added the authentication half over TLS, the reload included, and runs it once more
against a browser configured to block third-party cookies. That second case needs
`IgnoreDefaultArgs`, because Playwright's own `--disable-features` list switches partitioning off
and
beats the flags passed through `Args`, which would leave the case asserting nothing.
and beats the flags passed through `Args`, which would leave the case asserting nothing.
`TestHTTPS_CrossOriginSignInSurvivesAReload` and `TestHTTPS_SessionSurvivesThirdPartyCookieBlocking`
are table-driven over two flows, so anonymous and email are each measured in a third-party frame
with the reload, and each again under enforced partitioning, the blocking case giving every subtest
its own control cookie and its own partitioned-JWT guard in a fresh context so neither can pass
vacuously. `TestHTTPS_AuthCookiesCarryTheThirdPartyForm` is the third and reads the attributes out
of the browser store directly. Telegram is the one of the three still resting on inference. It is
exercised nowhere and
cannot be until #2208 makes the Telegram API base URL configurable, because without that the stack
cannot answer as Telegram; `go-pkgz/auth` #316 is the change that would let the suite measure it.
The
inference itself is that the client-side writer keys off `X-JWT` on any auth response and not off
the
provider, so nothing in it distinguishes one flow from another. The distinction is worth keeping
visible, because a criterion resting on flows the suite cannot reach is a milder version of the
defect this section was rewritten to remove.
exercised nowhere and cannot be until #2208 makes the Telegram API base URL configurable, because
without that the stack cannot answer as Telegram; `go-pkgz/auth` #316 is the change that would let
the suite measure it. The inference itself is that the client-side writer keys off `X-JWT` on any
auth response and not off the provider, so nothing in it distinguishes one flow from another. The
distinction is worth keeping visible, because a criterion resting on flows the suite cannot reach is
a milder version of the same defect in milder form: a requirement resting on what the suite cannot
reach.
`ALLOWED_HOSTS` sets the CSP `frame-ancestors` and `AUTH_SAME_SITE=none` lets the server's auth
cookies be set from any embedding domain. Those server-set cookies carry no `Partitioned`, so they
are the ones the browser drops; what survives the drop is the header fallback above.
are the ones Chromium, WebKit and Safari drop; Firefox keeps them, which is why it needs no
partitioned copy of its own. What survives the drop is the header fallback above.
There is a second mechanism aimed squarely at this, `AUTH_SEND_JWT_HEADER`, which returns the token
in a response header so the client can present it without relying on an ambient cookie. #1877 was
@@ -190,11 +192,10 @@ prefix that neither the backend nor the widget's own reader ever asks for, and m
`SameSite=Strict`, which is never sent from a third-party frame. Both are corrected here, and the
frontend now marks its cookies `SameSite=None; Secure; Partitioned` when it detects a third-party
context. The server-set cookies are untouched and still carry no `Partitioned`. That is what makes
the
upstream work matter, not what excuses it: `AUTH_SEND_JWT_HEADER` defaults to false, so in
the configuration remark42 actually ships nothing writes a partitioned cookie anywhere, and
partitioning the server's pair is what would carry email, anonymous and Telegram off-domain without
asking operators for a flag that costs them XSS exposure.
the upstream work matter, not what excuses it: `AUTH_SEND_JWT_HEADER` defaults to false, so in the
configuration remark42 actually ships nothing writes a partitioned cookie anywhere, and partitioning
the server's pair is what would carry email, anonymous and Telegram off-domain without asking
operators for a flag that costs them XSS exposure.
Constraints on any frontend design:
@@ -206,10 +207,10 @@ Constraints on any frontend design:
switched on, off and on again across releases, so pin the current state before relying on a
version number. A design depending on `SameSite=None` surviving has an expiry date
- the endpoint is CHIPS (`SameSite=None; Secure; Partitioned`) either way, and the only question is
who writes the cookie. Calling the header path a token that does not rely on ambient cookies was
wrong: it holds the token in memory for one page, and after a reload the browser sends the
partitioned cookie the frontend wrote, so both routes stand on the same attribute and differ only
in whether the server or the frontend sets it, plus the XSS cost the frontend writer carries.
who writes the cookie. The header path is not a token free of ambient cookies: it holds the token
in memory for one page, and after a reload the browser sends the partitioned cookie the frontend
wrote. Both routes stand on the same attribute and differ only in whether the server or the
frontend sets it, plus the token-theft exposure the frontend writer carries.
Neither is a flag flip, and the first cost falls upstream, not here:
`go-pkgz/auth/v2@v2.2.0` cannot emit `Partitioned` at all. Both cookies are hand-built in
`Service.Set` with only `HttpOnly`, `Path`, `Domain`, `MaxAge`, `Secure` and `SameSite`, and
@@ -334,11 +335,10 @@ the route is one that logs bodies. It is unreachable in any case, since the JWT
true`, set in `Service.Set`, and no JS can read it to build the URL.
So anonymous-first is what the current configuration gives, and the defensible Path B position is
that
making the document authenticated is a scoped piece of security work with its own cost. Until that
is costed, budget for anonymous-first: render anonymous, then hydrate the user state over XHR, which
does carry the header. Anonymous-first is also what a shared cache wants, though see the hydration
item in Path B for how much that is worth.
that making the document authenticated is a scoped piece of security work with its own cost. Until
that is costed, budget for anonymous-first: render anonymous, then hydrate the user state over XHR,
which does carry the header. Anonymous-first is also what a shared cache wants, though see the
hydration item in Path B for how much that is worth.
## Verified facts
@@ -363,10 +363,10 @@ Checked against the code at `7de51ad2` and by independent reviewers.
written, up from 7. Treat the exact figure as stale on sight; it is the only coverage that
survives a rewrite
- The unit suite is 48 files, 27 `*.test.*` plus 21 `*.spec.*`, and **426 cases**, as jest
enumerates them. Counting only `*.test.*` understates it by twenty files, which is the trap
enumerates them. Counting only `*.test.*` understates it by twenty-one files, which is the trap
- `en.json` is 180 keys with no ICU plural or select forms
- 136 non-test source files under `app/` excluding typings, mocks and stubs, 8,498 lines; 152 files
and 8,715 lines counting them
- 143 non-test source files under `app/` excluding typings, mocks and stubs; 158 counting them,
which is the figure the bundler section uses
- `profile.ts` (139 lines) is only the iframe host; the view is `profile.tsx` (235) reusing
`Comment` (638) in `view="user"` mode
- `last-comments` renders into the **host page**, not an iframe, and side-loads its own stylesheet
@@ -626,17 +626,15 @@ compiled, it is what verifies it.
**Cost**: months to an opt-in parallel UI reads as a floor derived from the optimistic architecture,
and the optimistic architecture does not hold. Anonymous-first is forced rather than chosen, so the
fragment layer reproduces the entire authenticated tree rather than a delta; add the three
subscription flows. R1 costs Path B less than an earlier version of this line claimed: it said a
server-rendered fragment has no client-side writer and therefore needs the upstream `Partitioned`
work, which contradicts Path B's own design, since the task list below retains the existing auth
through `htmx:configRequest`. `request` in `fetcher.ts` already turns `X-JWT` into the partitioned
pair, so Path B can keep that writer or attach the same handling to a response hook.
subscription flows. R1 costs Path B little: the task list below retains the existing auth through
`htmx:configRequest`, and `request` in `fetcher.ts` already turns `X-JWT` into the partitioned pair,
so Path B can keep that writer or attach the same handling to a response hook.
The upstream work is still the better answer for either path, and for a reason measurement supplied
rather than argument: with `PartitionedCookies` the server sets the pair itself and the `JWT` stays
`HttpOnly`, so the session survives third-party blocking without the token ever becoming readable
from script. The header route buys the same persistence by giving that readability away. So Path B
does not depend on the upstream change, and both paths are better with it.
The upstream `Partitioned` work is the better answer for either path. With `PartitionedCookies` the
server sets the pair itself and the `JWT` stays `HttpOnly`, so the session survives third-party
blocking without the token ever becoming readable from script, where the header route buys the same
persistence by giving that readability away. Neither path depends on the change and both are better
with it.
On one contributor the realistic figure is long enough that the plan's own warning applies to the
schedule and not only to the design. Deletes the entire npm toolchain. **Against it**: 2,400 lines
of the most stateful code get rewritten; a second HTML-fragment API surface becomes permanent