Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://api.example.com/eep/subscribe>; rel="subscribe"; type="application/json"
Link: <https://api.example.com/eep/stream?source=acme-corp>; rel="monitor"
Link: </.well-known/agent.json>; 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.

Expand Down
80 changes: 80 additions & 0 deletions packages/@eep-dev/compliance-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ const RECOMMENDATIONS: Record<string, string> = {
'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.',
Expand Down Expand Up @@ -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 {
Expand Down
128 changes: 128 additions & 0 deletions packages/@eep-dev/middleware/src/core/conditional.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading