Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
816db1f
docs: plan WebSub hub support roadmap
andrewshell Jun 14, 2026
7b1e708
docs(websub): record async-202 ADR and WebSub vocabulary
andrewshell Jun 14, 2026
c4dd16c
feat(core): parse and validate WebSub hub.* subscribe requests
andrewshell Jun 14, 2026
9f0853f
feat(core): verify WebSub subscriber intent with a challenge GET
andrewshell Jun 14, 2026
264aa39
feat(core): add async-202 accept seam for WebSub subscriptions
andrewshell Jun 14, 2026
693e25e
feat: wire the WebSub subscribe front door (core dispatcher + express)
andrewshell Jun 14, 2026
f37332f
feat(server): mount the WebSub hub front door
andrewshell Jun 14, 2026
0f40e52
test(e2e): cover the WebSub subscribe handshake end-to-end
andrewshell Jun 14, 2026
47f1430
feat: distribute feed content to WebSub subscribers on fan-out
andrewshell Jun 14, 2026
880fd23
test(e2e): prove an rssCloud ping fans out to both protocols
andrewshell Jun 14, 2026
53f3db0
feat: sign WebSub deliveries with X-Hub-Signature
andrewshell Jun 14, 2026
33035aa
test(e2e): verify X-Hub-Signature over the delivered body
andrewshell Jun 14, 2026
31244ab
feat: intent-verify WebSub unsubscribe before removal
andrewshell Jun 14, 2026
03af1df
test(e2e): cover the WebSub unsubscribe handshake
andrewshell Jun 14, 2026
9b650c2
feat: honor WebSub lease requests, clamped to configured bounds
andrewshell Jun 14, 2026
949e478
test(e2e): cover WebSub lease clamping and expiry
andrewshell Jun 14, 2026
e15806f
feat: accept WebSub-native publish to trigger fan-out
andrewshell Jun 15, 2026
a329ad6
test(e2e): cover WebSub-native publish content distribution
andrewshell Jun 15, 2026
cd0acf6
docs: retire the WebSub TODO; mark fat pings out of scope
andrewshell Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ _Avoid_: cross-origin, external, remote.
**Hub**:
The server end of the protocol: it answers **pleaseNotify** and **Ping**, owns the
**Resource**/**Subscription** state, and fans **Notification**s out. `@rsscloud/core` is the
protocol-neutral hub engine; `apps/server` is one deployment of it.
protocol-neutral hub engine; `apps/server` is one deployment of it. The same engine also
plays the [WebSub](https://www.w3.org/TR/websub/) **Hub** role (the W3C term is literally
"hub") — see the **WebSub** terms below; it never hosts source feeds in either protocol.
_Avoid_: server (that's a deployment of the hub, not the role), broker.

**Client**:
Expand All @@ -98,8 +100,9 @@ _Avoid_: feed (that's the parsed metadata on a **Resource**), source, producer.

**Notification**:
The outbound delivery from the **Hub** to a **Subscriber**'s callback when a **Resource**
changes — an `http-post` `url=` form or an XML-RPC `rssCloud.notify` call. What a
**Protocol plugin** sends and the **Client** receives and acknowledges.
changes — an `http-post` `url=` form, an XML-RPC `rssCloud.notify` call, or (for WebSub) a
**Content distribution** POST carrying the body itself. What a **Protocol plugin** sends
and the **Client** receives and acknowledges.
_Avoid_: ping (that's the inbound publisher signal), pleaseNotify (the inbound subscribe),
message, event.

Expand All @@ -116,6 +119,83 @@ shared by the **Hub** and the **Client**. It speaks typed `XmlRpcValue`s and car
`rssCloud.*` semantics — each end maps its own method shapes onto it.
_Avoid_: parser (that's one half), serializer, XML library.

### WebSub

The **Hub** also speaks [WebSub](https://www.w3.org/TR/websub/), the W3C successor to
PubSubHubbub. Hub-only: `apps/client` still owns the subscriber/publisher side, and the
hub never hosts source feeds (publishers point at it via `<link rel="hub">` in their own
feeds). These terms name what's WebSub-specific; they reuse the core terms above wherever
the concept is the same.

**Topic**:
The feed URL a WebSub **Subscriber** names in `hub.topic` — the WebSub-wire name for the
same URL core stores change-detection state about as a **Resource**. A subscriber's
`hub.topic` must be the *exact* URL string the publisher **Ping**s, because the store keys
feed entries by exact URL (the same exactness rssCloud already requires between the
subscribe-URL and the ping-URL; URL normalization is out of scope).
_Avoid_: Resource (that's core's stored state for the URL; "Topic" is the WebSub-wire name
for the URL the subscriber names), feed.

**Callback**:
The complete URL a WebSub **Subscriber** supplies in `hub.callback` — where **Content
distribution** POSTs and the **Intent verification** GET are sent. It becomes the
**Subscription**'s `url` directly: unlike rssCloud (where **buildSubscribeRequest** glues
the callback from port/path/domain), WebSub arrives with a finished URL, so the dispatcher
sets `callbackUrl = hub.callback` and skips the builder.
_Avoid_: Subscription.url (that's the stored field the callback becomes), notify endpoint,
apiurl.

**Intent verification**:
The WebSub handshake confirming a **Subscriber** actually requested a (un)subscribe: the
**Hub** GETs the **Callback** with `hub.mode` / `hub.topic` / `hub.challenge` (plus the
chosen **Lease**) and requires an exact `hub.challenge` echo with a `2xx`. WebSub *always*
verifies (spec mandate), so its **Protocol plugin** ignores **diffDomain** and never does
the rssCloud same-domain test-notify. Verification is async: the Hub answers the inbound
request `202` first, then runs the GET out of band via the **VerificationScheduler**.
_Avoid_: challenge handshake (rssCloud's term — related, but WebSub always verifies, echoes
a challenge, and runs async), diffDomain (WebSub ignores it).

**VerificationScheduler**:
The core-owned seam that runs the verify-then-persist task behind the async `202`. The
default runs it in-process, best-effort (one attempt, failures logged, a restart drops the
pending request). A future persisted-queue + retry implementation satisfies the same seam
with no change to the dispatcher, the plugin's verify, or the express factory. WebSub-only
and additive — rssCloud subscribe stays synchronous. See ADR-0002.
_Avoid_: queue (the default isn't durable yet), job runner, worker.

**Lease**:
The bounded lifetime of a WebSub **Subscription**. The **Hub** honors the subscriber's
requested `hub.lease_seconds` clamped to a configurable `[min, max]` (a default applies
when omitted), stores the chosen value in `details.leaseSeconds`, sets
`whenExpires = now + chosen`, and echoes the chosen value in the **Intent verification**
GET. `removeExpired()` drops the subscription on lapse, unchanged.
_Avoid_: expiry (that's the resulting `whenExpires`; the Lease is the requested-then-clamped
duration), TTL.

**Content distribution**:
The WebSub form of **Notification**: the **Hub** POSTs the changed **Topic**'s body
*verbatim* to the **Callback**, relaying the origin `Content-Type` and adding
`Link: <hub>; rel="hub", <topic>; rel="self"`. Where an rssCloud **Notification** sends
only the changed URL, Content distribution sends the content itself — so one rssCloud
**Ping** can drive both, from the same already-fetched body.
_Avoid_: notify (rssCloud's content-free signal), push, broadcast.

**Fat ping** (out of scope — not implemented):
A publish in which the **Publisher** POSTs the changed body itself, so the **Hub**
distributes it verbatim *without* re-fetching the **Topic**. Non-standard (a PubSubHubbub
0.4 extension) with no WebSub wire format, so we **deliberately don't implement it**
(decided 2026-06-15): the hub only ever does thin publishes — it names a **Topic** and
re-fetches through `core.ping`, exactly as rssCloud's **Ping** already works. The term is
kept here only to explain why our publish is called "thin."
_Avoid_: using "publish" to mean Fat ping (our publish is always thin); push.

**X-Hub-Signature**:
The HMAC the **Hub** adds over a **Content distribution** body (`X-Hub-Signature: sha256=…`)
when the **Subscriber** supplied a `hub.secret`, letting the subscriber authenticate the
delivery. The algorithm is a config knob (default `sha256`); no `hub.secret` → no header.
_Avoid_: HMAC (that's the algorithm; the header is the wire artifact), auth token, signature
(ambiguous — name the header).

## Example dialogue

> **Dev:** When a `pleaseNotify` comes in over XML-RPC, who decides the callback is `diffDomain`?
Expand All @@ -127,3 +207,10 @@ _Avoid_: parser (that's one half), serializer, XML library.
> **Domain expert:** They share the **XML-RPC codec** (`@rsscloud/xml-rpc`), not each other's calls. The Client builds `rssCloud.pleaseNotify`/`rssCloud.ping`; the Hub parses those and sends a **Notification**. Each maps its own `rssCloud.*` shapes onto the codec's typed values.
> **Dev:** And how does a **Publisher** point a **Subscriber** at us?
> **Domain expert:** Via the **Cloud element** in the publisher's own feed — the Client's `renderCloudFeed` writes it. The Hub never hosts the feed; it just answers the **pleaseNotify** the subscriber sends after reading that `<cloud>`.

> **Dev:** A WebSub subscriber names a **Topic** and core stores a **Resource** — are those two different things?
> **Domain expert:** Same URL, different vantage point. **Topic** is the WebSub-wire name for the feed URL the subscriber asks about; **Resource** is core's stored change-detection state for that URL. They have to be the *exact* same string — the store keys by exact URL, just like rssCloud already requires the subscribe-URL to match the ping-URL.
> **Dev:** So when an rssCloud **Publisher** **Ping**s, does a WebSub subscriber on that Topic hear about it?
> **Domain expert:** Yes — that's the headline. One **Ping** fetches the body once and fans out per **Subscription**: the rssCloud sub gets a **Notification**, the WebSub sub gets a **Content distribution** POST of that same body. The publisher never speaks WebSub; it only added `<link rel="hub">` to its feed.
> **Dev:** And the subscriber's `202`?
> **Domain expert:** That's just "accepted". **Intent verification** runs async behind the **VerificationScheduler** — the Hub GETs the **Callback**, checks the `hub.challenge` echo, and only then records the **Subscription**. So a test polls `/subscriptions.json`; it doesn't assert the record exists the instant the `202` lands.
49 changes: 0 additions & 49 deletions TODO.md

This file was deleted.

13 changes: 12 additions & 1 deletion apps/e2e/test/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ const https = require('https'),
bodyParser = require('body-parser'),
textParser = bodyParser.text({ type: '*/xml' }),
urlencodedParser = bodyParser.urlencoded({ extended: false }),
// Content-capture: record the raw body of any POST the urlencoded parser
// skipped (e.g. a WebSub content distribution carrying the feed verbatim).
// body-parser bails out when an earlier parser already set `req._body`, so
// this only fires for non-urlencoded POSTs and leaves rssCloud notify
// bodies (parsed into objects) untouched.
rawBodyParser = bodyParser.text({ type: () => true }),
parseRpcRequest = require('./helpers/parse-rpc-request'),
querystring = require('querystring'),
MOCK_SERVER_DOMAIN = process.env.MOCK_SERVER_DOMAIN,
Expand Down Expand Up @@ -107,7 +113,12 @@ module.exports = {
before: async function() {
this.app.post('/RPC2', textParser, rpcController.bind(this));
this.app.get('*', restController.bind(this));
this.app.post('*', urlencodedParser, restController.bind(this));
this.app.post(
'*',
urlencodedParser,
rawBodyParser,
restController.bind(this)
);

this.server = await this.app.listen(MOCK_SERVER_PORT);
console.log(` → Mock server started on port: ${MOCK_SERVER_PORT}`);
Expand Down
Loading
Loading