Skip to content
Merged
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
36 changes: 32 additions & 4 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,30 @@ Content-Type: application/json

The subscription sits in the `pending_verification` status until WebSub intent verification completes (see §10).

#### 5.1.1 Subscription management endpoints (normative)

`POST /eep/subscribe` creates a subscription and is the URL advertised as `layers.layer2_webhook` in the manifest (§12.3) and as `rel="subscribe"` in the `Link` header (§12.1). Every other operation on an existing subscription is addressed as a member of the `/eep/subscriptions` collection:

| Method | Path | Operation | Success | Scope |
|---|---|---|---|---|
| `POST` | `/eep/subscribe` | Create a subscription | `201` + subscription object | `write:subscriptions` |
| `GET` | `/eep/subscriptions` | List the caller's own subscriptions | `200` + `{ "subscriptions": [...] }` | `read:subscriptions` |
| `GET` | `/eep/subscriptions/{subscription_id}` | Read one subscription's status | `200` + subscription object | `read:subscriptions` |
| `DELETE` | `/eep/subscriptions/{subscription_id}` | Cancel a subscription | `204` | `write:subscriptions` |
| `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` |

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`.

Publishers MUST scope every operation to the authenticated caller and MUST return `404` — not `403` — for a `subscription_id` that exists but belongs to another subscriber, so the collection cannot be enumerated.

**Test deliveries.** `POST /eep/subscriptions/{subscription_id}/test` makes an `active` subscription's delivery path independently checkable: the publisher MUST send a normal, fully signed webhook to the registered `delivery_url` carrying an event of type `com.eep.subscription.test`, and MUST return `202 Accepted` once enqueued. The event MUST be signed and framed exactly like production traffic (§5.2, §5.3) — that is the entire point of the endpoint, and `@eep-dev/compliance-cli` relies on it to verify Standard Webhooks headers and HMAC correctness without waiting for organic traffic. Publishers MUST return `409 Conflict` when the subscription is not `active`, and MUST rate-limit this endpoint at least as tightly as subscription creation (§13).

This table makes normative the API that [How to subscribe](../guides/how-to-subscribe.md#managing-subscriptions) has documented since v0.1; previously the guide was the only place these operations were written down.

> **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.2 Webhook delivery format

The publisher MUST `POST` the following payload to the `delivery_url`:
Expand Down Expand Up @@ -648,7 +672,7 @@ EEP event types follow a reverse-domain dot notation pattern:
## 10. Subscription lifecycle

```
POST /subscribe
POST /eep/subscribe
[pending_verification]
Expand All @@ -661,15 +685,19 @@ POST /subscribe
│ │
Failure event delivery
▼ │
[rejected] 5 consecutive failures
[rejected] 5 consecutive failed deliveries
[paused]
POST /subscriptions/:id/resume
POST /eep/subscriptions/{subscription_id}/resume
[active]
```

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.

### WebSub intent verification

When creating a webhook subscription, the publisher MUST perform intent verification:
Expand Down Expand Up @@ -1028,7 +1056,7 @@ Suitable for: read-only publishers, IoT sensors, knowledge bases.
Superset of Core. Suitable for: B2B data APIs, financial feeds, subscription services.

- [x] All Core requirements above, plus:
- [x] Webhook subscription endpoint (`POST /eep/subscribe`) with full lifecycle (create/pause/resume/delete)
- [x] Webhook subscription endpoints per §5.1.1: create (`POST /eep/subscribe`), read, list, resume, test and cancel under `/eep/subscriptions`
- [x] WebSub intent verification before activating any webhook subscription
- [x] HMAC-SHA256 signature on all webhook deliveries (Standard Webhooks: `webhook-id`, `webhook-timestamp`, `webhook-signature` per §5)
- [x] Exponential backoff retry policy for failed webhook deliveries (min 5 attempts, max 24h window)
Expand Down
51 changes: 44 additions & 7 deletions packages/@eep-dev/compliance-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const RECOMMENDATIONS: Record<string, string> = {
'EEP discovery via Link header': 'Return a proper Link header with rel="subscribe" for entity/discovery endpoints.',
'Subscription creation': 'Implement POST /eep/subscribe with valid schema, auth, and returned subscription_id + delivery_secret.',
'WebSub Intent Verification': 'Perform challenge callback and echo hub.challenge exactly from subscriber endpoint.',
'Test delivery trigger (§5.1.1)': 'Implement POST /eep/subscriptions/{subscription_id}/test returning 202 and enqueueing a signed com.eep.subscription.test delivery to the registered delivery_url.',
'Webhook delivery received': 'Implement deterministic test event trigger and retry-safe outbound delivery.',
'Standard Webhooks headers present': 'Include webhook-id, webhook-timestamp, and webhook-signature on every webhook delivery.',
'HMAC-SHA256 signature is valid': 'Sign webhook payloads using Standard Webhooks v1 content format and timing-safe verification.',
Expand Down Expand Up @@ -391,20 +392,45 @@ async function runTests() {
receivedHeaders = null;
receivedRawBody = null;

// Trigger a synthetic delivery via SPECIFICATION.md §5.1.1.
//
// This MUST report its own outcome. `fetch` only rejects on a
// transport error, so a 404 (publisher does not implement the
// endpoint) used to resolve normally — the runner then waited 5s,
// received nothing, and blamed the *delivery* rather than the
// missing trigger. Implementers saw "Webhook delivery received:
// FAIL" and went hunting in their own dispatcher.
let triggered = false;
try {
await fetch(`${TARGET}/eep/subscriptions/${subscriptionId}/test`, {
const triggerRes = await fetch(`${TARGET}/eep/subscriptions/${subscriptionId}/test`, {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}` },
signal: AbortSignal.timeout(5000),
});
} catch {
fail('Test event delivery', 'failed to trigger test event');
if (triggerRes.status === 202 || triggerRes.ok) {
triggered = true;
logPass('Test delivery trigger (§5.1.1)', `HTTP ${triggerRes.status}`);
} else if (triggerRes.status === 404) {
logFail(
'Test delivery trigger (§5.1.1)',
`HTTP 404 — POST /eep/subscriptions/{id}/test is not implemented. ` +
`Standard Webhooks header and HMAC probes cannot run without it.`
);
} else {
logFail('Test delivery trigger (§5.1.1)', `HTTP ${triggerRes.status}`);
}
} catch (e) {
logFail('Test delivery trigger (§5.1.1)', `request failed: ${String(e)}`);
}

