From d441455a6c26a66cc50644fbe64430624252d247 Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 26 Aug 2026 22:47:59 +0300 Subject: [PATCH] feat(schemas): canonical OpenAPI and AsyncAPI descriptions of the protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find -iname '*openapi*' -o -iname '*asyncapi*'` returned zero committed files. The only OpenAPI in the project was the one `@eep-dev/setup-cli` generates *per deployment*, which inverts the dependency: every publisher authored its own description of a shared protocol, so there was no canonical document for anything to drift from. That is how the subscription resource came to be addressed five different ways across the repo, two of them load-bearing for conformance. Worse, nothing described Layer 2 SSE, outbound webhook delivery, or the Layer 3 pulse in machine-readable form at all — and those are where EEP's value lives. OpenAPI cannot express them: it models request/response, not a long-lived stream, a server-initiated delivery, or a bidirectional channel. AsyncAPI models exactly those, and the 24 JSON Schemas were already sitting there ready to be referenced. - `schemas/v0.1/openapi.yaml` — Layer 1 and the Layer 2 request/response surface, 19 operations, `$ref`ing the existing schemas and citing the spec section that defines each endpoint. - `schemas/v0.1/asyncapi.yaml` — the SSE stream, webhook delivery, WebSub intent verification and the pulse channel. Webhook delivery is modelled with the subscriber as the server, because EEP inverts the usual direction there and a reader needs to see that. - `scripts/check-openapi-routes.mjs` plus a CI job: method, path and `operationId` are diffed against the reference middleware's route table in both directions. Verified by injecting drift each way (renamed operationId, phantom path) and confirming a non-zero exit, rather than only confirming it passes on a clean tree. - Deprecated compatibility aliases are deliberately excluded from the canonical description: implementations accept them, publishers should not advertise them. - New §0 pointing at both documents, and `setup-cli`'s generated description now says it records one deployment's URLs and defers to the canonical documents on protocol semantics. Refs: EEP audit 2026-08 findings B2, O7 Signed-off-by: Ugur Cekmez --- .github/workflows/test.yml | 19 + docs/current/SPECIFICATION.md | 20 + .../setup-cli/src/generators/artifacts.ts | 13 +- schemas/v0.1/asyncapi.yaml | 204 ++++++ schemas/v0.1/openapi.yaml | 596 ++++++++++++++++++ scripts/check-openapi-routes.mjs | 143 +++++ 6 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 schemas/v0.1/asyncapi.yaml create mode 100644 schemas/v0.1/openapi.yaml create mode 100644 scripts/check-openapi-routes.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09b31b4..46f86cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -485,6 +485,25 @@ jobs: - run: pip install -e . pytest pytest-cov httpx - run: python -m pytest tests/ -q + # The canonical OpenAPI description and the reference middleware must + # describe the same HTTP surface. Nothing compared them before, which is how + # the subscription resource came to be addressed five different ways. + openapi-route-parity: + name: OpenAPI ↔ middleware parity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 # renovate: pin + - uses: actions/setup-node@v7 # renovate: pin + with: + node-version: '22' + - name: Build middleware and its workspace dependencies + run: | + for dep in gates signer validator; do + (cd "packages/@eep-dev/$dep" && npm ci && npm run build) + done + (cd packages/@eep-dev/middleware && npm install && npm run build) + - run: node scripts/check-openapi-routes.mjs + # The Internet-Draft is the document this project intends to submit for # standardisation. It described a different manifest than the one that # ships — different field names, different required set — and nothing diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index 693ea3d..d816022 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -34,6 +34,26 @@ The Entity Engagement Protocol (EEP) defines how digital entities publish real-t --- +## 0. Machine-readable descriptions + +This document is normative. Two companion documents describe the same protocol +in machine-readable form, and are kept in lockstep with it by CI: + +| Document | Covers | +|---|---| +| [`schemas/v0.1/openapi.yaml`](../../schemas/v0.1/openapi.yaml) | Layer 1 and the Layer 2 request/response surface | +| [`schemas/v0.1/asyncapi.yaml`](../../schemas/v0.1/asyncapi.yaml) | Layer 2 SSE and webhook delivery, Layer 3 pulse | +| [`schemas/v0.1/*.json`](../../schemas/v0.1/) | Payload shapes, referenced by both | + +`scripts/check-openapi-routes.mjs` fails the build when `openapi.yaml` and the +reference middleware disagree about a method, path or `operationId`. + +`@eep-dev/setup-cli` still emits a per-deployment OpenAPI document; that +records the URLs and options a particular publisher deployed, and defers to the +canonical documents on protocol semantics. + +--- + ## 1. Terminology - **Entity**: Any digital subject with a stable identity and state that can change over time (a person, business, AI agent, or product). diff --git a/packages/@eep-dev/setup-cli/src/generators/artifacts.ts b/packages/@eep-dev/setup-cli/src/generators/artifacts.ts index 7ba4e2c..5360882 100644 --- a/packages/@eep-dev/setup-cli/src/generators/artifacts.ts +++ b/packages/@eep-dev/setup-cli/src/generators/artifacts.ts @@ -172,7 +172,18 @@ function buildOpenAPI(config: EEPSetupConfig): Record { info: { title: `${config.identity.org_name} EEP API`, version: eepVersion, - description: `EEP-compliant API surface. Spec: https://eep.dev/docs/current/SPECIFICATION.md`, + description: [ + `EEP-compliant API surface for ${config.identity.org_name}.`, + ``, + `This document describes THIS deployment. The canonical, protocol-level`, + `description lives in the EEP repository at schemas/v0.1/openapi.yaml,`, + `with the event-driven surface (SSE, webhooks, pulse) in`, + `schemas/v0.1/asyncapi.yaml. Where the two disagree about protocol`, + `semantics, the canonical documents win — this one exists to record the`, + `URLs and options you actually deployed.`, + ``, + `Spec: https://eep.dev/docs/current/SPECIFICATION.md` + ].join("\n"), license: { name: "Apache 2.0", url: "https://www.apache.org/licenses/LICENSE-2.0" } }, servers: [{ url: config.identity.base_url, description: "Primary EEP endpoint" }], diff --git a/schemas/v0.1/asyncapi.yaml b/schemas/v0.1/asyncapi.yaml new file mode 100644 index 0000000..91c8c6b --- /dev/null +++ b/schemas/v0.1/asyncapi.yaml @@ -0,0 +1,204 @@ +# Canonical AsyncAPI description of the EEP event-driven surface. +# +# Nothing in this repository described Layer 2 SSE, outbound webhooks, or the +# Layer 3 WebSocket pulse in machine-readable form — and those are where EEP's +# value lives. OpenAPI cannot express them: it models request/response, not a +# long-lived stream, a server-initiated delivery, or a bidirectional channel. +# AsyncAPI models exactly those, and the JSON Schemas alongside this file +# already define every payload. +# +# Semantics are normative in SPECIFICATION.md; each channel cites its section. +asyncapi: 3.0.0 + +info: + title: Entity Engagement Protocol — event-driven surface + version: 0.1.0 + description: | + Layer 2 (SSE + outbound webhooks) and Layer 3 (WebSocket pulse) of EEP + v0.1. + + The Layer 1 and Layer 2 *request/response* surface is described in + `openapi.yaml`. This document covers the parts that are streams or + server-initiated deliveries. + license: + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + +servers: + publisherSse: + host: api.example.com + protocol: https + description: Layer 2 SSE stream (§4). Substitute your own origin. + publisherPulse: + host: api.example.com + protocol: wss + description: Layer 3 WebSocket pulse channel (§6). + subscriberWebhook: + host: agent.example.com + protocol: https + description: | + The subscriber's own `delivery_url`. EEP inverts the usual direction + here: the publisher is the client and the subscriber is the server. + +channels: + signalStream: + address: /eep/stream + servers: + - $ref: '#/servers/publisherSse' + title: Signal stream (SSE) + description: | + Server-Sent Events carrying EEP envelopes (§4). Each frame sets `id:` to + the event id and `event:` to the event type, so a reconnect with + `Last-Event-ID` resumes strictly after that id (§4.3, minimum 24h + retention). The publisher sends a comment heartbeat at least every 15 + seconds (§4.4). + messages: + eepEvent: + $ref: '#/components/messages/eepEvent' + + webhookDelivery: + address: '{deliveryUrl}' + servers: + - $ref: '#/servers/subscriberWebhook' + title: Webhook delivery + description: | + Signed HTTP POST to the subscriber's registered `delivery_url` (§5.2). + Retried on the §5.4 schedule; re-signed per attempt so a late retry + still lands inside the subscriber's 60-second replay window. + parameters: + deliveryUrl: + description: The `delivery_url` supplied at subscription time. + messages: + eepEvent: + $ref: '#/components/messages/eepEvent' + + intentVerification: + address: '{deliveryUrl}' + servers: + - $ref: '#/servers/subscriberWebhook' + title: WebSub intent verification + description: | + Before delivering, the publisher GETs `delivery_url` with `hub.mode`, + `hub.topic`, `hub.challenge` and `hub.lease_seconds`; the subscriber + echoes the challenge (§10). The same handshake with + `hub.mode=unsubscribe` guards unauthenticated cancellation, without + which a caller could cancel another subscriber's delivery by guessing a + `subscription_id`. + parameters: + deliveryUrl: + description: The `delivery_url` supplied at subscription time. + messages: + intentChallenge: + $ref: '#/components/messages/intentChallenge' + + pulse: + address: /eep/pulse + servers: + - $ref: '#/servers/publisherPulse' + title: Network pulse (WebSocket) + description: | + Bidirectional channel for low-latency commands and negotiation (§6). + Messages carry a monotonic per-channel `seq`; on a detected gap the + client requests replay, bounded by the publisher's retained window + (§6.3.1). + messages: + pulseMessage: + $ref: '#/components/messages/pulseMessage' + +operations: + receiveSignalStream: + action: receive + channel: + $ref: '#/channels/signalStream' + title: Consume the SSE stream + description: A subscriber opens the stream and receives events as they occur. + messages: + - $ref: '#/channels/signalStream/messages/eepEvent' + + deliverWebhook: + action: send + channel: + $ref: '#/channels/webhookDelivery' + title: Deliver an event by webhook + description: | + The publisher POSTs a signed envelope to the subscriber. The subscriber + returns 2xx within 10 seconds or the delivery is treated as failed + (§5.3). + messages: + - $ref: '#/channels/webhookDelivery/messages/eepEvent' + + verifyIntent: + action: send + channel: + $ref: '#/channels/intentVerification' + title: Verify subscription intent + messages: + - $ref: '#/channels/intentVerification/messages/intentChallenge' + + exchangePulse: + action: send + channel: + $ref: '#/channels/pulse' + title: Exchange pulse messages + messages: + - $ref: '#/channels/pulse/messages/pulseMessage' + +components: + messages: + eepEvent: + name: eepEvent + title: EEP event envelope + summary: A CloudEvents v1.0.2 envelope with EEP extension attributes. + contentType: application/json + headers: + type: object + properties: + webhook-id: + type: string + description: Stable across retries; the subscriber's deduplication key (§5.2). + webhook-timestamp: + type: string + description: Unix seconds. Rejected outside a 60-second window (§5.3). + webhook-signature: + type: string + description: | + Space-delimited signature tokens. `v1,` is HMAC-SHA256; `v1a,` is + Ed25519 (§5.3.1). A verifier for one scheme ignores the other's + tokens, which is what makes dual-signing a usable migration path. + traceparent: + type: string + description: W3C Trace Context, mirrored from the envelope (§7.1). + EEP-Version: + type: string + payload: + $ref: 'https://eep.dev/schemas/v0.1/event.envelope.json' + + intentChallenge: + name: intentChallenge + title: WebSub intent verification challenge + summary: Query parameters sent to the subscriber; the challenge is echoed back. + payload: + type: object + required: [hub.mode, hub.topic, hub.challenge] + properties: + 'hub.mode': + type: string + enum: [subscribe, unsubscribe] + 'hub.topic': + type: string + 'hub.challenge': + type: string + 'hub.lease_seconds': + type: integer + description: | + The lease being granted (§10.2). A publisher that sends it MUST + enforce it; one that will not enforce a lease MUST omit the + parameter rather than send a value that means nothing. + + pulseMessage: + name: pulseMessage + title: Pulse message + summary: A Layer 3 WebSocket frame. + contentType: application/json + payload: + $ref: 'https://eep.dev/schemas/v0.1/ws-message.json' diff --git a/schemas/v0.1/openapi.yaml b/schemas/v0.1/openapi.yaml new file mode 100644 index 0000000..7dac67e --- /dev/null +++ b/schemas/v0.1/openapi.yaml @@ -0,0 +1,596 @@ +# Canonical OpenAPI description of the EEP Layer 1 and Layer 2 HTTP surface. +# +# This file is normative-adjacent: it does not replace SPECIFICATION.md, but it +# is the single machine-readable description of the protocol's HTTP endpoints. +# Before it existed, `@eep-dev/setup-cli` generated a *per-deployment* +# `openapi-eep.json`, which inverted the dependency — every publisher authored +# its own description of a shared protocol. That is how the subscription +# resource came to be addressed five different ways across the repository. +# +# `scripts/check-openapi-routes.mjs` diffs these paths against the reference +# middleware's route table on every PR, so the two cannot drift again. +# +# Layer 2 SSE and Layer 3 WebSocket are described in `asyncapi.yaml`; OpenAPI +# cannot express either meaningfully. +openapi: 3.1.0 + +info: + title: Entity Engagement Protocol + version: 0.1.0 + summary: Push-based, verifiable communication between digital entities and agents. + description: | + The HTTP surface of EEP v0.1: Layer 1 state resolution and the Layer 2 + webhook subscription lifecycle. + + Endpoint semantics are normative in + [SPECIFICATION.md](https://github.com/eep-dev/EEP/blob/main/docs/current/SPECIFICATION.md); + each operation below cites the section that defines it. Payload shapes are + defined by the JSON Schemas alongside this file. + license: + name: Apache-2.0 + identifier: Apache-2.0 + contact: + name: EEP Core Team + url: https://eep.dev + +servers: + - url: https://api.example.com + description: An EEP publisher. Substitute your own origin. + +tags: + - name: discovery + description: Layer 1 — state resolution and capability discovery (§3, §12). + - name: subscriptions + description: Layer 2 — the subscription resource (§5.1.1). + - name: events + description: Layer 2 — event history and catch-up (§5.1.2). + - name: gates + description: Access gates and gated content (§3.4). + +paths: + /.well-known/eep.json: + get: + tags: [discovery] + operationId: manifest + summary: Publisher manifest + description: | + Discovery document for this publisher (§12.3). Conditionally + retrievable: send `If-None-Match` to revalidate instead of + re-downloading (§3.2.1). + parameters: + - $ref: '#/components/parameters/IfNoneMatch' + responses: + '200': + description: The manifest. + headers: + ETag: { $ref: '#/components/headers/ETag' } + Cache-Control: { $ref: '#/components/headers/CacheControl' } + content: + application/json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/eep-manifest.json' + '304': { $ref: '#/components/responses/NotModified' } + + /u/{entityType}/{entityId}: + get: + tags: [discovery] + operationId: entity + summary: Resolve an entity + description: Layer 1 entity resolution (§3.1). Content-negotiable (§3.1). + parameters: + - name: entityType + in: path + required: true + schema: { type: string } + - name: entityId + in: path + required: true + schema: { type: string } + - $ref: '#/components/parameters/IfNoneMatch' + responses: + '200': + description: The entity representation. + headers: + ETag: { $ref: '#/components/headers/ETag' } + Cache-Control: { $ref: '#/components/headers/CacheControl' } + Link: + description: Discovery links; `rel="subscribe"` MUST be present (§3.2). + schema: { type: string } + EEP-Version: + schema: { type: string } + content: + application/json: + schema: { type: object } + text/markdown: + schema: { type: string } + '304': { $ref: '#/components/responses/NotModified' } + + /eep/gates: + get: + tags: [gates] + operationId: gates + summary: Gate configuration + description: The entity's gate configuration (§3.4). + parameters: + - $ref: '#/components/parameters/IfNoneMatch' + responses: + '200': + description: Gate configuration. + headers: + ETag: { $ref: '#/components/headers/ETag' } + Cache-Control: + description: '`private` at minimum — gate config is caller-specific (§3.2.1).' + schema: { type: string } + content: + application/json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/gate.config.json' + '304': { $ref: '#/components/responses/NotModified' } + + /eep/services: + get: + tags: [discovery] + operationId: services + summary: Service catalog + description: Services this entity offers (§15.1). + parameters: + - $ref: '#/components/parameters/IfNoneMatch' + responses: + '200': + description: Service catalog. + headers: + ETag: { $ref: '#/components/headers/ETag' } + Cache-Control: { $ref: '#/components/headers/CacheControl' } + content: + application/json: + schema: { type: object } + '304': { $ref: '#/components/responses/NotModified' } + + /healthz: + get: + tags: [discovery] + operationId: health + summary: Liveness probe + responses: + '200': + description: The publisher is reachable. + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + + /eep/stream: + get: + tags: [events] + operationId: stream + summary: SSE event stream + description: | + Layer 2 SSE stream (§4). Fully described in `asyncapi.yaml`; listed + here so the HTTP entry point, its parameters and its content type are + discoverable from a single document. + parameters: + - name: source + in: query + schema: { type: string } + description: Restrict the stream to one entity. + - name: events + in: query + schema: { type: string } + description: Comma-separated event type filter; trailing wildcards allowed. + - name: last_event_id + in: query + schema: { type: string } + description: Resume after this event id (§4.3). Equivalent to `Last-Event-ID`. + - name: Last-Event-ID + in: header + schema: { type: string } + description: Resume after this event id (§4.3). + responses: + '200': + description: An open event stream. + content: + text/event-stream: + schema: { type: string } + + /eep/content/{resourcePath}: + get: + tags: [gates] + operationId: gatedContent + summary: Gated resource + description: Retrieve a gated resource, presenting proofs (§3.4). + parameters: + - name: resourcePath + in: path + required: true + schema: { type: string } + responses: + '200': + description: Access granted. + content: + application/json: + schema: { type: object } + '402': { $ref: '#/components/responses/PaymentRequired' } + '403': { $ref: '#/components/responses/AccessRestricted' } + '451': { $ref: '#/components/responses/LegallyRestricted' } + + /eep/subscribe: + post: + tags: [subscriptions] + operationId: subscribe + summary: Create a subscription + description: | + Creates a subscription (§5.1). This is the URL advertised as + `layers.layer2_webhook` in the manifest and as `rel="subscribe"` in the + `Link` header, which is why creation stays here while every member + operation lives under `/eep/subscriptions` (§5.1.1). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/subscription.request.json' + responses: + '201': + description: | + Created. This response carries `delivery_secret`, disclosed exactly + once and never returned again (§5.1.1). + content: + application/json: + schema: { type: object } + '400': { $ref: '#/components/responses/BadRequest' } + '402': { $ref: '#/components/responses/PaymentRequired' } + '429': { $ref: '#/components/responses/RateLimited' } + + /eep/subscriptions: + get: + tags: [subscriptions] + operationId: listSubscriptions + summary: List the caller's subscriptions + description: Scoped to the authenticated caller (§5.1.1). + security: + - bearerAuth: [] + responses: + '200': + description: The caller's subscriptions, without `delivery_secret`. + headers: + RateLimit: { $ref: '#/components/headers/RateLimit' } + RateLimit-Policy: { $ref: '#/components/headers/RateLimitPolicy' } + content: + application/json: + schema: { type: object } + '429': { $ref: '#/components/responses/RateLimited' } + + /eep/subscriptions/{subscriptionId}: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + get: + tags: [subscriptions] + operationId: subscriptionStatus + summary: Read a subscription + security: + - bearerAuth: [] + responses: + '200': + description: The subscription, without `delivery_secret`. + content: + application/json: + schema: { type: object } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [subscriptions] + operationId: unsubscribe + summary: Cancel a subscription + description: | + Idempotent and terminal (§10.1). A repeat `DELETE` of an + already-cancelled subscription returns `204`, not `404` — otherwise a + retrying client reads its own success as a failure. + security: + - bearerAuth: [] + responses: + '204': + description: Cancelled, or already cancelled. + + /eep/subscriptions/{subscriptionId}/pause: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + post: + tags: [subscriptions] + operationId: pauseSubscription + summary: Pause delivery + security: + - bearerAuth: [] + responses: + '200': + description: The paused subscription. + content: + application/json: + schema: { type: object } + '404': { $ref: '#/components/responses/NotFound' } + '409': { $ref: '#/components/responses/Conflict' } + + /eep/subscriptions/{subscriptionId}/resume: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + post: + tags: [subscriptions] + operationId: resumeSubscription + summary: Resume a paused subscription + security: + - bearerAuth: [] + responses: + '200': + description: The resumed subscription, with its failure counter cleared. + content: + application/json: + schema: { type: object } + '404': { $ref: '#/components/responses/NotFound' } + '409': { $ref: '#/components/responses/Conflict' } + + /eep/subscriptions/{subscriptionId}/test: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + post: + tags: [subscriptions] + operationId: testSubscriptionDelivery + summary: Trigger a synthetic test delivery + description: | + Sends a fully signed `com.eep.subscription.test` event to the + registered `delivery_url` (§5.1.1). This is what makes a publisher's + delivery path independently checkable — `@eep-dev/compliance-cli` + relies on it to verify Standard Webhooks headers and HMAC correctness + without waiting for organic traffic. + security: + - bearerAuth: [] + responses: + '202': + description: Accepted; the delivery is enqueued. + content: + application/json: + schema: { type: object } + '404': { $ref: '#/components/responses/NotFound' } + '409': + description: The subscription is not `active`. + content: + application/problem+json: + schema: { type: object } + + /eep/subscriptions/{subscriptionId}/redeliver: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + post: + tags: [events] + operationId: redeliver + summary: Re-send specific events + description: | + Redelivered events keep their original `id`, so an already-processed + event is discarded by the ordinary idempotency rule rather than + processed twice (§5.1.2). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [event_ids] + properties: + event_ids: + type: array + minItems: 1 + maxItems: 100 + items: { type: string } + responses: + '202': + description: | + Accepted. `unavailable` names ids outside the retention window, + which the subscriber needs to know about rather than have dropped. + content: + application/json: + schema: { type: object } + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/NotFound' } + + /eep/subscriptions/{subscriptionId}/delivery-log: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + get: + tags: [subscriptions] + operationId: deliveryLog + summary: Per-attempt delivery history + description: | + How a subscriber distinguishes "the publisher never sent it" from "my + endpoint rejected it" (§5.1.2, delivery_guarantees.md §4). + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: Delivery attempts, most recent last. + content: + application/json: + schema: { type: object } + '404': { $ref: '#/components/responses/NotFound' } + + /eep/events: + get: + tags: [events] + operationId: eventHistory + summary: Event history + description: | + Catch-up for webhook subscribers (§5.1.2). `next_cursor` is present + only while events remain — its absence is how a subscriber knows it is + caught up. + security: + - bearerAuth: [] + parameters: + - name: source + in: query + schema: { type: string } + - name: since + in: query + schema: { type: string } + description: Return events strictly after this event id. + - name: until + in: query + schema: { type: string } + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of events. + content: + application/json: + schema: + type: object + required: [events] + properties: + events: + type: array + items: + $ref: 'https://eep.dev/schemas/v0.1/event.envelope.json' + next_cursor: { type: string } + '410': + description: | + The cursor is older than the retention window. Deliberately not an + empty `200`: that is indistinguishable from "you are up to date" + and would let a subscriber believe it caught up while silently + losing events (§5.1.2). + content: + application/problem+json: + schema: { type: object } + '429': { $ref: '#/components/responses/RateLimited' } + + /eep/audit-log: + get: + tags: [subscriptions] + operationId: auditLog + summary: Audit log + description: Full-tier audit trail (§16.1). + security: + - bearerAuth: [] + responses: + '200': + description: Audit entries. + content: + application/json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/audit-log.json' + + /eep/pulse: + get: + tags: [events] + operationId: pulseUpgrade + summary: Layer 3 WebSocket upgrade + description: | + The pulse channel (§6). Described in `asyncapi.yaml`; a plain `GET` + without an upgrade returns `426`. + responses: + '101': + description: Switching protocols. + '426': + description: Upgrade required — connect over WebSocket. + content: + application/json: + schema: { type: object } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: API key (§11). Scopes are defined in §11 "Scopes". + + parameters: + IfNoneMatch: + name: If-None-Match + in: header + required: false + schema: { type: string } + description: Conditional request validator (§3.2.1). + SubscriptionId: + name: subscriptionId + in: path + required: true + schema: { type: string } + Limit: + name: limit + in: query + required: false + schema: { type: integer, minimum: 1 } + description: Page size. Publishers MUST cap this and MUST document the cap. + + headers: + ETag: + description: Entity-tag for conditional requests (§3.2.1). + schema: { type: string } + CacheControl: + description: Cache directives (§3.2.1). + schema: { type: string } + RateLimit: + description: 'Current quota state, e.g. `"sub"; r=87; t=120` (§13).' + schema: { type: string } + RateLimitPolicy: + description: 'Quota policy, e.g. `"sub"; q=100; w=3600` (§13).' + schema: { type: string } + RetryAfter: + description: Seconds until the caller may retry. + schema: { type: integer } + + responses: + NotModified: + description: | + The caller's validator matches. No body; `ETag` and `Cache-Control` + are repeated (§3.2.1). + headers: + ETag: { $ref: '#/components/headers/ETag' } + Cache-Control: { $ref: '#/components/headers/CacheControl' } + BadRequest: + description: Malformed request. + content: + application/problem+json: + schema: { type: object } + NotFound: + description: | + No such resource. Also returned for a resource belonging to another + caller, so the collection cannot be enumerated (§5.1.1). + content: + application/problem+json: + schema: { type: object } + Conflict: + description: The resource is not in a state that permits this operation. + content: + application/problem+json: + schema: { type: object } + PaymentRequired: + description: A payment gate is unmet (§3.4). + content: + application/problem+json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/gate.402-response.json' + AccessRestricted: + description: A non-payment gate is unmet (§3.4.3). + content: + application/problem+json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/gate.403-response.json' + RateLimited: + description: Rate limit exceeded (§13). + headers: + RetryAfter: { $ref: '#/components/headers/RetryAfter' } + RateLimit: { $ref: '#/components/headers/RateLimit' } + RateLimit-Policy: { $ref: '#/components/headers/RateLimitPolicy' } + content: + application/problem+json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/gate.429-response.json' + LegallyRestricted: + description: Unavailable for legal reasons (§3.4.3). + content: + application/problem+json: + schema: + $ref: 'https://eep.dev/schemas/v0.1/gate.451-response.json' diff --git a/scripts/check-openapi-routes.mjs b/scripts/check-openapi-routes.mjs new file mode 100644 index 0000000..7a18fc3 --- /dev/null +++ b/scripts/check-openapi-routes.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * Verify that `schemas/v0.1/openapi.yaml` and the reference middleware's route + * table describe the same HTTP surface. + * + * Why this exists: the subscription resource was addressed five different ways + * across the repository — two of them load-bearing for conformance — because + * nothing compared the spec, the middleware and the CLI. `@eep-dev/setup-cli` + * generated a *per-deployment* OpenAPI document, which inverted the + * dependency: every publisher authored its own description of a shared + * protocol, so there was no canonical description for anything to drift from. + * + * This gate makes that drift a build failure. It compares method+path pairs + * and operationIds in both directions. + * + * Usage: + * node scripts/check-openapi-routes.mjs + */ +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const OPENAPI = resolve(REPO_ROOT, 'schemas/v0.1/openapi.yaml'); + +/** + * Minimal extraction of `paths:` entries from the OpenAPI document. + * + * A full YAML parser would be a dependency this repo does not otherwise need + * at the root, and the structure we care about — path keys and the HTTP method + * keys nested one level under them — is unambiguous at fixed indentation. + */ +function parseOpenApiOperations(yaml) { + const lines = yaml.split('\n'); + const operations = []; + let inPaths = false; + let currentPath = null; + + for (const line of lines) { + if (/^paths:\s*$/.test(line)) { + inPaths = true; + continue; + } + if (!inPaths) continue; + // A non-indented, non-empty, non-comment line ends the paths block. + if (/^\S/.test(line) && line.trim().length > 0 && !line.startsWith('#')) break; + + const pathMatch = /^ {2}(\/\S*):\s*$/.exec(line); + if (pathMatch) { + currentPath = pathMatch[1]; + continue; + } + const methodMatch = /^ {4}(get|put|post|delete|patch|head|options):\s*$/.exec(line); + if (methodMatch && currentPath) { + operations.push({ method: methodMatch[1].toUpperCase(), path: currentPath, operationId: null }); + continue; + } + const opIdMatch = /^ {6}operationId:\s*(\S+)\s*$/.exec(line); + if (opIdMatch && operations.length > 0) { + operations[operations.length - 1].operationId = opIdMatch[1]; + } + } + return operations; +} + +/** `/eep/subscriptions/:subscriptionId` → `/eep/subscriptions/{subscriptionId}` */ +function normalizeExpressPath(path) { + return path.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); +} + +const { EEPServer } = await import( + resolve(REPO_ROOT, 'packages/@eep-dev/middleware/dist/index.js') +).catch((err) => { + console.error( + '\n✗ Could not load @eep-dev/middleware.\n' + + ' Build it first: (cd packages/@eep-dev/middleware && npm run build)\n' + ); + throw err; +}); + +const server = new EEPServer({ baseUrl: 'https://api.example.com', did: 'did:web:example.com' }); +const routes = server.getRouteDefinitions(); + +const openapiOps = parseOpenApiOperations(readFileSync(OPENAPI, 'utf8')); +if (openapiOps.length === 0) { + console.error('\n✗ No operations parsed from schemas/v0.1/openapi.yaml.\n'); + process.exit(1); +} + +const key = (method, path) => `${method} ${path}`; +const openapiByKey = new Map(openapiOps.map((op) => [key(op.method, op.path), op])); + +const problems = []; + +for (const route of routes) { + // Deprecated compatibility aliases are deliberately not advertised in the + // canonical description: implementations accept them, publishers should + // not publish them. + if (route.operationId.endsWith('Deprecated')) continue; + + const normalized = normalizeExpressPath(route.path); + const op = openapiByKey.get(key(route.method, normalized)); + if (!op) { + problems.push( + `middleware serves ${route.method} ${normalized} (${route.operationId}), ` + + `which openapi.yaml does not describe` + ); + continue; + } + if (op.operationId !== route.operationId) { + problems.push( + `${route.method} ${normalized}: middleware operationId '${route.operationId}' ` + + `!= openapi.yaml '${op.operationId}'` + ); + } +} + +const middlewareKeys = new Set( + routes + .filter((r) => !r.operationId.endsWith('Deprecated')) + .map((r) => key(r.method, normalizeExpressPath(r.path))) +); +for (const op of openapiOps) { + if (!middlewareKeys.has(key(op.method, op.path))) { + problems.push( + `openapi.yaml describes ${op.method} ${op.path} (${op.operationId ?? 'no operationId'}), ` + + `which the reference middleware does not serve` + ); + } +} + +if (problems.length > 0) { + console.error('\n✗ openapi.yaml and the reference middleware disagree:\n'); + for (const problem of problems) console.error(` ${problem}`); + console.error( + '\n The canonical description and the reference implementation must\n' + + ' describe the same surface. Update whichever is wrong — but do not\n' + + ' let them diverge silently.\n' + ); + process.exit(1); +} + +console.error(`✓ openapi.yaml matches the reference middleware (${openapiOps.length} operations)`);