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)`);