Skip to content

feat(public): a published, read-only blocklist at /blocked - #131

Merged
dev-bobsong merged 4 commits into
mainfrom
feat/public-blocklist-page
Oct 8, 2026
Merged

dev-bobsong merged 4 commits into
mainfrom
feat/public-blocklist-page

Conversation

@dev-bobsong

Copy link
Copy Markdown
Contributor

A published, read-only view of the instance's blocklist at /blocked.

The problem it solves

A caller who gets a 403 from the proxy cannot tell "this instance refuses that
host 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:

  • the blocklist is the operator's one-vote veto on what this deployment will
    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.
  • a read-only page has none of that: nothing to abuse, and it still answers the
    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.reason is a free-text note the
operator 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, 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") 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|en sets it and redirects),
rows from the same blocked_hosts the console reads, one small query per request.
Language-varying, so private, must-revalidate + Vary rather than a shared
cache. 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".

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.

Discoverability (three placements, each for a reason)

Where Why there
Landing's public-key card, directly under the terms reminder beside the copy button where someone decides to start using the shared key — the same "remind where the decision is made" placement /terms already uses
Security section of /docs where a reader who just received a 403 is reading; the paragraph already explains the D1 blocklist
Footer of every public page the row that is already "the links the page owes a reader"

Deliberately 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 moderation
list is not a product destination.

Verification

npm run check, npm test (765 — 12 new), npm run check:contrast, npm run build
and wrangler deploy --dry-run all green.

test/blocked-page.test.ts covers: the leak boundary (the HTML must not contain
any fragment of reason
— resold, ticket 1234, counsel, abuse: 4k), both
locales, ?lang= setting the cookie and redirecting, noindex and the robots
entry 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_KEY and a matching public-tier row —
asserted explicitly so the test cannot pass vacuously) with the note ordered after
the terms line, and the RESERVED_LABELS coupling.

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.

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.
@dev-bobsong dev-bobsong added the enhancement New feature or request label Oct 8, 2026
@dev-bobsong
dev-bobsong merged commit e9eb922 into main Oct 8, 2026
1 check passed
@dev-bobsong
dev-bobsong deleted the feat/public-blocklist-page branch October 8, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant