Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
345eee8ba4 | ||
|
|
7ee867e7bf |
@@ -1,9 +1,9 @@
|
||||
# Frontend direction: two viable paths, and what has to be true for either
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -33,170 +33,23 @@ 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` 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.
|
||||
**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.
|
||||
|
||||
**Two things about this criterion are decisions, not findings, and they belong to the
|
||||
maintainer.** They are marked so neither is settled by implication.
|
||||
**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`.
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
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
|
||||
@@ -205,11 +58,8 @@ 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 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.
|
||||
context. The server-set cookies still need the same treatment, and that is upstream work in
|
||||
`go-pkgz/auth`.
|
||||
|
||||
Constraints on any frontend design:
|
||||
|
||||
@@ -220,28 +70,19 @@ 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`) 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:
|
||||
- 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:
|
||||
`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. 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
|
||||
axis, since `Partitioned` is a separate attribute. That is a PR to the library before anything in
|
||||
this repo changes
|
||||
- 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. 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
|
||||
`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
|
||||
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
|
||||
@@ -330,17 +171,11 @@ 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, 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -349,15 +184,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 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.
|
||||
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.
|
||||
|
||||
## Verified facts
|
||||
|
||||
Checked against the code at `7de51ad2` and by independent reviewers.
|
||||
Checked against the code at `a82dc8d3` 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
|
||||
@@ -377,11 +212,11 @@ Checked against the code at `7de51ad2` 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 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
|
||||
- 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
|
||||
- `en.json` is 180 keys with no ICU plural or select forms
|
||||
- 143 non-test source files under `app/` excluding typings, mocks and stubs; 158 counting them,
|
||||
which is the figure the bundler section uses
|
||||
- 136 non-test source files under `app/` excluding typings, mocks and stubs, 8,498 lines; 152 files
|
||||
and 8,715 lines counting them
|
||||
- `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
|
||||
@@ -410,7 +245,7 @@ Checked against the code at `7de51ad2` 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 158 source files, typed by `tsconfig.json`, compiled by the
|
||||
- transpiles TS and JSX for 136 to 152 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
|
||||
@@ -487,33 +322,21 @@ 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 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`).
|
||||
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`).
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
### Task 3: Stable class names and a documented override stylesheet
|
||||
|
||||
@@ -606,10 +429,6 @@ 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
|
||||
@@ -641,23 +460,7 @@ 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 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.
|
||||
subscription flows and, if R1 is to be honoured, the upstream `Partitioned` work in `go-pkgz/auth`.
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ Unless discussion [#1139](https://github.com/umputun/remark42/discussions/1139)
|
||||
|
||||
Set `ALLOWED_HOSTS="'self',https://example1.org,https://example2.org"` with your domain names, and `AUTH_SEND_JWT_HEADER=true`.
|
||||
|
||||
`AUTH_SEND_JWT_HEADER` is what keeps a reader signed in across a page reload on Safari, in Chrome Incognito, and in any browser configured to block third-party cookies. Read [its security considerations](../../configuration/parameters/#security-considerations-for-authsend-jwt-header) before enabling it: it puts the token in a cookie JavaScript can read, which costs XSS exposure that a server-set `HttpOnly` cookie does not.
|
||||
`AUTH_SEND_JWT_HEADER` is what keeps a reader signed in across a page reload on Safari, in Chrome Incognito, and in Chrome configured to block third-party cookies. The one setting it does not survive is Firefox's "block all third-party cookies", which discards partitioned cookies as well, and which no configuration survives. Read [its security considerations](../../configuration/parameters/#security-considerations-for-authsend-jwt-header) before enabling it: it puts the token in a cookie JavaScript can read, which costs XSS exposure that a server-set `HttpOnly` cookie does not.
|
||||
|
||||
**`AUTH_SAME_SITE=none` is not needed alongside it**, which reverses what this page recommended for years, so it is worth showing the measurement instead of asserting it. Signing in anonymously from an embedded widget over https and dumping the browser's cookie jar gives, with the setting:
|
||||
|
||||
@@ -46,9 +46,11 @@ Note that this applies to Email, Telegram and anonymous authorisation, which the
|
||||
### What each browser actually does
|
||||
|
||||
Measured on real domains over real certificates, with Remark42 on one registrable domain and the
|
||||
host page on another, signing in and then reloading. Every "blocked" column below was verified with
|
||||
a control cookie: an ordinary third-party cookie written from inside the widget frame has to be
|
||||
dropped, or the run is not blocking anything and proves nothing.
|
||||
host page on another, signing in and then reloading. Chrome and Firefox were driven through
|
||||
Playwright, Safari 27 through its own WebDriver, so the Safari column is Safari itself and not an
|
||||
approximation of it. Every "blocked" column below was verified with a control cookie: an ordinary
|
||||
third-party cookie written from inside the widget frame has to be dropped, or the run is not
|
||||
blocking anything and proves nothing.
|
||||
|
||||
| configuration | Chrome, default | Chrome, third-party cookies blocked | Firefox, default | Firefox, "block all third-party" | Safari |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -59,8 +61,9 @@ dropped, or the run is not blocking anything and proves nothing.
|
||||
Three things in that table are worth spelling out.
|
||||
|
||||
**Safari needs no configuring to break the old recipe.** It blocks third-party cookies out of the
|
||||
box, so `AUTH_SAME_SITE=none` on its own has already stopped working there for every reader. This
|
||||
is not a future deprecation to plan for.
|
||||
box while still honouring `Partitioned`, so `AUTH_SAME_SITE=none` on its own has already stopped
|
||||
working there for every reader. This is not a future deprecation to plan for. With the header flag
|
||||
the widget's own partitioned cookie is readable in the frame and the session survives the reload.
|
||||
|
||||
**Firefox reaches "works" by a different route, and a weaker one.** Chrome and Safari refuse the
|
||||
server's cookie when it carries no `SameSite` attribute, which leaves the field clear for the
|
||||
@@ -80,4 +83,4 @@ Here are all possible combinations of these two:
|
||||
- `ALLOWED_HOSTS` set to a set of domains: comments are shown only on listed domains, and authorisation wouldn't work anywhere, except on the same domain Remark42 is installed on and subdomains of it.
|
||||
- `AUTH_SAME_SITE` set to `None`: comments are shown on any domain. Authorisation works on browsers that still permit third-party cookies, and stops working on the ones that block them.
|
||||
- `ALLOWED_HOSTS` set to a set of domains and `AUTH_SAME_SITE` set to `None`: comments are shown on listed domains, with the same authorisation caveat.
|
||||
- `ALLOWED_HOSTS` and `AUTH_SEND_JWT_HEADER=true`, with `AUTH_SAME_SITE` left alone: comments are shown on listed domains, and Email, Telegram and anonymous authorisation survives a reload whatever the browser's third-party cookie policy. This is the recommended arrangement. Adding `AUTH_SAME_SITE=none` on top changes nothing about whether a reader stays signed in; it only adds the server's unpartitioned cookies where the browser still takes them.
|
||||
- `ALLOWED_HOSTS` and `AUTH_SEND_JWT_HEADER=true`, with `AUTH_SAME_SITE` left alone: comments are shown on listed domains, and Email, Telegram and anonymous authorisation survives a reload under every third-party cookie policy except Firefox's "block all third-party cookies", which no configuration survives. This is the recommended arrangement. Adding `AUTH_SAME_SITE=none` on top changes nothing about whether a reader stays signed in; it only adds the server's unpartitioned cookies where the browser still takes them.
|
||||
|
||||
Reference in New Issue
Block a user