Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
125 changes: 113 additions & 12 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,16 @@ EEP event types follow a reverse-domain dot notation pattern:
| `com.example.agent.task.completed` | An A2A task completed successfully |
| `com.example.agent.task.failed` | An A2A task failed |

### Subscription lifecycle
| Event Type | Description |
|------------|-------------|
| `com.example.subscription.created` | A subscription was created and verified |
| `com.example.subscription.paused` | Delivery was suspended (see §10) |
| `com.example.subscription.resumed` | Delivery resumed after a pause |
| `com.example.subscription.expiring` | The lease is close to elapsing; renew to keep receiving (§10.2) |
| `com.example.subscription.expired` | The lease elapsed without renewal; terminal |
| `com.example.subscription.cancelled` | The subscription was cancelled; terminal (§10.1) |

### Commerce and marketplace
| Event Type | Description |
|------------|-------------|
Expand Down Expand Up @@ -681,23 +691,91 @@ POST /eep/subscribe
[Challenge Response from Subscriber]
Success ──────────────────► [active]
│ │
Failure event delivery
▼ │
[rejected] 5 consecutive failed deliveries
[paused]
POST /eep/subscriptions/{subscription_id}/resume
[active]
```
Success ──────────────────► [active] ◄──────────────┐
│ │ │ │ │
Failure │ │ │ re-subscribe (lease renewal)
▼ │ │ └───────────────┘
[rejected] │ │
│ └── lease elapses ──► [expired] (terminal)
5 consecutive failed deliveries
[paused]
POST /eep/subscriptions/{id}/resume
[active]

Any non-terminal state ── DELETE /eep/subscriptions/{id} ──► [cancelled] (terminal)
```

| State | Meaning | Terminal |
|---|---|---|
| `pending_verification` | Created; awaiting WebSub intent verification | no |
| `active` | Verified and receiving deliveries | no |
| `paused` | Delivery suspended after repeated failures, or by request | no |
| `rejected` | Intent verification failed or timed out | **yes** |
| `expired` | The lease elapsed without renewal | **yes** |
| `cancelled` | Cancelled by the subscriber or the publisher | **yes** |

A "failed delivery" is one that exhausted the full §5.4 retry schedule. The
counter is consecutive and resets on the next delivery that the subscriber
acknowledges with a 2xx. Endpoint paths are normative in §5.1.1.

Publishers MUST NOT deliver events for a subscription in `paused`, `rejected`,
`expired` or `cancelled`.

### 10.1 Cancellation (normative)

`DELETE /eep/subscriptions/{subscription_id}` cancels a subscription. On success
the publisher MUST:

1. Respond `204 No Content`. The operation is idempotent — a second `DELETE` of
an already-cancelled subscription MUST also return `204`, not `404`, so a
retrying client converges.
2. Stop delivering immediately. Deliveries already in flight MAY complete;
retries for them MUST NOT be scheduled.
3. Move the subscription to `cancelled`, a terminal state. A cancelled
`subscription_id` MUST NOT be reusable — a subscriber that wants delivery
again creates a new subscription.
4. Discard the `delivery_secret`.

Publishers MAY cancel a subscription unilaterally (for example when the
underlying entity is deleted, or an agreement gate is revoked). When they do,
they SHOULD emit `com.example.subscription.cancelled` on any other channel the
subscriber holds, and MUST include a `reason`.

Cancellation is how a subscriber exercises data-minimisation obligations over
the delivery relationship itself; `data_request`-style erasure of already
delivered payloads is covered separately in §16.

### 10.2 Lease lifetime and renewal (normative)

Intent verification carries `hub.lease_seconds` (see below). That value is a
contract, not decoration: **a subscription is time-bounded and expires unless
renewed.** Without expiry, an abandoned `delivery_url` receives traffic forever
and a publisher has no defined way to garbage-collect it.

- Publishers MUST treat `hub.lease_seconds` as the lifetime of the
subscription, starting from successful verification.
- The `201` creation response and every subscription representation (§5.1.1)
MUST carry `expires_at`, an RFC 3339 timestamp.
- A subscriber renews by re-subscribing with the same `source_did`,
`event_types` and `delivery_url`. The publisher MUST perform intent
verification again and, on success, extend the existing subscription rather
than creating a duplicate — the `subscription_id` is preserved.
- When the lease elapses without renewal, the publisher MUST move the
subscription to `expired` and stop delivering.
- Publishers SHOULD emit `com.example.subscription.expiring` to the subscriber
ahead of expiry — at least 24 hours before, or at 10% of the lease remaining,
whichever is sooner — carrying `subscription_id` and `expires_at`.

