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
72 changes: 71 additions & 1 deletion docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ The subscription sits in the `pending_verification` status until WebSub intent v
| `POST` | `/eep/subscriptions/{subscription_id}/pause` | Pause an `active` subscription | `200` + subscription object | `write:subscriptions` |
| `POST` | `/eep/subscriptions/{subscription_id}/resume` | Resume a `paused` subscription | `200` + subscription object | `write:subscriptions` |
| `POST` | `/eep/subscriptions/{subscription_id}/test` | Trigger a synthetic test delivery | `202` | `write:subscriptions` |
| `POST` | `/eep/subscriptions/{subscription_id}/redeliver` | Re-send specific events (§5.1.2) | `202` | `write:subscriptions` |
| `GET` | `/eep/subscriptions/{subscription_id}/delivery-log` | Per-attempt delivery history (§5.1.2) | `200` | `read:subscriptions` |

Publishers MUST NOT return the `delivery_secret` on any of these responses; it is disclosed exactly once, in the `201` body of the creating `POST /eep/subscribe`.

Expand All @@ -397,6 +399,74 @@ This table makes normative the API that [How to subscribe](../guides/how-to-subs

> **Compatibility note (v0.1).** Reference middleware released before this section served the member operations under `/eep/subscribe/{subscription_id}`. Implementations SHOULD continue to accept those paths as deprecated aliases through the `0.1.x` line and SHOULD advertise only the `/eep/subscriptions` forms.

#### 5.1.2 Event history and redelivery (normative)

SSE subscribers recover missed events with `Last-Event-ID` (§4.3, minimum 24h
retention). Layer 3 clients recover with `system`/`replay` from a `seq`
(§6.3.1). **Webhook subscribers had no equivalent at all.**

That gap is load-bearing. §10 moves a subscription to `paused` after repeated
delivery failures — precisely when the subscriber's endpoint was down and it
missed the most. Without catch-up, resuming produces a silent hole: the
subscription works again and the events from the outage are simply gone.

Publishers MUST expose event history:

```http
GET /eep/events?source={did}&since={event_id}&limit={n}
Authorization: Bearer {API_KEY}
```

| Parameter | Required | Meaning |
|---|---|---|
| `source` | no | Restrict to one entity; defaults to everything the caller may read |
| `since` | no | Return events strictly **after** this event `id` |
| `until` | no | Return events at or before this event `id` |
| `limit` | no | Page size; publishers MUST cap it and MUST document the cap |

- The retention floor is the same as SSE replay: **at least 24 hours** (§4.3).
- Events MUST be returned in emission order for a given `source`.
- The response MUST include a `next_cursor` when more events remain, and MUST
omit it when they do not. A subscriber pages until `next_cursor` is absent.
- `since` naming an event outside the retention window MUST produce `410 Gone`
with the oldest retained id, so the subscriber reconciles from Layer 1 state
rather than silently believing it caught up. Publishers MUST NOT fabricate
history, and MUST NOT return an empty page for an unsatisfiable cursor —
that is indistinguishable from "you are up to date".
- Access is scoped to the caller. History MUST NOT reveal events for entities
or tiers the caller could not have subscribed to, and gate evaluation
(§3.4) applies to each event as it would have at delivery time.

This is the endpoint metered by the "Event stream history queries" quota in
§13, which previously referred to no defined endpoint.

**Redelivery.** Publishers SHOULD accept a targeted redelivery request:

```http
POST /eep/subscriptions/{subscription_id}/redeliver
{ "event_ids": ["01HN3QK7GX-1708123456000"] }
```

Redelivered events MUST carry their original `id`, so a subscriber that already
processed one discards it by the ordinary idempotency rule
([delivery_guarantees.md](./delivery_guarantees.md) §6) rather than
double-processing. They MUST be re-signed with a current
`webhook-timestamp` (§5.3), and MUST NOT reset the subscription's failure
counter. Publishers MUST return `202 Accepted` on enqueue and MUST cap the
number of ids per request.

**Delivery log.** Publishers MUST expose per-attempt delivery history for a
subscription:

```http
GET /eep/subscriptions/{subscription_id}/delivery-log
```

The fields and the 30-day retention floor are specified in
[delivery_guarantees.md](./delivery_guarantees.md) §4. This endpoint is how a
subscriber distinguishes "the publisher never sent it" from "my endpoint
rejected it", which is otherwise unanswerable from the subscriber's side.

### 5.2 Webhook delivery format

The publisher MUST `POST` the following payload to the `delivery_url`:
Expand Down Expand Up @@ -1217,7 +1287,7 @@ Recommended default limits per subscriber:
| Subscription creation | 100/day |
| SSE connections | 5 concurrent |
| Webhook deliveries received | 10,000/day |
| Event stream history queries | 60/hour |
| Event stream history queries (`GET /eep/events`, §5.1.2) | 60/hour |

### 13.1 Cold-start DID trust progression (normative)

Expand Down
193 changes: 192 additions & 1 deletion packages/@eep-dev/middleware/src/core/eep-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { parseGateConfig, type GateProof, type ProofVerifier } from "@eep-dev/gates";
import { EEPServer } from "./eep-server.js";
import { InMemoryEventStore } from "./event-store.js";
import {
TEST_DELIVERY_EVENT_TYPE,
DEFAULT_LEASE_SECONDS,
Expand Down Expand Up @@ -303,7 +304,7 @@ describe("EEPServer", () => {
did: "did:web:example.com"
});
const routes = server.getRouteDefinitions();
expect(routes.length).toBe(18);
expect(routes.length).toBe(21);
const operationIds = routes.map((route) => route.operationId);
expect(operationIds).toContain("subscribe");
expect(operationIds).toContain("subscriptionStatus");
Expand All @@ -312,6 +313,9 @@ describe("EEPServer", () => {
expect(operationIds).toContain("resumeSubscription");
expect(operationIds).toContain("pauseSubscription");
expect(operationIds).toContain("testSubscriptionDelivery");
expect(operationIds).toContain("eventHistory");
expect(operationIds).toContain("redeliver");
expect(operationIds).toContain("deliveryLog");
});

// SPECIFICATION.md §5.1.1: creation stays on `POST /eep/subscribe` (it is
Expand Down Expand Up @@ -600,6 +604,193 @@ describe("EEPServer", () => {
});
});

