diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index 714bdce..d0a21a7 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -111,13 +111,66 @@ HTTP/1.1 200 OK Content-Type: application/json EEP-Version: 0.1 EEP-Entity-DID: did:web:example.com:u:acme-corp +ETag: "a3f1c9e2" +Last-Modified: Sat, 22 Feb 2026 14:30:00 GMT +Cache-Control: public, max-age=300 Link: ; rel="subscribe"; type="application/json" Link: ; rel="monitor" Link: ; rel="agent-card" ``` The `Link` header with `rel="subscribe"` MUST be present on all entity resolution responses. This is the primary EEP discovery mechanism. -### 3.2.2 Agent Request Headers +### 3.2.1 Conditional requests and caching (normative) + +Layer 1 is the **polled** surface of EEP: `/.well-known/eep.json`, entity +resolution, `/eep/gates`, `/eep/services` and the capability query endpoint. +An agent tracking many entities re-reads these documents far more often than +they change, and a manifest is not small — `eep-manifest.json` constrains 24 +properties including nested `x402`, `compliance`, `data_residency` and +`discovery_hints` objects. + +Publishers MUST therefore make these responses conditionally retrievable: + +1. Every `2xx` response to a `GET` on a Layer 1 resource MUST carry an `ETag`. + The entity-tag MUST change whenever the representation changes and MUST NOT + change when it does not. A strong validator is RECOMMENDED; a publisher that + cannot guarantee byte-stability (for example because it serialises maps in + nondeterministic order) MUST use a weak validator (`W/"…"`) rather than a + strong one it cannot honour. +2. Publishers MUST honour `If-None-Match` and MUST respond `304 Not Modified`, + with no body, when the validator matches. +3. Publishers SHOULD send `Last-Modified` and honour `If-Modified-Since`. +4. Publishers MUST send `Cache-Control`. `max-age` SHOULD reflect how volatile + the resource actually is; a manifest that changes rarely SHOULD NOT be + marked `no-store`. +5. A `304` MUST repeat `ETag` and `Cache-Control`, and MUST NOT be sent in + response to a request that carried no conditional header. + +Gated resources (§3.4) MUST be marked `Cache-Control: private` at minimum, so a +shared cache never serves gated content to an agent that did not satisfy the +gate. Publishers MUST evaluate gates **before** returning `304`: a subscriber +whose access was revoked must not keep validating a cached copy. + +### 3.2.2 Content coding (normative) + +Layer 1 documents and the Layer 2 SSE stream are highly compressible — the SSE +stream is near-identical JSON repeated indefinitely. + +- Publishers MUST honour `Accept-Encoding` on Layer 1 responses and SHOULD + offer at least `gzip`. +- Publishers SHOULD honour `Accept-Encoding` on the SSE stream. A publisher + that compresses an event stream MUST flush the compressor at every event + boundary; otherwise events sit in the compressor's buffer and the stream + stops being a stream. A publisher that cannot flush per event MUST serve the + stream uncompressed rather than break delivery latency. +- Publishers MAY negotiate `permessage-deflate` on the Layer 3 pulse channel. +- Publishers MUST NOT compress a response whose body is empty, including + `304`. + +Compression and conditional requests compose: a `304` avoids the body +entirely, and compression reduces what is left when a body must be sent. + +### 3.2.3 Agent Request Headers When an agent accesses restricted entity resources (e.g., submitting proofs to a gate, or posting to a protected inbox), the agent MUST include specific `EEP-` prefixed HTTP headers to identify itself and securely sign the request. diff --git a/packages/@eep-dev/compliance-cli/src/index.ts b/packages/@eep-dev/compliance-cli/src/index.ts index 20df413..280974b 100644 --- a/packages/@eep-dev/compliance-cli/src/index.ts +++ b/packages/@eep-dev/compliance-cli/src/index.ts @@ -137,6 +137,11 @@ const RECOMMENDATIONS: Record = { '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.', 'Webhook timestamp is fresh (\u00a75.3)': 'Send a current webhook-timestamp on every delivery and re-sign retries, so deliveries land inside the subscriber 60s replay window.', + 'manifest sends ETag (\u00a73.2.1)': 'Emit an ETag on every Layer 1 GET so agents can revalidate instead of re-downloading.', + 'manifest honours If-None-Match (\u00a73.2.1)': 'Return 304 Not Modified when the client presents a matching validator.', + 'manifest ETag is stable across requests (\u00a73.2.1)': 'Serialise deterministically (e.g. sorted keys) so the ETag changes only when the representation does.', + 'manifest sends Cache-Control (\u00a73.2.1)': 'Send Cache-Control with a max-age reflecting how volatile the resource actually is.', + 'manifest honours Accept-Encoding (\u00a73.2.2)': 'Offer at least gzip on Layer 1 responses.', 'manifest validates against eep-manifest.json': 'Serve a /.well-known/eep.json that validates against schemas/v0.1/eep-manifest.json in full, not just the headline fields.', 'event validates against event.envelope.json': 'Emit event envelopes that validate against schemas/v0.1/event.envelope.json.', 'CloudEvents specversion is 1.0': 'Emit CloudEvents v1.0 envelopes for all events.', @@ -768,6 +773,81 @@ async function runTests() { fail('/.well-known/eep.json manifest reachable', String(e)); } + // ── §3.2.1: conditional requests on Layer 1 ──────────────────── + // + // Layer 1 is the polled surface. An agent tracking many entities + // re-reads the manifest far more often than it changes, so ETag + + // 304 is the cheapest byte reduction available to a publisher. + try { + const first = await fetch(`${TARGET}/.well-known/eep.json`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + const etag = first.headers.get('etag'); + const cacheControl = first.headers.get('cache-control'); + + if (etag) { + logPass('manifest sends ETag (\u00a73.2.1)', etag); + const second = await fetch(`${TARGET}/.well-known/eep.json`, { + headers: { Accept: 'application/json', 'If-None-Match': etag }, + signal: AbortSignal.timeout(5000), + }); + if (second.status === 304) { + logPass('manifest honours If-None-Match (\u00a73.2.1)', 'HTTP 304 Not Modified'); + } else { + logFail( + 'manifest honours If-None-Match (\u00a73.2.1)', + `re-sent the full body as HTTP ${second.status}; \u00a73.2.1 requires 304`, + ); + } + + // A validator that changes every request is worse than none: + // the client pays for the round-trip and still gets a body. + const third = await fetch(`${TARGET}/.well-known/eep.json`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + const etagAgain = third.headers.get('etag'); + if (etagAgain === etag) { + logPass('manifest ETag is stable across requests (\u00a73.2.1)'); + } else { + logFail( + 'manifest ETag is stable across requests (\u00a73.2.1)', + `got ${etag} then ${etagAgain}; an unstable validator never produces a cache hit`, + ); + } + } else { + logFail('manifest sends ETag (\u00a73.2.1)', 'no ETag header'); + logSkip('manifest honours If-None-Match (\u00a73.2.1)', 'no ETag to revalidate with'); + logSkip('manifest ETag is stable across requests (\u00a73.2.1)', 'no ETag'); + } + + if (cacheControl) logPass('manifest sends Cache-Control (\u00a73.2.1)', cacheControl); + else logFail('manifest sends Cache-Control (\u00a73.2.1)', 'no Cache-Control header'); + } catch (e) { + logFail('manifest sends ETag (\u00a73.2.1)', String(e)); + } + + // ── §3.2.2: content coding ───────────────────────────────────── + try { + const res = await fetch(`${TARGET}/.well-known/eep.json`, { + headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip' }, + signal: AbortSignal.timeout(5000), + }); + // `fetch` transparently decodes, but reports what was negotiated. + const encoding = res.headers.get('content-encoding'); + if (encoding && encoding.includes('gzip')) { + logPass('manifest honours Accept-Encoding (\u00a73.2.2)', `Content-Encoding: ${encoding}`); + } else { + logFail( + 'manifest honours Accept-Encoding (\u00a73.2.2)', + 'served uncompressed despite Accept-Encoding: gzip', + ); + } + } catch (e) { + logFail('manifest honours Accept-Encoding (\u00a73.2.2)', String(e)); + } + // Test: 403 response for non-payment gate failures (G6) if (ENTITY) { try { diff --git a/packages/@eep-dev/middleware/src/core/conditional.test.ts b/packages/@eep-dev/middleware/src/core/conditional.test.ts new file mode 100644 index 0000000..4185fdd --- /dev/null +++ b/packages/@eep-dev/middleware/src/core/conditional.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { computeETag, ifNoneMatchSatisfied, withConditional } from "./conditional.js"; + +describe("computeETag", () => { + it("produces a quoted, stable tag", () => { + const tag = computeETag({ a: 1 }); + expect(tag).toMatch(/^"[A-Za-z0-9_-]{22}"$/); + expect(computeETag({ a: 1 })).toBe(tag); + }); + + // Without canonicalisation a publisher building its manifest from a map + // would emit a fresh ETag on every request, so no conditional request + // would ever hit — worse than not implementing them, because the client + // pays for the validator round-trip and still receives a body. + it("is insensitive to property insertion order", () => { + expect(computeETag({ a: 1, b: 2 })).toBe(computeETag({ b: 2, a: 1 })); + expect(computeETag({ outer: { x: 1, y: 2 } })).toBe(computeETag({ outer: { y: 2, x: 1 } })); + }); + + it("changes when the representation changes", () => { + expect(computeETag({ a: 1 })).not.toBe(computeETag({ a: 2 })); + expect(computeETag({ a: [1, 2] })).not.toBe(computeETag({ a: [2, 1] })); + }); + + it("preserves array order, which is semantic", () => { + expect(computeETag(["a", "b"])).not.toBe(computeETag(["b", "a"])); + }); +}); + +describe("ifNoneMatchSatisfied", () => { + const etag = '"abc123"'; + + it("returns false when the header is absent", () => { + expect(ifNoneMatchSatisfied(undefined, etag)).toBe(false); + expect(ifNoneMatchSatisfied("", etag)).toBe(false); + }); + + it("matches an identical tag", () => { + expect(ifNoneMatchSatisfied('"abc123"', etag)).toBe(true); + }); + + it("matches within a comma-separated list", () => { + expect(ifNoneMatchSatisfied('"other", "abc123", "more"', etag)).toBe(true); + }); + + it("matches `*`", () => { + expect(ifNoneMatchSatisfied("*", etag)).toBe(true); + }); + + // RFC 9110 §8.8.3.2: If-None-Match uses the WEAK comparison function, so + // W/"x" and "x" match. A string equality check would be wrong here. + it("compares weak and strong validators weakly", () => { + expect(ifNoneMatchSatisfied('W/"abc123"', etag)).toBe(true); + expect(ifNoneMatchSatisfied('"abc123"', 'W/"abc123"')).toBe(true); + }); + + it("does not match a different tag", () => { + expect(ifNoneMatchSatisfied('"different"', etag)).toBe(false); + }); +}); + +describe("withConditional", () => { + const body = { did: "did:web:example.com", eep_version: "0.1" }; + const options = { cacheControl: "public, max-age=300" }; + + it("adds ETag and Cache-Control to a 200", () => { + const res = withConditional({ status: 200, body }, {}, options); + expect(res.status).toBe(200); + expect(res.headers?.ETag).toMatch(/^"/); + expect(res.headers?.["Cache-Control"]).toBe("public, max-age=300"); + expect(res.body).toEqual(body); + }); + + it("preserves headers the handler already set", () => { + const res = withConditional( + { status: 200, headers: { "EEP-Version": "0.1" }, body }, + {}, + options + ); + expect(res.headers?.["EEP-Version"]).toBe("0.1"); + expect(res.headers?.ETag).toBeDefined(); + }); + + it("collapses to a bodyless 304 when the client already has it", () => { + const first = withConditional({ status: 200, body }, {}, options); + const etag = first.headers!.ETag!; + const second = withConditional({ status: 200, body }, { "if-none-match": etag }, options); + expect(second.status).toBe(304); + expect(second.body).toBeNull(); + // §3.2.1: a 304 repeats ETag and Cache-Control. + expect(second.headers?.ETag).toBe(etag); + expect(second.headers?.["Cache-Control"]).toBe("public, max-age=300"); + }); + + it("returns a full body when the client's validator is stale", () => { + const res = withConditional({ status: 200, body }, { "if-none-match": '"stale"' }, options); + expect(res.status).toBe(200); + expect(res.body).toEqual(body); + }); + + it("matches If-None-Match case-insensitively on the header name", () => { + const first = withConditional({ status: 200, body }, {}, options); + const res = withConditional( + { status: 200, body }, + { "If-None-Match": first.headers!.ETag! }, + options + ); + expect(res.status).toBe(304); + }); + + it("emits Last-Modified when the publisher knows it", () => { + const res = withConditional({ status: 200, body }, {}, { + ...options, + lastModified: "Sat, 22 Feb 2026 14:30:00 GMT", + }); + expect(res.headers?.["Last-Modified"]).toBe("Sat, 22 Feb 2026 14:30:00 GMT"); + }); + + // Collapsing an error to 304 would tell the client its cached success is + // still valid when it is not. + it("never makes a non-2xx response conditional", () => { + for (const status of [304, 400, 402, 404, 500]) { + const res = withConditional({ status, body: { error: "nope" } }, { "if-none-match": "*" }, options); + expect(res.status).toBe(status); + expect(res.headers?.ETag).toBeUndefined(); + } + }); +}); diff --git a/packages/@eep-dev/middleware/src/core/conditional.ts b/packages/@eep-dev/middleware/src/core/conditional.ts new file mode 100644 index 0000000..5ca246f --- /dev/null +++ b/packages/@eep-dev/middleware/src/core/conditional.ts @@ -0,0 +1,119 @@ +/** + * Conditional-request support for Layer 1 (SPECIFICATION.md §3.2.1). + * + * Layer 1 is the polled surface of EEP — manifest, entity resolution, gates, + * services, capabilities. An agent tracking many entities re-reads these far + * more often than they change, and a manifest is not small. Before this, + * nothing in the spec or the packages emitted `ETag`, honoured + * `If-None-Match`, or sent `Cache-Control`: every poll re-downloaded the whole + * document. + */ +import { createHash } from "node:crypto"; +import type { OutgoingResponse } from "./request-handler.js"; + +/** + * Compute a strong entity-tag over a JSON-serialisable body. + * + * The body is serialised with sorted keys so the tag is stable across runs + * regardless of property insertion order. Without that, a publisher that + * builds its manifest from a map would emit a new `ETag` on every request and + * conditional requests would never hit — worse than not implementing them, + * because the client pays for the validator round-trip and still gets a body. + */ +export function computeETag(body: unknown): string { + const canonical = JSON.stringify(body, canonicalReplacer); + const digest = createHash("sha256").update(canonical ?? "null", "utf8").digest("base64url"); + // 22 base64url chars ≈ 128 bits: ample for change detection, short enough + // that the header stays cheap on every response. + return `"${digest.slice(0, 22)}"`; +} + +function canonicalReplacer(_key: string, value: unknown): unknown { + if (value === null || typeof value !== "object" || Array.isArray(value)) return value; + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = (value as Record)[key]; + } + return sorted; +} + +/** + * Does an `If-None-Match` header match `etag`? + * + * Handles the comma-separated list form and `*`, and compares weak and strong + * validators using the weak comparison function RFC 9110 §8.8.3.2 prescribes + * for `If-None-Match` — `W/"x"` and `"x"` are a match here, which is why this + * cannot be a string equality check. + */ +export function ifNoneMatchSatisfied(header: string | undefined, etag: string): boolean { + if (!header) return false; + const candidates = header.split(",").map((c) => c.trim()).filter((c) => c.length > 0); + if (candidates.includes("*")) return true; + const normalized = stripWeak(etag); + return candidates.some((candidate) => stripWeak(candidate) === normalized); +} + +function stripWeak(tag: string): string { + return tag.startsWith("W/") ? tag.slice(2) : tag; +} + +export interface ConditionalOptions { + /** `Cache-Control` for this resource. */ + cacheControl: string; + /** RFC 1123 `Last-Modified`, when the publisher knows it. */ + lastModified?: string; +} + +/** + * Attach validators to a `200` response, collapsing it to `304` when the + * client already holds the current representation. + * + * A `304` repeats `ETag` and `Cache-Control` and carries no body, per §3.2.1. + * Only `2xx` responses are made conditional: collapsing an error to `304` + * would tell a client its cached success is still valid when it is not. + */ +export function withConditional( + response: OutgoingResponse, + requestHeaders: Record, + options: ConditionalOptions +): OutgoingResponse { + if (response.status < 200 || response.status >= 300) return response; + + const etag = computeETag(response.body); + const headers: Record = { + ...(response.headers ?? {}), + ETag: etag, + "Cache-Control": options.cacheControl, + }; + if (options.lastModified) headers["Last-Modified"] = options.lastModified; + + const ifNoneMatch = headerValue(requestHeaders, "if-none-match"); + if (ifNoneMatchSatisfied(ifNoneMatch, etag)) { + return { + status: 304, + headers: { + ETag: etag, + "Cache-Control": options.cacheControl, + ...(options.lastModified ? { "Last-Modified": options.lastModified } : {}), + }, + body: null, + }; + } + + return { ...response, headers, body: response.body }; +} + +function headerValue( + headers: Record, + name: string +): string | undefined { + const direct = headers[name]; + if (direct !== undefined) return direct; + // Node lower-cases incoming header names, but adapters vary; fall back to + // a case-insensitive scan rather than silently missing the header and + // serving a full body to a client that sent a valid conditional request. + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name) return value; + } + return undefined; +} 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 a08f6f5..61cb028 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.test.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.test.ts @@ -600,6 +600,97 @@ describe("EEPServer", () => { }); }); + // SPECIFICATION.md §3.2.1 — Layer 1 is the polled surface, and nothing + // emitted ETag or honoured If-None-Match, so every poll re-downloaded the + // whole document. + describe("Layer 1 conditional requests (§3.2.1)", () => { + const server = () => + new EEPServer({ baseUrl: "https://api.example.com", did: "did:web:example.com" }); + + const layer1 = [ + { + name: "manifest", + call: (s: EEPServer, headers: Record) => + s.getManifestHandler()({ method: "GET" as const, path: "/.well-known/eep.json", headers }), + cacheControl: "public, max-age=300" + }, + { + name: "entity", + call: (s: EEPServer, headers: Record) => + s.getEntityHandler()({ + method: "GET" as const, + path: "/u/u/alice", + headers, + params: { entityType: "u", entityId: "alice" } + }), + cacheControl: "public, max-age=60" + }, + { + name: "gates", + call: (s: EEPServer, headers: Record) => + s.getGatesHandler()({ method: "GET" as const, path: "/eep/gates", headers }), + // Gate config describes who may access what; a shared cache must not + // hand one agent's view to another. + cacheControl: "private, max-age=60" + }, + { + name: "services", + call: (s: EEPServer, headers: Record) => + s.getServicesHandler()({ method: "GET" as const, path: "/eep/services", headers }), + cacheControl: "public, max-age=60" + } + ]; + + it.each(layer1)("$name emits ETag and Cache-Control", async ({ call, cacheControl }) => { + const res = await call(server(), {}); + expect(res.status).toBe(200); + expect(res.headers?.ETag).toMatch(/^"[A-Za-z0-9_-]+"$/); + expect(res.headers?.["Cache-Control"]).toBe(cacheControl); + }); + + it.each(layer1)("$name returns 304 for a matching If-None-Match", async ({ call }) => { + const s = server(); + const first = await call(s, {}); + const second = await call(s, { "if-none-match": first.headers!.ETag! }); + expect(second.status).toBe(304); + expect(second.body).toBeNull(); + expect(second.headers?.ETag).toBe(first.headers!.ETag); + }); + + it.each(layer1)("$name returns a body for a stale validator", async ({ call }) => { + const res = await call(server(), { "if-none-match": '"stale-validator"' }); + expect(res.status).toBe(200); + expect(res.body).not.toBeNull(); + }); + + // A validator that changes on every request is worse than none: the + // client pays for the round-trip and still gets a body. + it("emits the same ETag across repeated identical requests", async () => { + const s = server(); + const a = await s.getManifestHandler()({ method: "GET", path: "/.well-known/eep.json", headers: {} }); + const b = await s.getManifestHandler()({ method: "GET", path: "/.well-known/eep.json", headers: {} }); + expect(a.headers?.ETag).toBe(b.headers?.ETag); + }); + + it("emits different ETags for different entities", async () => { + const s = server(); + const alice = await s.getEntityHandler()({ + method: "GET", path: "/u/u/alice", headers: {}, params: { entityType: "u", entityId: "alice" } + }); + const bob = await s.getEntityHandler()({ + method: "GET", path: "/u/u/bob", headers: {}, params: { entityType: "u", entityId: "bob" } + }); + expect(alice.headers?.ETag).not.toBe(bob.headers?.ETag); + }); + + it("keeps the discovery Link header on entity responses", async () => { + const res = await server().getEntityHandler()({ + method: "GET", path: "/u/u/alice", headers: {}, params: { entityType: "u", entityId: "alice" } + }); + expect(res.headers?.Link).toContain('rel="subscribe"'); + }); + }); + // SPECIFICATION.md §10.2 — a subscription is time-bounded. `hub.lease_seconds` // was advertised during intent verification but never enforced, which made // it decorative: an abandoned delivery_url received traffic forever. diff --git a/packages/@eep-dev/middleware/src/core/eep-server.ts b/packages/@eep-dev/middleware/src/core/eep-server.ts index 11a1626..dc2b59a 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 { withConditional } from "./conditional.js"; import { TEST_DELIVERY_EVENT_TYPE, DEFAULT_LEASE_SECONDS, @@ -151,8 +152,8 @@ export class EEPServer { } getManifestHandler(): RequestHandler { - return async () => { - return { + return async (request) => { + const response: OutgoingResponse = { status: 200, body: { did: this.did, @@ -170,6 +171,11 @@ export class EEPServer { x402_enabled: false } }; + // §3.2.1 — the manifest is the most-polled and least-volatile Layer 1 + // document, so it is the one that benefits most from a 304. + return withConditional(response, request.headers, { + cacheControl: "public, max-age=300" + }); }; } @@ -177,7 +183,7 @@ export class EEPServer { return async (request) => { const entityType = request.params?.entityType ?? "u"; const entityId = request.params?.entityId ?? "default"; - return { + const response: OutgoingResponse = { status: 200, headers: { "EEP-Version": "0.1", @@ -195,15 +201,26 @@ export class EEPServer { } } }; + return withConditional(response, request.headers, { + cacheControl: "public, max-age=60" + }); }; } getGatesHandler(): RequestHandler { - return async () => ({ status: 200, body: this.gateConfig }); + return async (request) => + withConditional({ status: 200, body: this.gateConfig }, request.headers, { + // Gate config describes who may access what. `private` keeps a shared + // cache from handing one agent's view to another. + cacheControl: "private, max-age=60" + }); } getServicesHandler(): RequestHandler { - return async () => ({ status: 200, body: this.services }); + return async (request) => + withConditional({ status: 200, body: this.services }, request.headers, { + cacheControl: "public, max-age=60" + }); } getHealthHandler(): RequestHandler {