add(websub): ship a self-hub for this site's two feeds - #137
Closed
jdevalk wants to merge 1 commit into
Closed
Conversation
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>
Deploying specification-website with
|
| 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 |
Owner
Author
|
Auto-closed by GitHub when the stacked base branch |
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.
Makes the site a worked example of
foundations/websubinstead 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.topicis 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
functions/websub.tsLinkrel="hub"/rel="self"andX-Hub-SignatureHMAC-SHA256 (§5.4), plus a bearer-gatedhub.mode=publish.migrations/0001_websub_subscriptions.sql.github/workflows/websub-ping.ymlscripts/test-websub.mjsnpm run test:websub— 35 assertions.src/pages/privacy.astrowrangler.tomlPublishing isn't part of WebSub. §4 explicitly leaves publisher→hub notification out of scope, so
hub.mode=publishfollows 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:
.internal/.local/.home.arpaare refused too. All 18 rejection cases are pinned in the test, including169.254.169.254,2130706433(decimal-encoded loopback), and[::1].hub.challenge. That's the protocol's own anti-amplification measure, andredirect: "error"on the probe means a redirected challenge doesn't count as confirmation.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 buildall 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.
Then paste the id into
wrangler.toml, uncomment the block, and:You'll also need
WEBSUB_PUBLISH_SECRETas both a Pages secret and a GitHub Actions repo secret. The full seven-step checklist is inwrangler.tomlso 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 arel=\"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
_headersblocks, the twoatom: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
addedentry 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