// Wait up to 5s for delivery
await new Promise(r => setTimeout(r, 5000));
// Only wait for a delivery we actually managed to trigger, and skip
// (rather than fail) the downstream signature probes otherwise — the
// publisher's signing is untested, not proven broken.
if (triggered) {
await new Promise(r => setTimeout(r, 5000));
}

if (receivedWebhook && receivedHeaders) {
if (triggered && receivedWebhook && receivedHeaders) {
pass('Webhook delivery received', `event type: ${(receivedWebhook as any).type}`);

// Verify Standard Webhooks headers
Expand Down Expand Up @@ -452,8 +478,19 @@ async function runTests() {
if (event.eep_version) pass('EEP extension attributes present', `eep_version: ${event.eep_version}`);
else fail('EEP extension attributes present', 'eep_version missing');

} else if (!triggered) {
// The trigger itself already failed and said so. Skip — rather
// than fail — everything downstream: the publisher's signing and
// envelope are untested here, not proven broken.
const reason = 'test delivery could not be triggered (see §5.1.1)';
skip('Webhook delivery received', reason);
skip('Standard Webhooks headers present', reason);
skip('HMAC-SHA256 signature is valid', reason);
skip('CloudEvents specversion is 1.0', reason);
skip('Event id field present', reason);
skip('Event source field present', reason);
} else {
fail('Webhook delivery received', 'no webhook received within 5s');
fail('Webhook delivery received', 'trigger accepted but no webhook arrived within 5s');
skip('Standard Webhooks headers present', 'no delivery');
skip('HMAC-SHA256 signature is valid', 'no delivery');
skip('CloudEvents specversion is 1.0', 'no delivery');
Expand Down
Loading
Loading