A subscriber MAY request a lease by sending `lease_seconds` in the subscription
request. Publishers MAY clamp it to their own policy and MUST report the value
actually granted in `expires_at`. Publishers that do not implement expiry MUST
NOT advertise a `hub.lease_seconds` they will not honour; they SHOULD omit the
parameter instead of sending a value that means nothing.

### WebSub intent verification

When creating a webhook subscription, the publisher MUST perform intent verification:
Expand All @@ -716,6 +794,29 @@ When creating a webhook subscription, the publisher MUST perform intent verifica

Intent verification prevents malicious actors from registering unauthorized URLs to bounce traffic through the publisher.

`hub.lease_seconds` is the lifetime the publisher is granting; see §10.2. A
publisher that sends it MUST enforce it.

### Unsubscribe verification

`DELETE /eep/subscriptions/{subscription_id}` (§10.1) is authenticated by the
caller's API key, so it needs no callback round-trip.

A publisher MAY additionally accept WebSub-style unsubscribe, where the request
is not authenticated as the subscription's owner. In that case the publisher
MUST verify intent exactly as it does for `subscribe`, with `hub.mode` set to
`unsubscribe`:

```
?hub.mode=unsubscribe
&hub.topic=did:web:example.com:u:acme-corp
&hub.challenge=random_secure_string_32_chars
```

The subscriber endpoint MUST echo `hub.challenge`. Without this round-trip an
unauthenticated caller could cancel another subscriber's delivery by guessing a
`subscription_id`, so publishers MUST NOT act on an unverified unsubscribe.

---

## 11. Authentication and authorization
Expand Down
132 changes: 125 additions & 7 deletions packages/@eep-dev/middleware/src/core/eep-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { parseGateConfig, type GateProof, type ProofVerifier } from "@eep-dev/gates";
import { EEPServer } from "./eep-server.js";
import { TEST_DELIVERY_EVENT_TYPE } from "./request-handler.js";
import {
TEST_DELIVERY_EVENT_TYPE,
DEFAULT_LEASE_SECONDS,
MIN_LEASE_SECONDS,
MAX_LEASE_SECONDS
} from "./request-handler.js";
import type { EventBusAdapter, DBAdapter, SubscriptionRecord, SubscriptionUpdate } from "./request-handler.js";

// Prevent real DNS resolution during subscribe validation tests.
Expand Down Expand Up @@ -595,6 +600,88 @@ describe("EEPServer", () => {
});
});

