From 202e140020973c4416cb25f385331f2c2f457b91 Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 26 Aug 2026 21:37:05 +0300 Subject: [PATCH] fix(spec,middleware,compliance-cli): define one canonical subscription resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscription resource was addressed five different ways across the repository, and two of those paths were load-bearing for conformance: spec §5.1 POST /eep/subscribe spec §10 POST /subscribe spec §10 POST /subscriptions/:id/resume middleware GET|DELETE /eep/subscribe/:id compliance-cli GET /eep/subscriptions compliance-cli POST /eep/subscriptions/:id/test `POST /eep/subscriptions/:id/test` is how the Core-tier probe triggers a delivery in order to verify Standard Webhooks headers and the HMAC signature. It was specified nowhere and implemented nowhere — not in the middleware, not in either reference implementation. Worse, it failed silently: `fetch` rejects only on a transport error, so a 404 resolved normally, the runner slept 5s, received nothing, and reported "Webhook delivery received: FAIL". Implementers saw a delivery failure and went hunting in their own dispatcher for a bug that was never theirs. `GET /eep/subscriptions` (the Standard-tier rate-limit probe) had the same shape: asserted against a 404. The paths chosen here are not new. `docs/guides/how-to-subscribe.md` has documented `/eep/subscriptions/{id}` with `pause`, `resume`, `test` and `DELETE` since v0.1, and `delivery_guarantees.md` references the same collection. The guide was right; the spec and the middleware were the outliers. Creation stays on `POST /eep/subscribe` because that is what the manifest advertises as `layers.layer2_webhook` and what the `rel="subscribe"` Link header points at. Changes: - Spec: new §5.1.1 making the subscription resource normative — the member operations, their status codes and scopes, the rule that `delivery_secret` is never re-exposed, 404-not-403 for another subscriber's id, and the semantics of a test delivery. - Spec: §10 lifecycle now uses the canonical paths and defines what a "failed delivery" is (a fully exhausted §5.4 retry schedule); §14.2's conformance line points at §5.1.1 instead of claiming an unwritten lifecycle. - Middleware: serve list / pause / resume / test alongside the existing status and unsubscribe handlers, all under `/eep/subscriptions`, with the pre-§5.1.1 `/eep/subscribe/:id` paths kept as deprecated aliases. - Middleware: `WebhookDispatcher` routes `com.eep.subscription.test` to the single subscription in `data.subscription_id` rather than fanning out by `event_types` — a test delivery must reach a subscriber whose patterns would never match it, and must reach nobody else. - compliance-cli: the trigger now reports its own outcome, naming the missing endpoint on a 404, and downstream signature probes SKIP rather than FAIL when no delivery could be triggered. Refs: EEP audit 2026-08 findings A4, A9 Signed-off-by: Ugur Cekmez --- docs/current/SPECIFICATION.md | 36 ++- packages/@eep-dev/compliance-cli/src/index.ts | 51 +++- .../middleware/src/core/eep-server.test.ts | 221 +++++++++++++++++- .../middleware/src/core/eep-server.ts | 161 ++++++++++++- .../middleware/src/core/request-handler.ts | 8 + .../src/dispatcher/webhook-dispatcher.test.ts | 81 +++++++ .../src/dispatcher/webhook-dispatcher.ts | 20 +- packages/@eep-dev/middleware/src/index.ts | 1 + 8 files changed, 560 insertions(+), 19 deletions(-) diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index fe6a7b5..6ee5f2f 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -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`: @@ -648,7 +672,7 @@ EEP event types follow a reverse-domain dot notation pattern: ## 10. Subscription lifecycle ``` -POST /subscribe +POST /eep/subscribe │ ▼ [pending_verification] @@ -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: @@ -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) diff --git a/packages/@eep-dev/compliance-cli/src/index.ts b/packages/@eep-dev/compliance-cli/src/index.ts index 9ad0249..9d4888b 100644 --- a/packages/@eep-dev/compliance-cli/src/index.ts +++ b/packages/@eep-dev/compliance-cli/src/index.ts @@ -111,6 +111,7 @@ const RECOMMENDATIONS: Record = { '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.', @@ -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 @@ -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'); diff --git a/packages/@eep-dev/middleware/src/core/eep-server.test.ts b/packages/@eep-dev/middleware/src/core/eep-server.test.ts index 722fe3c..0e17014 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.test.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.test.ts @@ -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 { TEST_DELIVERY_EVENT_TYPE } from "./request-handler.js"; import type { EventBusAdapter, DBAdapter, SubscriptionRecord, SubscriptionUpdate } from "./request-handler.js"; // Prevent real DNS resolution during subscribe validation tests. @@ -44,8 +45,11 @@ class RecordingDBAdapter implements DBAdapter { class RecordingEventBusAdapter implements EventBusAdapter { public readonly published: string[] = []; - async publish(event: { type: string }): Promise { + /** Full envelopes, for assertions that need more than the event type. */ + public readonly events: Array<{ type: string; data?: unknown }> = []; + async publish(event: { type: string; data?: unknown }): Promise { this.published.push(event.type); + this.events.push(event); } async subscribe(): Promise { return; @@ -294,11 +298,72 @@ describe("EEPServer", () => { did: "did:web:example.com" }); const routes = server.getRouteDefinitions(); - expect(routes.length).toBe(12); + expect(routes.length).toBe(18); const operationIds = routes.map((route) => route.operationId); expect(operationIds).toContain("subscribe"); expect(operationIds).toContain("subscriptionStatus"); expect(operationIds).toContain("unsubscribe"); + expect(operationIds).toContain("listSubscriptions"); + expect(operationIds).toContain("resumeSubscription"); + expect(operationIds).toContain("pauseSubscription"); + expect(operationIds).toContain("testSubscriptionDelivery"); + }); + + // SPECIFICATION.md §5.1.1: creation stays on `POST /eep/subscribe` (it is + // what the manifest and the rel="subscribe" Link header advertise); every + // member operation is addressed under the `/eep/subscriptions` collection. + it("addresses subscription member operations under /eep/subscriptions", () => { + const server = new EEPServer({ + baseUrl: "https://api.example.com", + did: "did:web:example.com" + }); + const routes = server.getRouteDefinitions(); + const find = (operationId: string) => routes.find((r) => r.operationId === operationId); + + expect(find("subscribe")).toMatchObject({ method: "POST", path: "/eep/subscribe" }); + expect(find("listSubscriptions")).toMatchObject({ method: "GET", path: "/eep/subscriptions" }); + expect(find("subscriptionStatus")).toMatchObject({ + method: "GET", + path: "/eep/subscriptions/:subscriptionId" + }); + expect(find("unsubscribe")).toMatchObject({ + method: "DELETE", + path: "/eep/subscriptions/:subscriptionId" + }); + expect(find("pauseSubscription")).toMatchObject({ + method: "POST", + path: "/eep/subscriptions/:subscriptionId/pause" + }); + expect(find("resumeSubscription")).toMatchObject({ + method: "POST", + path: "/eep/subscriptions/:subscriptionId/resume" + }); + expect(find("testSubscriptionDelivery")).toMatchObject({ + method: "POST", + path: "/eep/subscriptions/:subscriptionId/test" + }); + }); + + it("keeps the pre-§5.1.1 /eep/subscribe/:id member paths as deprecated aliases", () => { + const server = new EEPServer({ + baseUrl: "https://api.example.com", + did: "did:web:example.com" + }); + const routes = server.getRouteDefinitions(); + expect(routes).toContainEqual( + expect.objectContaining({ + method: "GET", + path: "/eep/subscribe/:subscriptionId", + operationId: "subscriptionStatusDeprecated" + }) + ); + expect(routes).toContainEqual( + expect.objectContaining({ + method: "DELETE", + path: "/eep/subscribe/:subscriptionId", + operationId: "unsubscribeDeprecated" + }) + ); }); describe("subscribe body validation", () => { @@ -530,6 +595,158 @@ describe("EEPServer", () => { }); }); + // SPECIFICATION.md §5.1.1 — list / resume / test-delivery member operations. + describe("subscription collection operations (§5.1.1)", () => { + 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 seed = async (db: RecordingDBAdapter, overrides: Partial = {}) => { + const record: SubscriptionRecord = { + subscription_id: "sub_test_1", + source_did: "did:web:agent.example", + delivery_method: "webhook", + callback_url: "https://hook.example/notify", + event_types: ["com.example.entity.updated"], + status: "active", + failure_count: 0, + delivery_secret: "whsec_super_secret_value_1234", + created_at: new Date().toISOString(), + ...overrides + }; + await db.saveSubscription(record); + return record; + }; + + it("lists subscriptions without ever re-exposing delivery_secret", async () => { + const { db, server } = makeServer(); + await seed(db); + const res = await server.getSubscriptionListHandler()({ + method: "GET", + path: "/eep/subscriptions", + headers: {} + }); + expect(res.status).toBe(200); + const body = res.body as { count: number; subscriptions: Array> }; + expect(body.count).toBe(1); + expect(body.subscriptions[0]).not.toHaveProperty("delivery_secret"); + expect(body.subscriptions[0]?.subscription_id).toBe("sub_test_1"); + }); + + it("resumes a paused subscription and clears its failure counter", async () => { + const { db, server } = makeServer(); + await seed(db, { status: "paused", failure_count: 5 }); + const res = await server.getSubscriptionResumeHandler()({ + method: "POST", + path: "/eep/subscriptions/sub_test_1/resume", + headers: {}, + params: { subscriptionId: "sub_test_1" } + }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "active", failure_count: 0 }); + expect(res.body).not.toHaveProperty("delivery_secret"); + expect(db.rows[0]?.status).toBe("active"); + expect(db.rows[0]?.failure_count).toBe(0); + }); + + it("pauses an active subscription", async () => { + const { db, server } = makeServer(); + await seed(db, { status: "active" }); + const res = await server.getSubscriptionPauseHandler()({ + method: "POST", + path: "/eep/subscriptions/sub_test_1/pause", + headers: {}, + params: { subscriptionId: "sub_test_1" } + }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "paused" }); + expect(res.body).not.toHaveProperty("delivery_secret"); + expect(db.rows[0]?.status).toBe("paused"); + }); + + it("rejects pausing an already-paused subscription with 409", async () => { + const { db, server } = makeServer(); + await seed(db, { status: "paused" }); + const res = await server.getSubscriptionPauseHandler()({ + method: "POST", + path: "/eep/subscriptions/sub_test_1/pause", + headers: {}, + params: { subscriptionId: "sub_test_1" } + }); + expect(res.status).toBe(409); + }); + + it("rejects resuming an already-active subscription with 409", async () => { + const { db, server } = makeServer(); + await seed(db, { status: "active" }); + const res = await server.getSubscriptionResumeHandler()({ + method: "POST", + path: "/eep/subscriptions/sub_test_1/resume", + headers: {}, + params: { subscriptionId: "sub_test_1" } + }); + expect(res.status).toBe(409); + }); + + it("returns 404 when resuming an unknown subscription", async () => { + const { server } = makeServer(); + const res = await server.getSubscriptionResumeHandler()({ + method: "POST", + path: "/eep/subscriptions/nope/resume", + headers: {}, + params: { subscriptionId: "nope" } + }); + expect(res.status).toBe(404); + }); + + it("enqueues a test delivery addressed to exactly one subscription", async () => { + const { db, bus, server } = makeServer(); + await seed(db); + const res = await server.getSubscriptionTestHandler()({ + method: "POST", + path: "/eep/subscriptions/sub_test_1/test", + headers: {}, + params: { subscriptionId: "sub_test_1" } + }); + expect(res.status).toBe(202); + expect(bus.published).toEqual([TEST_DELIVERY_EVENT_TYPE]); + expect(bus.events[0]?.data).toMatchObject({ subscription_id: "sub_test_1" }); + }); + + it("refuses a test delivery for a paused subscription with 409", async () => { + const { db, bus, server } = makeServer(); + await seed(db, { status: "paused" }); + const res = await server.getSubscriptionTestHandler()({ + method: "POST", + path: "/eep/subscriptions/sub_test_1/test", + headers: {}, + params: { subscriptionId: "sub_test_1" } + }); + expect(res.status).toBe(409); + expect(bus.published).toEqual([]); + }); + + it("returns 404 for a test delivery on an unknown subscription", async () => { + const { bus, server } = makeServer(); + const res = await server.getSubscriptionTestHandler()({ + method: "POST", + path: "/eep/subscriptions/nope/test", + headers: {}, + params: { subscriptionId: "nope" } + }); + expect(res.status).toBe(404); + expect(bus.published).toEqual([]); + }); + }); + describe("subscription status and unsubscribe", () => { const makeServer = () => { const db = new RecordingDBAdapter(); diff --git a/packages/@eep-dev/middleware/src/core/eep-server.ts b/packages/@eep-dev/middleware/src/core/eep-server.ts index 502df99..a6a241d 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.ts @@ -10,6 +10,7 @@ import { type ProofVerifier } from "@eep-dev/gates"; import { SSRFError, validateEventTypePattern, validateSSRF } from "@eep-dev/validator"; +import { TEST_DELIVERY_EVENT_TYPE } from "./request-handler.js"; import type { AuthAdapter, CloudEvent, @@ -386,6 +387,149 @@ export class EEPServer { }; } + /** + * `GET /eep/subscriptions` — list the caller's subscriptions (§5.1.1). + * + * `delivery_secret` is stripped: it is disclosed exactly once, in the + * creation response. + */ + getSubscriptionListHandler(): RequestHandler { + return async () => { + const subscriptions = await this.dbAdapter.listSubscriptions(); + const safe = subscriptions.map(({ delivery_secret: _secret, ...rest }) => rest); + return { status: 200, body: { subscriptions: safe, count: safe.length } }; + }; + } + + /** + * `POST /eep/subscriptions/:subscriptionId/pause` — stop delivering to an + * `active` subscription without cancelling it (§5.1.1, §10). + */ + getSubscriptionPauseHandler(): RequestHandler { + return async (request) => { + const subscriptionId = request.params?.subscriptionId; + if (!subscriptionId) { + return { + status: 400, + body: { error: "invalid_request", message: "subscription_id is required" } + }; + } + const subscription = await this.dbAdapter.getSubscription(subscriptionId); + if (!subscription) { + return { + status: 404, + body: { error: "not_found", message: `subscription ${subscriptionId} does not exist` } + }; + } + if (subscription.status === "paused") { + return { + status: 409, + body: { error: "conflict", message: `subscription ${subscriptionId} is already paused` } + }; + } + await this.dbAdapter.updateSubscription(subscriptionId, { status: "paused" }); + const updated = await this.dbAdapter.getSubscription(subscriptionId); + const { delivery_secret: _secret, ...safe } = updated ?? subscription; + return { status: 200, body: { ...safe, status: "paused" } }; + }; + } + + /** + * `POST /eep/subscriptions/:subscriptionId/resume` — move a `paused` + * subscription back to `active` and clear its failure counter (§5.1.1, §10). + */ + getSubscriptionResumeHandler(): RequestHandler { + return async (request) => { + const subscriptionId = request.params?.subscriptionId; + if (!subscriptionId) { + return { + status: 400, + body: { error: "invalid_request", message: "subscription_id is required" } + }; + } + const subscription = await this.dbAdapter.getSubscription(subscriptionId); + if (!subscription) { + return { + status: 404, + body: { error: "not_found", message: `subscription ${subscriptionId} does not exist` } + }; + } + if (subscription.status === "active") { + return { + status: 409, + body: { + error: "conflict", + message: `subscription ${subscriptionId} is already active` + } + }; + } + await this.dbAdapter.updateSubscription(subscriptionId, { + status: "active", + failure_count: 0 + }); + const updated = await this.dbAdapter.getSubscription(subscriptionId); + const { delivery_secret: _secret, ...safe } = updated ?? subscription; + return { status: 200, body: { ...safe, status: "active", failure_count: 0 } }; + }; + } + + /** + * `POST /eep/subscriptions/:subscriptionId/test` — enqueue a synthetic, + * fully signed delivery to the subscription's registered `delivery_url` + * (§5.1.1). + * + * This is what makes a publisher's delivery path independently checkable: + * `@eep-dev/compliance-cli` calls it to verify Standard Webhooks headers + * and HMAC correctness without waiting for organic traffic. The event is + * published on the normal event bus; `WebhookDispatcher` routes + * `com.eep.subscription.test` to the single subscription named in + * `data.subscription_id` rather than fanning it out by `event_types`. + */ + getSubscriptionTestHandler(): RequestHandler { + return async (request) => { + const subscriptionId = request.params?.subscriptionId; + if (!subscriptionId) { + return { + status: 400, + body: { error: "invalid_request", message: "subscription_id is required" } + }; + } + const subscription = await this.dbAdapter.getSubscription(subscriptionId); + if (!subscription) { + return { + status: 404, + body: { error: "not_found", message: `subscription ${subscriptionId} does not exist` } + }; + } + if (subscription.status !== "active") { + return { + status: 409, + body: { + error: "conflict", + message: `subscription ${subscriptionId} is ${subscription.status}; test deliveries require an active subscription` + } + }; + } + + const event: CloudEvent = { + id: `evt_test_${randomBytes(8).toString("hex")}`, + type: TEST_DELIVERY_EVENT_TYPE, + source: this.did, + time: new Date().toISOString(), + data: { + subscription_id: subscriptionId, + message: "EEP synthetic test delivery — no state changed." + } + }; + await this.eventBusAdapter.publish(event); + + return { + status: 202, + body: { status: "accepted", subscription_id: subscriptionId, event_id: event.id } + }; + }; + } + getUnsubscribeHandler(): RequestHandler { return async (request) => { const subscriptionId = request.params?.subscriptionId; @@ -454,9 +598,22 @@ export class EEPServer { { method: "GET", path: "/healthz", operationId: "health", handler: this.getHealthHandler() }, { method: "GET", path: "/eep/stream", operationId: "stream", handler: this.getSSEHandler() }, { method: "GET", path: "/eep/content/:resourcePath", operationId: "gatedContent", handler: this.getGatedResourceHandler() }, + // Subscription resource per SPECIFICATION.md §5.1.1. Creation stays on + // `POST /eep/subscribe` because that URL is what the manifest advertises + // as `layers.layer2_webhook` and what the `rel="subscribe"` Link header + // points at. Every member operation lives under `/eep/subscriptions`. { method: "POST", path: "/eep/subscribe", operationId: "subscribe", handler: this.getSubscribeHandler() }, - { method: "GET", path: "/eep/subscribe/:subscriptionId", operationId: "subscriptionStatus", handler: this.getSubscriptionStatusHandler() }, - { method: "DELETE", path: "/eep/subscribe/:subscriptionId", operationId: "unsubscribe", handler: this.getUnsubscribeHandler() }, + { method: "GET", path: "/eep/subscriptions", operationId: "listSubscriptions", handler: this.getSubscriptionListHandler() }, + { method: "GET", path: "/eep/subscriptions/:subscriptionId", operationId: "subscriptionStatus", handler: this.getSubscriptionStatusHandler() }, + { method: "DELETE", path: "/eep/subscriptions/:subscriptionId", operationId: "unsubscribe", handler: this.getUnsubscribeHandler() }, + { method: "POST", path: "/eep/subscriptions/:subscriptionId/pause", operationId: "pauseSubscription", handler: this.getSubscriptionPauseHandler() }, + { method: "POST", path: "/eep/subscriptions/:subscriptionId/resume", operationId: "resumeSubscription", handler: this.getSubscriptionResumeHandler() }, + { method: "POST", path: "/eep/subscriptions/:subscriptionId/test", operationId: "testSubscriptionDelivery", handler: this.getSubscriptionTestHandler() }, + + // Deprecated 0.1.x aliases for the member operations, which shipped + // under `/eep/subscribe/:id` before §5.1.1 existed. Remove at 0.2. + { method: "GET", path: "/eep/subscribe/:subscriptionId", operationId: "subscriptionStatusDeprecated", handler: this.getSubscriptionStatusHandler() }, + { method: "DELETE", path: "/eep/subscribe/:subscriptionId", operationId: "unsubscribeDeprecated", handler: this.getUnsubscribeHandler() }, { method: "GET", path: "/eep/audit-log", operationId: "auditLog", handler: this.getAuditLogHandler() }, { method: "GET", path: "/eep/pulse", operationId: "pulseUpgrade", handler: this.getPulseUpgradeHandler() } ]; diff --git a/packages/@eep-dev/middleware/src/core/request-handler.ts b/packages/@eep-dev/middleware/src/core/request-handler.ts index 97501c6..b665908 100644 --- a/packages/@eep-dev/middleware/src/core/request-handler.ts +++ b/packages/@eep-dev/middleware/src/core/request-handler.ts @@ -26,6 +26,14 @@ export type RouteDefinition = { operationId: string; }; +/** + * Event type emitted by `POST /eep/subscriptions/:id/test` (SPECIFICATION.md + * §5.1.1). `WebhookDispatcher` routes it to the single subscription named in + * `data.subscription_id` instead of fanning it out by `event_types`, so a + * conformance probe can exercise the signed delivery path on demand. + */ +export const TEST_DELIVERY_EVENT_TYPE = "com.eep.subscription.test"; + export type SubscriptionRecord = { subscription_id: string; source_did: string; diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts index db5f95a..0bac34f 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts @@ -3,6 +3,7 @@ import { EEPSigner } from "@eep-dev/signer"; import { WebhookDispatcher, DEFAULT_RETRY_SCHEDULE_MS, type WebhookHttpClient } from "./webhook-dispatcher.js"; import { InMemoryDBAdapter } from "../db/in-memory.js"; import { InMemoryEventBusAdapter } from "../event-bus/in-memory.js"; +import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js"; import type { CloudEvent, SubscriptionRecord } from "../core/request-handler.js"; const SECRET = "test-secret-abcdefghij"; @@ -58,6 +59,86 @@ describe("WebhookDispatcher", () => { expect(DEFAULT_RETRY_SCHEDULE_MS).toEqual([0, 5_000, 30_000, 120_000, 900_000, 3_600_000, 21_600_000]); }); + // SPECIFICATION.md §5.1.1: a synthetic test delivery is addressed to ONE + // subscription. It must reach that subscriber even though + // `com.eep.subscription.test` matches none of their `event_types`, and it + // must not fan out to anyone else. + describe("test deliveries (§5.1.1)", () => { + const testEvent = (subscriptionId: string) => + event({ + id: "evt_test_1", + type: TEST_DELIVERY_EVENT_TYPE, + data: { subscription_id: subscriptionId } + }); + + it("delivers to the addressed subscription despite no event_types match", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ subscription_id: "sub_target" })); + const { client, calls } = mockClient([200]); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + + const results = await dispatcher.dispatch(testEvent("sub_target")); + + expect(results).toHaveLength(1); + expect(results[0]?.delivered).toBe(true); + expect(calls).toHaveLength(1); + + // Signed exactly like production traffic — that is what makes the + // endpoint usable as a conformance probe. + const { headers, body } = calls[0]!; + expect( + new EEPSigner(SECRET).verify( + headers["webhook-id"]!, + headers["webhook-timestamp"]!, + headers["webhook-signature"]!, + body + ) + ).toBe(true); + }); + + it("does not fan out to other subscriptions", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ subscription_id: "sub_target" })); + await db.saveSubscription( + subscription({ subscription_id: "sub_bystander", callback_url: "https://other.example/hooks" }) + ); + const { client, calls } = mockClient([200]); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + + const results = await dispatcher.dispatch(testEvent("sub_target")); + + expect(results).toHaveLength(1); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://agent.example/hooks/eep"); + }); + + it("delivers nothing when the addressed subscription does not exist", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ subscription_id: "sub_target" })); + const { client, calls } = mockClient([200]); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + + const results = await dispatcher.dispatch(testEvent("sub_missing")); + + expect(results).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + it("ignores a malformed test event with no subscription_id", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ subscription_id: "sub_target" })); + const { client, calls } = mockClient([200]); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + + const results = await dispatcher.dispatch( + event({ id: "evt_test_2", type: TEST_DELIVERY_EVENT_TYPE, data: {} }) + ); + + expect(results).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + }); + it("delivers a matching event with verifiable Standard Webhooks headers", async () => { const db = new InMemoryDBAdapter(); await db.saveSubscription(subscription()); diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts index ae6cbaa..d14ddef 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts @@ -1,5 +1,6 @@ import { EEPSigner } from "@eep-dev/signer"; import { matchesAnyPattern } from "@eep-dev/validator"; +import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js"; import type { CloudEvent, DBAdapter, @@ -167,13 +168,24 @@ export class WebhookDispatcher { } private isTarget(sub: SubscriptionRecord, event: CloudEvent): boolean { - return ( + const deliverable = sub.delivery_method === "webhook" && sub.status === "active" && typeof sub.callback_url === "string" && - sub.callback_url.length > 0 && - matchesAnyPattern(event.type, sub.event_types) - ); + sub.callback_url.length > 0; + if (!deliverable) return false; + + // A synthetic test delivery is addressed to ONE subscription and must not + // fan out. It also deliberately bypasses `event_types`: the whole point is + // to exercise the signed delivery path for a subscriber whose patterns + // would never match `com.eep.subscription.test`. See SPECIFICATION.md + // §5.1.1. + if (event.type === TEST_DELIVERY_EVENT_TYPE) { + const target = (event.data as { subscription_id?: unknown } | undefined)?.subscription_id; + return typeof target === "string" && target === sub.subscription_id; + } + + return matchesAnyPattern(event.type, sub.event_types); } private async deliverWithRetry(event: CloudEvent, sub: SubscriptionRecord): Promise { diff --git a/packages/@eep-dev/middleware/src/index.ts b/packages/@eep-dev/middleware/src/index.ts index 2c0b4d5..12d5e46 100644 --- a/packages/@eep-dev/middleware/src/index.ts +++ b/packages/@eep-dev/middleware/src/index.ts @@ -1,4 +1,5 @@ export { EEPServer, type EEPServerOptions } from "./core/eep-server.js"; +export { TEST_DELIVERY_EVENT_TYPE } from "./core/request-handler.js"; export type { AuthAdapter, CloudEvent,