|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
@@ -33,23 +33,170 @@ users report against most. The target arrangement is remark42 serving from its o
|
|
|
|
|
documented in `site/content/docs/manuals/separate-domain/index.md`, so operators follow it and then
|
|
|
|
|
find that authentication behaves differently from the same-domain case.
|
|
|
|
|
|
|
|
|
|
**Acceptance criteria.** A reader on `food.com` can sign in with any configured provider, post,
|
|
|
|
|
reload the page, and still be signed in, on a browser that blocks unpartitioned third-party cookies.
|
|
|
|
|
Nothing short of the reload proves it: the widget holds a token in memory for the life of a page, so
|
|
|
|
|
a test that signs in and posts without reloading passes while the persistence is entirely broken.
|
|
|
|
|
**Acceptance criteria.** A reader on `food.com` signs in through email, Telegram or anonymous,
|
|
|
|
|
posts, reloads the page, and is still signed in, on a browser that blocks unpartitioned third-party
|
|
|
|
|
cookies. Nothing short of the reload proves it: the widget holds a token in memory for the life of a
|
|
|
|
|
page, so a test that signs in and posts without reloading passes while the persistence is entirely
|
|
|
|
|
broken.
|
|
|
|
|
|
|
|
|
|
**Where that stands.** 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 boundary, and `ALLOWED_HOSTS` refusal through
|
|
|
|
|
`TestCrossOrigin_DisallowedHostNeverReportsInited`. The authentication half is not covered and
|
|
|
|
|
cannot be until the e2e stack speaks https, because an embedded cookie needs `SameSite=None`, which
|
|
|
|
|
browsers accept only with `Secure`.
|
|
|
|
|
**Two things about this criterion are decisions, not findings, and they belong to the
|
|
|
|
|
maintainer.** They are marked so neither is settled by implication.
|
|
|
|
|
|
|
|
|
|
`ALLOWED_HOSTS` sets the CSP `frame-ancestors` and `AUTH_SAME_SITE=none` lets auth cookies be set
|
|
|
|
|
from any embedding domain. OAuth is what visibly fails off-domain (discussion #1139). Telegram,
|
|
|
|
|
email and anonymous work where third-party cookies are still permitted, and stop working where they
|
|
|
|
|
are not: the server's cookies carry no `Partitioned`, and the header fallback is off by default, so
|
|
|
|
|
nothing saves them once the browser blocks unpartitioned third-party cookies.
|
|
|
|
|
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 fails for
|
|
|
|
|
the flow as built on any browser that blocks or partitions third-party cookies, which is Safari's
|
|
|
|
|
default; under the recommended recipe, which drops `AUTH_SAME_SITE=none`, it fails on the remaining
|
|
|
|
|
browsers too. 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
|
|
|
|
|
assume it.
|
|
|
|
|
|
|
|
|
|
The second is what counts as evidence, and the criterion is not met end to end by any single run.
|
|
|
|
|
The criterion asks for sign-in, a post and a reload. The permanent suite covers sign-in, post and
|
|
|
|
|
reload for anonymous and email, but on Chromium's default policy and against a stack running
|
|
|
|
|
`AUTH_SAME_SITE=none` alongside the header, which is not the recommended recipe; the blocking case
|
|
|
|
|
adds the control and the reload but does not post. The cross-browser campaign covered sign-in,
|
|
|
|
|
reload and the controls on the recommended recipe, and did not post. So persistence is measured and
|
|
|
|
|
posting-after-reload is not, on the same run. Taking those together, 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
|
|
|
|
|
standing requirement is meant to be a test a proposal passes, so either inference is acceptable
|
|
|
|
|
evidence here and the section should say so, or Telegram leaves the criterion partly pending until
|
|
|
|
|
#2208 and `go-pkgz/auth` #316 make it measurable.
|
|
|
|
|
|
|
|
|
|
OAuth sits outside that criterion as the flow is built today. The provider callback is a top-level
|
|
|
|
|
navigation in a popup, so `Service.Set` writes its
|
|
|
|
|
cookie there as an ordinary first-party cookie for the auth host, carrying no `Partitioned` and so
|
|
|
|
|
having no partition key at all. The frame on `food.com` sees it only as an unpartitioned third-party
|
|
|
|
|
cookie, which is exactly what a blocking browser refuses. The cookie is `HttpOnly` besides, so the
|
|
|
|
|
popup cannot read it and hand it over in script. `SameSite` is not what obstructs this: the
|
|
|
|
|
separate-domain manual has operators set `AUTH_SAME_SITE=none`, and the callback navigation is
|
|
|
|
|
top-level regardless.
|
|
|
|
|
|
|
|
|
|
Two routes could bring OAuth inside the criterion, and both need costing before either is called
|
|
|
|
|
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, 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. 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()`
|
|
|
|
|
under `?selfClose` in `iframe.ejs`. Nothing happens there that a browser counts as interaction.
|
|
|
|
|
|
|
|
|
|
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 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
|
|
|
|
|
the frame redeems it over XHR. Two shapes exist there and they cost differently. If the redemption
|
|
|
|
|
answers with `Set-Cookie`, that cookie has to carry `Partitioned` to
|
|
|
|
|
be stored at all, which is the upstream library work described below. If it answers with `X-JWT`
|
|
|
|
|
instead, `fetcher.ts` writes the partitioned pair itself and nothing upstream has to change, which
|
|
|
|
|
makes it the cheaper of the two. Both handoff shapes are backend features and out of scope here; the
|
|
|
|
|
Storage Access
|
|
|
|
|
route is not, and is the one worth pricing first for that reason. Until something lands, an
|
|
|
|
|
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 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 survives.
|
|
|
|
|
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
|
|
|
|
|
in the frame: `activeJwtToken` in `fetcher.ts` is a module-level variable filled only from the
|
|
|
|
|
response header, nothing ever reads the `JWT` cookie back, and `getCookie` is called once in the
|
|
|
|
|
whole app for `XSRF-TOKEN`. So the first request after a reload sends no header and the token
|
|
|
|
|
arrives as an ambient cookie, third-party by definition inside the frame, and survives only because
|
|
|
|
|
it is partitioned. #2214's control cookie encodes exactly that: an unpartitioned `SameSite=None`
|
|
|
|
|
cookie written by the same script in the same frame has to be dropped, or the case declares itself
|
|
|
|
|
vacuous.
|
|
|
|
|
|
|
|
|
|
**Measured across three engines and four browser targets, and the mechanism is not uniform.** The
|
|
|
|
|
above was reasoned from the code and verified on Chromium. Driving the real thing on two genuinely
|
|
|
|
|
different registrable domains 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 does not reach the frame, and the widget's
|
|
|
|
|
partitioned pair is what the session runs on. The reason the server pair is absent differs by
|
|
|
|
|
engine: on Chromium `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 it is
|
|
|
|
|
third-party blocking, which applies whatever the attribute says. Only the outcome was measured; the
|
|
|
|
|
causes are read off documented browser behaviour.
|
|
|
|
|
|
|
|
|
|
The two pairs are not in competition. CHIPS makes the partition key part of a cookie's identity, so
|
|
|
|
|
a partitioned cookie and an unpartitioned one of the same name are different cookies and coexist:
|
|
|
|
|
#2218 measured all four at once on Chromium with both settings on. Firefox is the exception
|
|
|
|
|
precisely because Total Cookie Protection files the server's cookie under the embedder's partition,
|
|
|
|
|
which makes the keys match, and a script may not replace an `HttpOnly` cookie whose key it collides
|
|
|
|
|
with.
|
|
|
|
|
|
|
|
|
|
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 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 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 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
|
|
|
|
|
recommends `AUTH_SEND_JWT_HEADER=true` and carries the XSS trade-off in the same breath, and the
|
|
|
|
|
parameter page no longer promises `SameSite=Strict` cookies and a `__Host-` prefix that
|
|
|
|
|
`authCookieOptions` stopped emitting in a third-party context at #2197. That PR also measured
|
|
|
|
|
`AUTH_SAME_SITE=none` out of the recommended recipe: on Chromium, where that jar was read, the
|
|
|
|
|
header flag leaves it adding an unpartitioned `HttpOnly` JWT to third-party delivery and
|
|
|
|
|
contributing nothing to persistence. Safari drops that pair outright and Firefox delivers it either
|
|
|
|
|
way, so the setting is dead weight on all three for different reasons.
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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.
|
|
|
|
|
`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
|
|
|
|
|
asserts more than the evidence holds, which is the defect this section exists to avoid.
|
|
|
|
|
|
|
|
|
|
`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 Chromium, WebKit and Safari drop; Firefox keeps them, which is why it never holds a
|
|
|
|
|
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
|
|
|
|
@@ -58,8 +205,11 @@ first half. Its persistence never worked on https: the client wrote its copy und
|
|
|
|
|
prefix that neither the backend nor the widget's own reader ever asks for, and marked it
|
|
|
|
|
`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 still need the same treatment, and that is upstream work in
|
|
|
|
|
`go-pkgz/auth`.
|
|
|
|
|
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 exposes the token to script.
|
|
|
|
|
|
|
|
|
|
Constraints on any frontend design:
|
|
|
|
|
|
|
|
|
@@ -70,19 +220,28 @@ Constraints on any frontend design:
|
|
|
|
|
outright and honours only cookies explicitly marked `Partitioned`; its CHIPS support has been
|
|
|
|
|
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`) or a token not relying on ambient
|
|
|
|
|
cookies. Neither is a flag flip, and the first cost is upstream rather than here:
|
|
|
|
|
- the endpoint is CHIPS (`SameSite=None; Secure; Partitioned`) either way, and the only question is
|
|
|
|
|
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
|
|
|
|
|
`AUTH_SAME_SITE` in `cmd/server.go` offers `default`/`none`/`lax`/`strict` with no partitioned
|
|
|
|
|
axis, since `Partitioned` is a separate attribute. That is a PR to the library before anything in
|
|
|
|
|
this repo changes
|
|
|
|
|
axis, since `Partitioned` is a separate attribute. The first option therefore starts with a PR to
|
|
|
|
|
the library. The second needs nothing upstream and already works, which is why R1 leans on it, but
|
|
|
|
|
it is gated behind a flag that ships off and exposes the token to script, so it answers the
|
|
|
|
|
requirement
|
|
|
|
|
without being the answer an operator gets by default
|
|
|
|
|
- the popup-to-iframe handoff cannot be done in JS. The JWT cookie is `HttpOnly: true`, set in
|
|
|
|
|
`Service.Set`, so the top-level popup cannot read it to hand over, and the receiving end would not
|
|
|
|
|
work either: `setAuthCookie` in `cookies.ts` writes `SameSite: 'Strict'` under a `__Host-` prefix
|
|
|
|
|
with no `Partitioned`, and Strict is never sent from a third-party frame. The handoff has to be
|
|
|
|
|
server-mediated, a one-time code redeemed for a `Set-Cookie … Partitioned` issued from the
|
|
|
|
|
embedded context. Email and anonymous authenticate over XHR from inside the iframe, which keeps
|
|
|
|
|
`Service.Set`, so the top-level popup cannot read it to hand over. The receiving end is no longer
|
|
|
|
|
the obstacle it was: since #2197 `authCookieOptions` in `cookies.ts` returns
|
|
|
|
|
`SameSite=None; Secure; Partitioned` in a third-party https context and adds no `__Host-` prefix.
|
|
|
|
|
What remains missing is any route from the popup's storage bucket to the frame's, so the handoff
|
|
|
|
|
has to be server-mediated, a one-time code redeemed either for a `Set-Cookie … Partitioned` or,
|
|
|
|
|
more cheaply, for an `X-JWT` the frame's own writer turns into the partitioned pair. Email and
|
|
|
|
|
anonymous authenticate over XHR from inside the iframe, which keeps
|
|
|
|
|
them working while third-party cookies are permitted, but XHR does not create a partition by
|
|
|
|
|
itself, so they need `Partitioned` for the same reason OAuth does
|
|
|
|
|
- the failure mode is silent rather than an error, which is why #1139 reads as a hang. OAuth
|
|
|
|
@@ -171,11 +330,17 @@ exposes on `Opts` and threads into the JWT service. remark42 leaves it unset, so
|
|
|
|
|
list applies and the short circuit in `Service.Get` never fires for any method. Setting it would
|
|
|
|
|
make an authenticated document render possible.
|
|
|
|
|
|
|
|
|
|
Do not reach for it globally, though. `GET /deleteme` deletes every comment a user has written, and
|
|
|
|
|
is a GET deliberately, so that the link in the confirmation email works when clicked. Exempting GET
|
|
|
|
|
from XSRF wholesale removes that protection from a destructive endpoint. Anything built on this has
|
|
|
|
|
to scope the exemption to the document route alone, and that route has to be provably side-effect
|
|
|
|
|
free. Cost that work rather than assuming either that the door is shut or that it is open.
|
|
|
|
|
Do not reach for it globally, though, and note that the obvious example does not carry the argument.
|
|
|
|
|
`GET /deleteme` deletes every comment a user has written and is a GET deliberately, so that the
|
|
|
|
|
link in the confirmation email works when clicked, but XSRF is only one of three gates on it: the
|
|
|
|
|
route sits under `radmin`, which applies `Auth`, `AdminOnly` and `matchSiteID`, and
|
|
|
|
|
`deleteMeRequestCtrl` then requires a separately signed token carrying a `delete_me` attribute it
|
|
|
|
|
cannot forge. Exempting GET wholesale removes one defence there, not the only one.
|
|
|
|
|
|
|
|
|
|
The caution still stands, but it has to be earned by an audit instead of by that example: scope any
|
|
|
|
|
exemption to the document route, establish that route is side-effect free, and check every other
|
|
|
|
|
authenticated GET before deciding how much route-scoped work the library needs. Cost that audit,
|
|
|
|
|
without assuming either that the door is shut or that it is open.
|
|
|
|
|
|
|
|
|
|
There is also a query-parameter path, and it is worse than the constraint: `Service.Get` accepts the
|
|
|
|
|
token from a query parameter, `?jwt=` rather than the library default `?token=` because remark42
|
|
|
|
@@ -184,15 +349,15 @@ skipped entirely. That would put a live JWT into `Referer`, into history, and in
|
|
|
|
|
the route is one that logs bodies. It is unreachable in any case, since the JWT cookie is `HttpOnly:
|
|
|
|
|
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 honest 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.
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
## Verified facts
|
|
|
|
|
|
|
|
|
|
Checked against the code at `a82dc8d3` and by independent reviewers.
|
|
|
|
|
Checked against the code at `7de51ad2` and by independent reviewers.
|
|
|
|
|
|
|
|
|
|
- 9 runtime dependencies against 61 devDependencies, and **68** override entries, all in the single
|
|
|
|
|
`frontend/apps/remark42/package.json` since #2197 removed the workspace root. Before this effort
|
|
|
|
@@ -212,11 +377,11 @@ Checked against the code at `a82dc8d3` and by independent reviewers.
|
|
|
|
|
- The e2e suite is Go and playwright-go since #2180, and passed 60 tests while this was being
|
|
|
|
|
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 46 files, 25 `*.test.*` plus 21 `*.spec.*`, and **426 cases**, as jest
|
|
|
|
|
enumerates them. Counting only `*.test.*` understates it by twenty files, which is the trap
|
|
|
|
|
- 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-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
|
|
|
|
@@ -245,7 +410,7 @@ Checked against the code at `a82dc8d3` and by independent reviewers.
|
|
|
|
|
|
|
|
|
|
The concrete "what is left" list, and what any no-npm proposal has to answer for.
|
|
|
|
|
|
|
|
|
|
- transpiles TS and JSX for 136 to 152 source files, typed by `tsconfig.json`, compiled by the
|
|
|
|
|
- transpiles TS and JSX for 158 source files, typed by `tsconfig.json`, compiled by the
|
|
|
|
|
preset list in `.babelrc.js`
|
|
|
|
|
- CSS modules for 29 `*.module.css` files, 2,219 lines, including 177 nested `&`, 4 `composes` and
|
|
|
|
|
10 `:global` occurrences across 6 files, all handled by the CSS-modules rule in
|
|
|
|
@@ -322,21 +487,33 @@ widget *code* through npm breaks OAuth, but it adds a publish step and a version
|
|
|
|
|
- [ ] Document `__colors__` from `window.name` (`templates/iframe.ejs`), which works today and is
|
|
|
|
|
undocumented
|
|
|
|
|
- [ ] Fix the Astro and Gatsby manuals, which declare `REMARK42: any` and `remark_config: any`
|
|
|
|
|
- [ ] Correct the published locale list. `site/content/docs/configuration/frontend/_index.md` names
|
|
|
|
|
17 languages under `Locales`, and `app/locales/` ships 24 catalogs, so seven are documented
|
|
|
|
|
nowhere and a reader cannot discover them
|
|
|
|
|
|
|
|
|
|
### Task 2: Extend the e2e suite
|
|
|
|
|
|
|
|
|
|
**Done.** #2180 moved the suite to Go and playwright-go and took it from 7 tests to 22; #2196 took
|
|
|
|
|
it to 48. Every item this task originally listed is covered: vote and its failure path
|
|
|
|
|
(`vote_test.go`), edit inside and outside the deadline, delete and reply (`comment_test.go`), sort
|
|
|
|
|
change and collapse persistence (`thread_test.go`), anonymous and email auth (`auth_test.go`), the
|
|
|
|
|
profile iframe and last-comments (`widgets_test.go`).
|
|
|
|
|
it to 63, and master now carries 70 runnable top-level tests. Every item this task originally listed
|
|
|
|
|
is covered: vote and its failure path (`vote_test.go`), edit inside and outside the deadline, delete
|
|
|
|
|
and reply (`comment_test.go`), sort change and collapse persistence (`thread_test.go`), anonymous
|
|
|
|
|
and email auth (`auth_test.go`), the profile iframe and last-comments (`widgets_test.go`).
|
|
|
|
|
|
|
|
|
|
What remains uncovered is a different list, and it is the contract surface rather than the
|
|
|
|
|
behaviour: a cross-origin host page (every host page in the suite is served from the widget origin,
|
|
|
|
|
so R1 has no coverage at all), the `comments.html` fallback, `remark_config` fields (`url`,
|
|
|
|
|
`page_title`, hash deep links, `max_shown_comments`, the three `show_*_subscription` flags,
|
|
|
|
|
`__colors__`), the listener leak on a repeated `createInstance`, timezone-local date rendering, the
|
|
|
|
|
unknown-locale fallback, and the composer.
|
|
|
|
|
Most of what this task once listed as the remaining contract surface has since been covered too,
|
|
|
|
|
and the list is kept short here because an out-of-date backlog sends someone to write tests that
|
|
|
|
|
exist. Now covered: the cross-origin host page (`crossorigin_test.go` and `https_test.go`), the
|
|
|
|
|
`comments.html` fallback and its injection case
|
|
|
|
|
(`TestWidgets_CommentsPageOpensAThreadOnItsOwnOrigin`
|
|
|
|
|
and `TestWidgets_CommentsPageRefusesInjectedMarkup`), and the `remark_config` fields `url`,
|
|
|
|
|
`page_title`, `__colors__`, the subscription flags, timezone rendering and the unknown-locale
|
|
|
|
|
fallback, all as `TestConfig_*` cases in `config_test.go`. The composer is substantially covered by
|
|
|
|
|
`comment_test.go`, including drafts and a failed post. Repeated `createInstance` is covered at unit
|
|
|
|
|
level in `embed.test.ts`, though not end to end.
|
|
|
|
|
|
|
|
|
|
What is genuinely still uncovered: hash deep links, `max_shown_comments`, Telegram auth and its
|
|
|
|
|
subscription flag, and the storage-denied fallback trigger, which `e2e/README.md` explains needs
|
|
|
|
|
WebKit. Telegram is blocked on #2208 and `go-pkgz/auth` #316. #2219 adds one more: a deployment
|
|
|
|
|
under a path prefix, verifying chunks and assets for every entry including `deleteme`.
|
|
|
|
|
|
|
|
|
|
### Task 3: Stable class names and a documented override stylesheet
|
|
|
|
|
|
|
|
|
@@ -429,6 +606,10 @@ compiled, it is what verifies it.
|
|
|
|
|
(`URLKeyWithUser`, used by `findCommentsCtrl`), one entry per user with a separate `admin!!` key.
|
|
|
|
|
An HTML cache fragments the same way, so the shared-cache benefit only pays for logged-out readers
|
|
|
|
|
- [ ] Attach the existing auth via `htmx:configRequest` on fragment requests
|
|
|
|
|
- [ ] Preserve the explicit height report on panel close. `useDropdown` in `auth.hooks.ts` calls
|
|
|
|
|
`updateIframeHeight()` when the sign-in panel closes, added by #2213, because the panel is
|
|
|
|
|
absolutely positioned and neither ResizeObserver sees it disappear. A server-rendered replacement
|
|
|
|
|
loses the widget's height entirely if it drops that call
|
|
|
|
|
- [ ] Keep client-side: embed script, auth popups, composer, collapse and hidden-user state,
|
|
|
|
|
optimistic votes, and the **three subscription flows**. Email, Telegram and RSS
|
|
|
|
|
(`comment-form/__subscribe-by-email/`, `__subscribe-by-telegram/`, `__subscribe-by-rss/`) are each
|
|
|
|
@@ -460,7 +641,23 @@ 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 and, if R1 is to be honoured, the upstream `Partitioned` work in `go-pkgz/auth`.
|
|
|
|
|
subscription flows. R1 costs Path B little: the task list above 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 `Partitioned` work is the better answer for the flows that can use it. 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.
|
|
|
|
|
|
|
|
|
|
Two limits keep that from being a free win. The option is global, so `Service.Set` applies it to the
|
|
|
|
|
OAuth callback too, and a cookie partitioned to the popup's own top-level context is one the frame
|
|
|
|
|
cannot see: turning it on regresses OAuth on the permissive browsers where an unpartitioned
|
|
|
|
|
`SameSite=None` cookie works today, so it wants flow scoping or a separate OAuth answer. And the
|
|
|
|
|
security gain is narrower than "no XSS exposure": `HttpOnly` stops a script extracting and replaying
|
|
|
|
|
the bearer token, but the XSRF value stays readable by design, and script on the widget origin can
|
|
|
|
|
still make authenticated requests that the browser attaches the `HttpOnly` cookie to. What it
|
|
|
|
|
removes is token theft, not authenticated action during an XSS.
|
|
|
|
|
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
|
|
|
|
|