// SPECIFICATION.md §5.1.2 — webhooks had no catch-up mechanism at all,
// which bites hardest right after §10 pauses a subscription: the endpoint
// was down, it missed the most, and resuming produced a silent hole.
describe("event history and redelivery (§5.1.2)", () => {
const makeServer = () => {
const db = new RecordingDBAdapter();
const bus = new RecordingEventBusAdapter();
const store = new InMemoryEventStore();
const server = new EEPServer({
baseUrl: "https://api.example.com",
did: "did:web:example.com",
dbAdapter: db,
eventBusAdapter: bus,
eventStore: store
});
return { db, bus, store, server };
};

const evt = (id: string) => ({
id,
type: "com.example.entity.updated",
source: "did:web:acme.example",
time: "2026-01-01T00:00:00.000Z",
data: {}
});

const createSubscription = async (server: EEPServer): Promise<string> => {
const res = await 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"]
}
});
return (res.body as { subscription_id: string }).subscription_id;
};

it("returns history for a subscriber catching up", async () => {
const { server } = makeServer();
for (const id of ["e1", "e2", "e3"]) await server.recordEvent(evt(id));

const res = await server.getEventHistoryHandler()({
method: "GET",
path: "/eep/events",
headers: {},
query: { since: "e1" }
});
expect(res.status).toBe(200);
const body = res.body as { events: Array<{ id: string }>; next_cursor?: string };
expect(body.events.map((e) => e.id)).toEqual(["e2", "e3"]);
expect(body.next_cursor).toBeUndefined();
});

