Skip to content

add(websub): ship a self-hub for this site's two feeds - #137

Closed
jdevalk wants to merge 1 commit into
add/websub-2026-07-30from
add/websub-hub-2026-07-30
Closed

add(websub): ship a self-hub for this site's two feeds#137
jdevalk wants to merge 1 commit into
add/websub-2026-07-30from
add/websub-hub-2026-07-30

Conversation

@jdevalk

@jdevalk jdevalk commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Stacked on #136 — base branch is add/websub-2026-07-30, not main. Merge #136 first and GitHub will retarget this to main.

Makes the site a worked example of foundations/websub instead of a page describing something we don't run. Answers the open question in #136's body: we ship it, so "ship it before you spec it" stops being a judgement call.

Design: a self-hub, not an open one

hub.topic is allowlisted to our own two feeds. That single constraint is the whole reason this is reasonable to run on a static site — it means the endpoint can't be pointed at arbitrary URLs and can't relay anyone else's content. An open hub is a public service with a completely different risk profile.

What's here

File Role
functions/websub.ts The hub. Subscribe/unsubscribe (§5.1), verification of intent (§5.3), content distribution with Link rel="hub"/rel="self" and X-Hub-Signature HMAC-SHA256 (§5.4), plus a bearer-gated hub.mode=publish.
migrations/0001_websub_subscriptions.sql D1 subscription store, keyed on (topic, callback).
.github/workflows/websub-ping.yml Pings the hub after a content push.
scripts/test-websub.mjs npm run test:websub — 35 assertions.
src/pages/privacy.astro Discloses what a subscription stores.
wrangler.toml D1 binding (commented out) + the go-live checklist.

Publishing isn't part of WebSub. §4 explicitly leaves publisher→hub notification out of scope, so hub.mode=publish follows the de-facto PubSubHubbub 0.4 convention, gated behind a bearer token because only our own deploy may cause a fan-out.

No cron trigger. Leases expire lazily — the DELETE ... WHERE lease_expires_at <= now() runs at distribution time. That was the one piece that looked like it would need scheduling.

The ping waits 5 minutes for Pages to deploy. Distributing a stale feed is worse than distributing late: subscribers would receive the previous body and, having been notified, wouldn't re-poll.

Outbound-request safety

This is the first code in the repo that fetches URLs supplied by strangers, so, concretely:

  • Callback guard. https only, no credentials, no non-443 ports, and no IP literals at all — rejecting every literal kills the SSRF class in one rule rather than enumerating RFC 1918 / loopback / link-local / CGNAT and their IPv6 equivalents, and costs legitimate subscribers nothing. Single-label hostnames and .internal/.local/.home.arpa are refused too. All 18 rejection cases are pinned in the test, including 169.254.169.254, 2130706433 (decimal-encoded loopback), and [::1].
  • No delivery without the handshake. Nothing is ever POSTed to a callback that hasn't echoed hub.challenge. That's the protocol's own anti-amplification measure, and redirect: "error" on the probe means a redirected challenge doesn't count as confirmation.
  • Caps. 500 subscribers per topic, 30-day maximum lease, 10-second verify timeout, 15-second delivery timeout, fan-out in batches of 10.
  • No retry queue, deliberately. A subscriber that's down misses one notification; the next publish carries the full current feed anyway, and a retry queue would need durable state.
  • Constant-time compare on the publish token.

Verification

npm run test:websub — 35 assertions, all passing. Most of the value is the callback table; the HMAC is checked against the RFC 4231 test case 2 known vector, so the signature format is provably right rather than plausible.

Also: npm run lint, npm run format:check, npm run check (0 errors), npm run build all clean.

What I could not verify locally: the Astro dev server doesn't run functions/, and there's no D1 binding yet, so subscribe → verify → distribute is untested end to end. That needs the provisioning below, then step 7 of the checklist. I'd rather say so than imply I exercised it.

Two things only you can do

