Repository navigation
feat(public): a published, read-only blocklist at /blocked - #131
Merged
Merged
Conversation
A caller who gets a 403 from the proxy cannot currently tell "this instance
refuses that host" from "the upstream refused it". Publishing the list answers
that, and it is the transparency story the rest of the site already makes.
**Deliberately read-only.** The full version of this idea also had an anonymous
"request a block" form, and that is the part that is dangerous: the blocklist is
the operator's one-vote veto on what this deployment will reach, so an
unauthenticated write path into it means anyone who can get a human to click
"approve" can deny the instance access to any domain — including the domains the
operator needs. It would also mean a new internet-facing endpoint to rate-limit,
queue-cap, de-duplicate and moderate. A read-only page has none of that: nothing
to abuse, and it still answers the question the caller actually has. The intake
form, if it is ever wanted, can be added later on top of this page.
**What is published, and what is not.** Hostname and date only. `reason` is a
free-text note the operator writes for themselves and it can carry a complainant,
a legal reference, or an internal judgement, so it stays in the console. If
curated transparency is wanted later the right shape is a separate `public_note`
field, not the internal reason.
**`noindex`**, absent from the sitemap, and `Disallow: /blocked` in robots.txt: it
is thin content, and for a proxy whose value is "can I reach X" the list would
otherwise double as a probe ("is X blocked? no → X still works"). A self-hoster
who wants it as a content page can flip one line.
**Server-rendered, one URL, no islands, no public JSON endpoint** — like
`/terms`: language from the cookie (`?lang=zh|en` sets it and redirects), rows
from the same `blocked_hosts` read the console uses, one small query per request.
Language-varying, so it is `private, must-revalidate` with `Vary` rather than a
shared cache. It **never 500s**: a D1 error renders an empty list, because a
public status page that breaks with the database is worse than one that says
"nothing recorded". Capped at 200 rows with a count of the rest, so a long
blocklist cannot grow into a huge page.
`blocked` joins the subdomain `RESERVED_LABELS` — the coupling #106 established,
without which `blocked.<zone>` would decode to the hostname `blocked.com` and be
proxied there. en/zh copy; 9 tests covering the leak boundary (no `reason` in the
HTML), both locales, `?lang=`, noindex + robots agreement, no shared caching, the
D1-failure path, the empty state, truncation, and the reserved label.
Refs the #124 discussion — this is the staging step: the page exists first, and
an intake flow can be layered on later once there is real demand for it.
…l look A page nobody can reach answers nobody's question, so `/blocked` needs two entry points — and the landing page is the wrong one. - **The footer of every public page.** That row is already "the links the page owes a reader" (terms, llms.txt, source, console), which is exactly what a published moderation list is. It appears on the landing, terms, docs, snippets and compare at zero cost to any of them. - **The security section of `/docs`**, in one sentence next to the paragraph that already explains the D1 blocklist. This is the highest-value placement: someone who just received a `403` from a proxy is reading exactly this text, and the ambiguity it resolves is "this instance refuses that host" vs "the upstream refused it". **Not the landing nav.** That nav is anchors plus a single off-page link to `/docs`, with a comment saying the hero stays free of it on purpose. A moderation list is not a product destination, and the footer already covers the landing — adding it there would dilute a funnel page to chase a page that is `noindex` anyway. Both places are asserted in tests (the footer link on two pages, the docs sentence and its link), and the copy is en/zh.
The feature commit recorded the page but not its entry points, which is the half a reviewer will ask about: discoverability is what makes a published list worth having, and 'noindex' makes it a purely human-discovery problem.
The card is where someone decides to start using the shared key, and the `/terms` reminder already sits there for the same reason: the reminder belongs where the decision is made, not behind a click-through. So the blocklist note goes directly under that line — "a host this instance blocks outright answers 403 whatever the quotas say, and the list is public" — rather than in the nav, which is anchors-plus-`/docs` by design. Placed under the terms reminder (not, as a first attempt had it, under the daily limits row): the decision point is the copy button, and the card's quota table is further down where nobody reads prose. Tests assert the card rendered at all (it needs `PUBLIC_KEY` *and* a matching public-tier row), that the link is present, and that the note follows the terms line rather than floating elsewhere on the page. en/zh copy.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A published, read-only view of the instance's blocklist at
/blocked.The problem it solves
A caller who gets a
403from the proxy cannot tell "this instance refuses thathost on purpose" from "the upstream refused it". The list answers that, and it is
the transparency story the rest of the site already makes (open source,
self-hostable, no lock-in).
Why it is read-only, and that is the whole design
The full version of this idea also had an anonymous "request a block" form. That
part is the dangerous one and it is deliberately left out:
reach. An unauthenticated write path into it means anyone who can get a human to
click "approve" can deny the instance access to any domain — including the ones
the operator needs. It would also mean a new internet-facing endpoint to
rate-limit, queue-cap, de-duplicate and moderate, plus a second table whose rows
would otherwise have to stay out of the enforcement read path and its 30 s memo.
question the caller actually has.
This is a staging step, not a retreat. The page exists first; an intake flow can be
layered on later (same route, a form plus a queue) once there is real demand for it
rather than an assumed need for it.
What is published, and what is not
Hostname and date only.
blocked_hosts.reasonis a free-text note theoperator writes for themselves and can carry a complainant, a legal reference, or
an internal judgement — it stays in the console. If curated transparency is wanted
later the right shape is a separate
public_note, not the internal reason.noindex, absent from the sitemap, andDisallow: /blockedin robots.txt. Itis thin content, and for a proxy whose value is "can I reach X" the list would
otherwise double as a probe ("is X blocked? no → X still works") and a disclosure
of who complained about whom. A self-hoster who wants it as a content page can
flip one line.
Shape
Server-rendered, one URL, no islands, no public JSON endpoint — exactly
/terms' shape: language from the cookie (?lang=zh|ensets it and redirects),rows from the same
blocked_hoststhe console reads, one small query per request.Language-varying, so
private, must-revalidate+Varyrather than a sharedcache. Capped at 200 rows with a count of the rest. It never 500s: a D1 error
renders an empty list, because a public status page that breaks with the database
is worse than one that says "nothing recorded".
blockedjoins the subdomainRESERVED_LABELS— the coupling #106 established,without which
blocked.<zone>would decode to the hostnameblocked.comand beproxied there.
Discoverability (three placements, each for a reason)
/termsalready uses/docs403is reading; the paragraph already explains the D1 blocklistDeliberately not the landing nav: that is anchors plus one off-page link to
/docs, with a comment that the hero stays free of it on purpose — a moderationlist is not a product destination.
Verification
npm run check,npm test(765 — 12 new),npm run check:contrast,npm run buildand
wrangler deploy --dry-runall green.test/blocked-page.test.tscovers: the leak boundary (the HTML must not containany fragment of
reason—resold,ticket 1234,counsel,abuse: 4k), bothlocales,
?lang=setting the cookie and redirecting,noindexand the robotsentry agreeing, no shared caching, the D1-failure path, the empty state, truncation
at 200 with the hidden count, the footer link on two pages, the docs sentence, the
landing card (which only renders with
PUBLIC_KEYand a matching public-tier row —asserted explicitly so the test cannot pass vacuously) with the note ordered after
the terms line, and the
RESERVED_LABELScoupling.en/zh copy throughout.
Before merging
The page is linked from every public footer and from the landing's public-key card,
so merging it means publishing this deployment's blocklist. Hostnames only, no
reasons — but the hostnames are still information. Worth a deliberate yes.