// SPECIFICATION.md §10.2 — a subscription is time-bounded. `hub.lease_seconds`
// was advertised during intent verification but never enforced, which made
// it decorative: an abandoned delivery_url received traffic forever.
describe("lease lifetime (§10.2)", () => {
const makeServer = () => {
const db = new RecordingDBAdapter();
const bus = new RecordingEventBusAdapter();
const server = new EEPServer({
baseUrl: "https://api.example.com",
did: "did:web:example.com",
dbAdapter: db,
eventBusAdapter: bus
});
return { db, bus, server };
};

const subscribe = async (server: EEPServer, extra: Record<string, unknown> = {}) =>
server.getSubscribeHandler()({
method: "POST",
path: "/eep/subscribe",
headers: {},
body: {
source_did: "did:web:agent.example",
delivery_method: "webhook",
delivery_url: "https://hook.example/notify",
event_types: ["com.example.entity.updated"],
...extra
}
});

it("grants the default 30-day lease when none is requested", async () => {
const { server } = makeServer();
const res = await subscribe(server);
const body = res.body as { expires_at: string; created_at: string };
const seconds = (Date.parse(body.expires_at) - Date.parse(body.created_at)) / 1000;
expect(seconds).toBe(DEFAULT_LEASE_SECONDS);
});

it("honours a requested lease within policy", async () => {
const { server } = makeServer();
const res = await subscribe(server, { lease_seconds: 3600 });
const body = res.body as { expires_at: string; created_at: string };
expect((Date.parse(body.expires_at) - Date.parse(body.created_at)) / 1000).toBe(3600);
});

it("clamps a lease below the minimum rather than rejecting the subscription", async () => {
const { server } = makeServer();
const res = await subscribe(server, { lease_seconds: 1 });
const body = res.body as { expires_at: string; created_at: string };
expect(res.status).toBe(201);
expect((Date.parse(body.expires_at) - Date.parse(body.created_at)) / 1000).toBe(MIN_LEASE_SECONDS);
});

it("clamps a lease above the maximum", async () => {
const { server } = makeServer();
const res = await subscribe(server, { lease_seconds: 99_999_999 });
const body = res.body as { expires_at: string; created_at: string };
expect((Date.parse(body.expires_at) - Date.parse(body.created_at)) / 1000).toBe(MAX_LEASE_SECONDS);
});

it("falls back to the default for a non-numeric lease", async () => {
const { server } = makeServer();
const res = await subscribe(server, { lease_seconds: "forever" });
const body = res.body as { expires_at: string; created_at: string };
expect(res.status).toBe(201);
expect((Date.parse(body.expires_at) - Date.parse(body.created_at)) / 1000).toBe(DEFAULT_LEASE_SECONDS);
});

it("reports expires_at on the subscription representation", async () => {
const { server } = makeServer();
const created = await subscribe(server);
const id = (created.body as { subscription_id: string }).subscription_id;
const status = await server.getSubscriptionStatusHandler()({
method: "GET",
path: `/eep/subscriptions/${id}`,
headers: {},
params: { subscriptionId: id }
});
expect(typeof (status.body as { expires_at?: string }).expires_at).toBe("string");
});
});

// SPECIFICATION.md §5.1.1 — list / resume / test-delivery member operations.
describe("subscription collection operations (§5.1.1)", () => {
const makeServer = () => {
Expand Down Expand Up @@ -813,35 +900,66 @@ describe("EEPServer", () => {
expect(res.status).toBe(400);
});

it("deletes the subscription and publishes subscription.deleted", async () => {
it("cancels the subscription and publishes subscription.cancelled", async () => {
const { server, db, bus } = makeServer();
const id = await createSubscription(server);
expect(db.rows.length).toBe(1);
bus.published.length = 0;

const res = await server.getUnsubscribeHandler()({
method: "DELETE",
path: `/eep/subscribe/${id}`,
path: `/eep/subscriptions/${id}`,
headers: {},
params: { subscriptionId: id }
});
expect(res.status).toBe(204);
expect(db.rows.length).toBe(0);
expect(bus.published).toEqual(["subscription.deleted"]);
expect(bus.published).toEqual(["subscription.cancelled"]);
expect(bus.events.at(-1)?.data).toMatchObject({
subscription_id: id,
reason: expect.any(String)
});
});

it("returns 404 when DELETE targets an unknown id and skips publish", async () => {
// §10.1: cancellation is idempotent. Returning 404 on the second DELETE
// would tell a retrying client its own successful cancellation failed.
it("returns 204 for an unknown id and publishes nothing", async () => {
const { server, bus } = makeServer();
const res = await server.getUnsubscribeHandler()({
method: "DELETE",
path: "/eep/subscribe/sub_missing",
path: "/eep/subscriptions/sub_missing",
headers: {},
params: { subscriptionId: "sub_missing" }
});
expect(res.status).toBe(404);
expect(res.status).toBe(204);
expect(bus.published).toEqual([]);
});

it("is idempotent across repeated cancellation", async () => {
const { server, db, bus } = makeServer();
const id = await createSubscription(server);
bus.published.length = 0;

const first = await server.getUnsubscribeHandler()({
method: "DELETE",
path: `/eep/subscriptions/${id}`,
headers: {},
params: { subscriptionId: id }
});
const second = await server.getUnsubscribeHandler()({
method: "DELETE",
path: `/eep/subscriptions/${id}`,
headers: {},
params: { subscriptionId: id }
});

expect(first.status).toBe(204);
expect(second.status).toBe(204);
expect(db.rows.length).toBe(0);
// The lifecycle event fires once, for the transition that happened.
expect(bus.published).toEqual(["subscription.cancelled"]);
});

it("returns 400 when DELETE has no subscription id param", async () => {
const { server } = makeServer();
const res = await server.getUnsubscribeHandler()({
Expand Down
Loading