// 410, not an empty 200: an empty page is indistinguishable from "you are
// up to date", which would let a subscriber believe it caught up.
it("returns 410 with the oldest retained id for an unsatisfiable cursor", async () => {
const { server } = makeServer();
await server.recordEvent(evt("e1"));
const res = await server.getEventHistoryHandler()({
method: "GET",
path: "/eep/events",
headers: {},
query: { since: "evicted" }
});
expect(res.status).toBe(410);
expect(res.body).toMatchObject({
error: "retention_window_exceeded",
oldest_retained_event_id: "e1"
});
});

it("pages with next_cursor", async () => {
const { server } = makeServer();
for (const id of ["e1", "e2", "e3"]) await server.recordEvent(evt(id));
const res = await server.getEventHistoryHandler()({
method: "GET",
path: "/eep/events",
headers: {},
query: { limit: "2" }
});
const body = res.body as { events: Array<{ id: string }>; next_cursor?: string };
expect(body.events).toHaveLength(2);
expect(body.next_cursor).toBe("e2");
});

it("re-publishes requested events and names the ones it cannot", async () => {
const { server, bus } = makeServer();
await server.recordEvent(evt("e1"));
const id = await createSubscription(server);
bus.published.length = 0;

const res = await server.getRedeliverHandler()({
method: "POST",
path: `/eep/subscriptions/${id}/redeliver`,
headers: {},
params: { subscriptionId: id },
body: { event_ids: ["e1", "gone"] }
});

expect(res.status).toBe(202);
expect(res.body).toMatchObject({ redelivered: ["e1"], unavailable: ["gone"] });
// Redelivered events keep their original id so an already-processed
// event is discarded by the ordinary idempotency rule.
expect(bus.events.at(-1)).toMatchObject({ type: "com.example.entity.updated" });
});

it("rejects a redeliver request with no event ids", async () => {
const { server } = makeServer();
const id = await createSubscription(server);
const res = await server.getRedeliverHandler()({
method: "POST",
path: `/eep/subscriptions/${id}/redeliver`,
headers: {},
params: { subscriptionId: id },
body: { event_ids: [] }
});
expect(res.status).toBe(400);
});

it("rejects a redeliver request above the per-request cap", async () => {
const { server } = makeServer();
const id = await createSubscription(server);
const res = await server.getRedeliverHandler()({
method: "POST",
path: `/eep/subscriptions/${id}/redeliver`,
headers: {},
params: { subscriptionId: id },
body: { event_ids: Array.from({ length: 101 }, (_, i) => `e${i}`) }
});
expect(res.status).toBe(400);
});

it("returns 404 when redelivering for an unknown subscription", async () => {
const { server } = makeServer();
const res = await server.getRedeliverHandler()({
method: "POST",
path: "/eep/subscriptions/nope/redeliver",
headers: {},
params: { subscriptionId: "nope" },
body: { event_ids: ["e1"] }
});
expect(res.status).toBe(404);
});

it("exposes the delivery log for a subscription", async () => {
const { server, store } = makeServer();
const id = await createSubscription(server);
await store.recordDelivery({
subscription_id: id,
event_id: "e1",
attempt: 1,
timestamp: new Date().toISOString(),
status_code: 500,
response_time_ms: 42,
final_status: "failed"
});

const res = await server.getDeliveryLogHandler()({
method: "GET",
path: `/eep/subscriptions/${id}/delivery-log`,
headers: {},
params: { subscriptionId: id }
});
expect(res.status).toBe(200);
const body = res.body as { attempts: Array<{ final_status: string }> };
expect(body.attempts).toHaveLength(1);
// This is what lets a subscriber tell "never sent" from "my endpoint
// rejected it".
expect(body.attempts[0]?.final_status).toBe("failed");
});

it("returns 404 for the delivery log of an unknown subscription", async () => {
const { server } = makeServer();
const res = await server.getDeliveryLogHandler()({
method: "GET",
path: "/eep/subscriptions/nope/delivery-log",
headers: {},
params: { subscriptionId: "nope" }
});
expect(res.status).toBe(404);
});
});

// SPECIFICATION.md §3.2.1 — Layer 1 is the polled surface, and nothing
// emitted ETag or honoured If-None-Match, so every poll re-downloaded the
// whole document.
Expand Down
Loading