feat(spec,middleware): specify unsubscribe and enforce the subscription lease - #99
Open
ucekmez wants to merge 1 commit into
Open
Conversation
…on lease Two halves of the same hole in §10. **Unsubscribe existed everywhere except the normative spec.** The middleware implemented `DELETE`, `ws-message.json` defined `unsubscribe` / `unsubscribed`, the IETF draft referenced it, and the conformance fixtures README claimed to hold vectors for it. SPECIFICATION.md contained the string zero times, and the §10 lifecycle diagram had no cancellation path and no terminal state other than `rejected`. A subscription protocol with no specified way to stop a subscription is the first thing a new implementer hits. **The WebSub lease was advertised and never honoured.** Intent verification sends `hub.lease_seconds=2592000`, but nothing defined expiry, renewal, or `hub.mode=unsubscribe`. EEP borrowed WebSub's challenge handshake without its lifecycle, so the value was decorative: subscriptions were immortal and an abandoned `delivery_url` received traffic forever with no defined way to garbage-collect it. Spec: - §10 lifecycle gains `expired` and `cancelled` as terminal states, a state table, and an explicit rule that non-`active` subscriptions are not delivered to. - §10.1 Cancellation: `204`, idempotent, terminal, id not reusable, `delivery_secret` discarded, in-flight deliveries not retried. Idempotency matters — returning 404 on a repeat DELETE tells a retrying client its own successful cancellation failed. - §10.2 Lease lifetime: `hub.lease_seconds` is binding, `expires_at` is reported on every representation, renewal is re-subscription with intent verification and preserves the `subscription_id`, and a publisher that will not enforce a lease MUST NOT advertise one. - Unsubscribe verification: an unauthenticated WebSub-style unsubscribe MUST complete a `hub.mode=unsubscribe` challenge, otherwise a caller could cancel someone else's delivery by guessing a `subscription_id`. - Six `subscription.*` lifecycle events added to the §9 catalog. Schema: `lease_seconds` on `subscription.request.json` (optional, 300..31536000), types regenerated. TypeScript and Python middleware: - Grant a lease at creation, clamping a requested value into policy rather than rejecting the subscription, and report `expires_at`. - `WebhookDispatcher` refuses to deliver once the lease has elapsed. The check is at delivery time rather than in a sweeper, so the rule holds in deployments with no background job — an unenforced lease is no lease. - Cancellation is idempotent and emits `subscription.cancelled` with a reason, replacing the non-catalogued `subscription.deleted`. Conformance fixtures for a lease request and a below-minimum lease. Refs: EEP audit 2026-08 findings A2, A3 Signed-off-by: Ugur Cekmez <ucekmez@gmail.com>
9 tasks
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.
Summary
PR 7 of a stacked series. Base is #98. Not for merge without review.
Two halves of the same hole in §10.
1.
unsubscribeexisted everywhere except the normative specDELETE /eep/subscribe/:id,operationId: "unsubscribe"ws-message.json—unsubscribe/unsubscribedsubscription/holds "subscribe/unsubscribe request and response shapes"rejectedA subscription protocol with no specified way to stop a subscription is the first thing a new implementer hits.
2. The WebSub lease was advertised and never honoured
Intent verification sends
hub.lease_seconds=2592000. Nothing defined expiry, renewal, orhub.mode=unsubscribe. EEP borrowed WebSub's challenge handshake without its lifecycle, so the value was decorative: subscriptions were immortal, and an abandoneddelivery_urlreceived traffic forever with no defined way to garbage-collect it.What changed
Spec
expiredandcancelledterminal states, a state table, and an explicit rule that non-activesubscriptions are not delivered to.204, idempotent, terminal, id not reusable,delivery_secretdiscarded, in-flight deliveries not retried. Idempotency is the load-bearing part: returning404on a repeatDELETEtells a retrying client its own successful cancellation failed.hub.lease_secondsis binding;expires_aton every representation; renewal is re-subscription with intent verification, preserving thesubscription_id; and a publisher that will not enforce a lease MUST NOT advertise one.hub.mode=unsubscribechallenge. Without it, a caller could cancel someone else's delivery by guessing asubscription_id.subscription.*lifecycle events added to the §9 catalog (they were being emitted but were not in the catalog).Schema —
lease_secondsonsubscription.request.json(optional, 300–31536000). Types regenerated.Both middleware packages
expires_at.WebhookDispatcherrefuses to deliver once the lease has elapsed — checked at delivery time rather than by a sweeper, so the rule holds in deployments with no background job. An unenforced lease is no lease.subscription.cancelledwith a reason, replacing the non-cataloguedsubscription.deleted.Fixtures — a lease request and a below-minimum lease.
Scope
Checklist
DELETEon an unknown id now returns204instead of404(idempotency, §10.1).subscription.cancelled, notsubscription.deleted— the old name was in neither the §9 catalog nor any schema.Subscriptions created after this change also expire after 30 days by default where previously they never did. That is the point of the change, but it is a behaviour change for existing deployments and belongs in release notes.
Verification
tests/@eep-dev/middlewareeep-middleware-python@eep-dev/compliance-clitests/cross-impl/test_conformance_fixtures.pycompliance-cli --fixturescodegen-schema-types --checkcheck-draft-schema-parityNotes for reviewers
Clamping rather than rejecting a requested
lease_secondsis a judgement call. The lease is the publisher's to grant, so a subscriber asking for something unreasonable should still end up with a working subscription and an honestexpires_at, rather than a400it has to interpret. The schema still bounds the field so the request is checkable — the negative fixture pins that.Renewal is specified but not implemented. §10.2 says re-subscribing with the same
source_did/event_types/delivery_urlextends the existing subscription and preserves the id; the middleware currently creates a new subscription. I did not want to bundle a de-duplication change into this PR, and the spec text is what implementors need first. Happy to follow up, or to soften §10.2 toSHOULDif you'd rather not have the gap.subscription.expiringisSHOULD, notMUST, and is not emitted by the middleware yet — it needs a scheduler, which this in-process dispatcher does not have.