I can't provision Cloudflare resources, so the hub is inert as committed — the D1 binding is present but commented out, and the endpoint returns 503 without it. Safe to merge at any point.

npx wrangler d1 create specification-website-websub

Then paste the id into wrangler.toml, uncomment the block, and:

npx wrangler d1 migrations apply specification-website-websub --remote

You'll also need WEBSUB_PUBLISH_SECRET as both a Pages secret and a GitHub Actions repo secret. The full seven-step checklist is in wrangler.toml so it stays with the code.

Why the advertisement is held back

I initially had rel="hub" going live in this PR and backed it out. On merge-before-provisioning, we'd have advertised a hub whose POST returns 503 — which is precisely the failure the spec page names: "rel=\"hub\" is an advertisement, not a delivery mechanism... A feed with a rel=\"hub\" link and nothing behind it is a feed that still gets polled." Shipping that in the same PR as the page saying it would have been embarrassing.

So the _headers blocks, the two atom:link rel="hub" lines, and the api-catalog entry are all committed commented out, and go live in the same commit that provisions D1 (step 5). Step 6 adds the worked-example callout to the spec page — deliberately not in this PR, because until the hub is live the page would be claiming something untrue.

Changelog

No new entry. The added entry in #136 covers the page; I'd suggest extending its body to mention the hub once it's live rather than logging the plumbing separately — but that's a judgement call on a borderline case, so tell me which you'd prefer.

🤖 Generated with Claude Code

Makes the site a worked example of /spec/foundations/websub/ rather than a
page describing something we do not run.

A *self-hub*: hub.topic is allowlisted to /rss.xml and /changelog/rss.xml,
so the endpoint cannot fetch arbitrary URLs or relay anyone else's content.
That constraint is what makes a hub reasonable to run here — an open hub is
a public service with a very different risk profile.

- functions/websub.ts — subscribe/unsubscribe (WebSub 5.1), verification of
  intent (5.3), content distribution with Link rel=hub/self and an
  X-Hub-Signature HMAC-SHA256 (5.4), plus a bearer-gated hub.mode=publish
  (the PubSubHubbub 0.4 convention; WebSub 4 leaves this out of scope).
- migrations/0001 — D1 subscription store. Leases expire lazily at
  distribution time, so the hub needs no cron trigger.
- .github/workflows/websub-ping.yml — pings the hub after a content push,
  mirroring deploy-mcp.yml's trigger. Waits for the Pages deploy first;
  distributing a stale feed is worse than distributing late.
- scripts/test-websub.mjs (npm run test:websub) — 35 assertions over the
  callback guard, lease clamping, constant-time token compare, and the HMAC
  against the RFC 4231 known vector.
- privacy.astro — subscribing is the first thing on this site that stores
  data a visitor gives us. Says what is kept, for how long, and who sees it.

The D1 binding is committed COMMENTED OUT and the hub 503s without it, so
this is inert and safe to merge before the database exists. The rel="hub"
advertisement is held back to the same commit that provisions D1 — shipping
a hub link with nothing behind it is the exact failure the spec page warns
about. wrangler.toml carries the go-live checklist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying specification-website with  Cloudflare Pages  Cloudflare Pages

Latest commit: d5274b7
Status: ✅  Deploy successful!
Preview URL: https://2548fe0e.specification-website.pages.dev
Branch Preview URL: https://add-websub-hub-2026-07-30.specification-website.pages.dev

View logs

@jdevalk
jdevalk deleted the branch add/websub-2026-07-30 July 31, 2026 10:00
@jdevalk jdevalk closed this Jul 31, 2026
@jdevalk

jdevalk commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

Auto-closed by GitHub when the stacked base branch add/websub-2026-07-30 was deleted on merging #136 — not an intentional close, and GitHub refuses both a base change and a reopen once the base is gone. Continued as #141: same branch, same commits, retargeted to main with main merged in and the package.json conflict against #139's script removals resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant