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
37 changes: 37 additions & 0 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,43 @@ Entities MUST declare their EEP capabilities in the JSON response:

The `gated`, `gates_url`, `commerce`, and `services_url` fields are OPTIONAL. They MUST be present if the entity uses gated access or offers services.

### 3.3.1 Error responses (normative)

EEP error responses are **RFC 9457 problem details**. Publishers MUST serve
them with `Content-Type: application/problem+json` and MUST include the
`type`, `title` and `status` members; `detail` and `instance` are RECOMMENDED.

| Member | Level | Meaning |
|---|---|---|
| `type` | MUST | URI identifying the problem type. Clients match on this, not on the HTTP status. |
| `title` | MUST | Short summary of the type. MUST NOT vary between occurrences. |
| `status` | MUST | The HTTP status, repeated so the document survives being logged or forwarded. |
| `detail` | SHOULD | Explanation specific to this occurrence, for a developer reading a log. |
| `instance` | MAY | URI reference identifying this occurrence. |

Registered EEP problem types:

| Status | `type` |
|---|---|
| 402 | `https://eep.dev/problems/payment-required` |
| 403 | `https://eep.dev/problems/access-restricted` |
| 429 | `https://eep.dev/problems/rate-limited` |
| 451 | `https://eep.dev/problems/legally-restricted` |

Everything EEP already defined on these responses — `unmet_requirements`,
`required_tier`, `retry_after_seconds`, `signed_challenge` and the rest —
remains, as RFC 9457 **extension members**. This is additive: a client reading
the existing fields keeps working, and a client that understands problem
details gains a shape it already knows.

Publishers MUST NOT vary `type` to encode per-occurrence information; that is
what `detail` is for. Clients MUST tolerate unknown extension members and MUST
NOT treat an unrecognised `type` as a different HTTP status than `status` says.

Bespoke error envelopes were a needless dialect: `application/problem+json` is
understood by generic HTTP clients, API gateways and agent frameworks without
EEP-specific parsing, and it is what a standards reviewer will expect.

### 3.4 Gated access

Entities MAY define **gates** to restrict access to resources. A gate configuration has entity-defined **tiers**, each with a list of **requirements** and a set of **access patterns** that tier opens up.
Expand Down
16 changes: 16 additions & 0 deletions packages/@eep-dev/gates/src/http-402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
*/

import type { GateConfig, GateProof, AccessRestrictionResponse, RateLimitResponse, Requirement } from './types.js';
import {
PROBLEM_TYPE_PAYMENT_REQUIRED,
PROBLEM_TYPE_RATE_LIMITED,
PROBLEM_JSON_CONTENT_TYPE,
} from './types.js';
import { resolveAccess } from './access-resolver.js';
import { matchesAny, findTiersForResource } from './resource-matcher.js';

Expand Down Expand Up @@ -44,6 +49,12 @@ export async function build402Response(
}

const response: AccessRestrictionResponse = {
// RFC 9457 members first: a generic problem-details client reads
// these, and the EEP fields below are extension members.
type: PROBLEM_TYPE_PAYMENT_REQUIRED,
title: 'Payment Required',
status: 402,
detail: `Access to '${resource}' requires the '${requiredTier}' tier.`,
error: 'access_restricted',
resource,
current_tier: accessResult.tier,
Expand Down Expand Up @@ -116,6 +127,10 @@ export async function build429Response(
const signedChallenge = `${challengePayload}.${toB64(signature)}`;

const body: RateLimitResponse = {
type: PROBLEM_TYPE_RATE_LIMITED,
title: 'Too Many Requests',
status: 429,
detail: `Rate limit exceeded for ${agentDid}; retry after ${retryAfterSeconds}s.`,
error: 'rate_limited',
did_rate_limit_key: agentDid,
retry_after_seconds: retryAfterSeconds,
Expand All @@ -127,6 +142,7 @@ export async function build429Response(
};

const headers: Record<string, string> = {
'Content-Type': PROBLEM_JSON_CONTENT_TYPE,
'Retry-After': String(retryAfterSeconds),
'X-EEP-Rate-Limit-DID': agentDid,
'X-EEP-Rate-Reset': windowResetAt,
Expand Down
13 changes: 12 additions & 1 deletion packages/@eep-dev/gates/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export type {
// Proof-of-Intent (G4)
IntentDocument, ProofOfIntent,
// Access results
UnmetRequirement, AccessResult, AccessRestrictionResponse,
UnmetRequirement, AccessResult, AccessRestrictionResponse, ProblemDetails,
// HTTP 403/451 (G6)
ForbiddenResponse, LegallyRestrictedResponse,
// EEP Manifest & ERC-8004 (G3/G8)
Expand Down Expand Up @@ -146,6 +146,17 @@ export {
// ── HTTP 402 ──────────────────────────────────────────────────────────────────
export { build402Response, isGatedResource, build429Response } from './http-402.js';

// RFC 9457 problem details (SPECIFICATION.md §3.3.1). Error responses carry
// these members alongside the EEP-specific fields, which become problem
// extension members.
export {
PROBLEM_TYPE_PAYMENT_REQUIRED,
PROBLEM_TYPE_ACCESS_RESTRICTED,
PROBLEM_TYPE_RATE_LIMITED,
PROBLEM_TYPE_LEGALLY_RESTRICTED,
PROBLEM_JSON_CONTENT_TYPE,
} from './types.js';

// ── Commerce ──────────────────────────────────────────────────────────────────
export {
transition, getValidActions, isTerminal,
Expand Down
96 changes: 96 additions & 0 deletions packages/@eep-dev/gates/src/problem-details.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
build402Response,
build429Response,
parseGateConfig,
PROBLEM_TYPE_PAYMENT_REQUIRED,
PROBLEM_TYPE_RATE_LIMITED,
PROBLEM_JSON_CONTENT_TYPE,
} from './index.js';

// SPECIFICATION.md §3.3.1 — EEP error responses are RFC 9457 problem details.
// The EEP-specific fields remain as problem *extension members*, so this is
// additive: a client reading the old fields keeps working.
describe('RFC 9457 problem details (§3.3.1)', () => {
const config = parseGateConfig({
default_tier: 'public',
tiers: {
public: { requirements: [], access: ['entity.public.profile'] },
premium: {
requirements: [{ type: 'payment', amount: 1, currency: 'usd', per: 'request' }],
access: ['content.papers.full_text'],
},
},
});

describe('402', () => {
it('carries type, title and status', async () => {
const body = await build402Response(config, 'content.papers.full_text', []);
expect(body.type).toBe(PROBLEM_TYPE_PAYMENT_REQUIRED);
expect(body.title).toBe('Payment Required');
expect(body.status).toBe(402);
});

it('carries an occurrence-specific detail', async () => {
const body = await build402Response(config, 'content.papers.full_text', []);
expect(typeof body.detail).toBe('string');
expect(body.detail).toContain('content.papers.full_text');
});

// The point of RFC 9457 extension members: nothing EEP already
// defined is removed or renamed.
it('preserves every pre-existing EEP field', async () => {
const body = await build402Response(config, 'content.papers.full_text', []);
expect(body.error).toBe('access_restricted');
expect(body.resource).toBe('content.papers.full_text');
expect(body.current_tier).toBe('public');
expect(body.required_tier).toBe('premium');
expect(Array.isArray(body.unmet_requirements)).toBe(true);
});

// `title` identifies the problem TYPE, so it must not vary between
// occurrences; `detail` is where per-occurrence information goes.
it('keeps title stable across different resources', async () => {
const a = await build402Response(config, 'content.papers.full_text', []);
const b = await build402Response(config, 'entity.public.profile', []);
expect(a.title).toBe(b.title);
expect(a.type).toBe(b.type);
});
});

describe('429', () => {
const sign = async (challenge: string) => `sig(${challenge.slice(0, 8)})`;

it('carries type, title and status', async () => {
const { body } = await build429Response('did:key:agent', 60, sign);
expect(body.type).toBe(PROBLEM_TYPE_RATE_LIMITED);
expect(body.title).toBe('Too Many Requests');
expect(body.status).toBe(429);
});

it('serves the RFC 9457 media type', async () => {
const { headers } = await build429Response('did:key:agent', 60, sign);
expect(headers['Content-Type']).toBe(PROBLEM_JSON_CONTENT_TYPE);
});

it('preserves the pre-existing rate-limit fields', async () => {
const { body, headers } = await build429Response('did:key:agent', 60, sign, {
limitPerWindow: 100,
requestsMade: 101,
});
expect(body.error).toBe('rate_limited');
expect(body.did_rate_limit_key).toBe('did:key:agent');
expect(body.retry_after_seconds).toBe(60);
expect(body.signed_challenge).toContain('v1.');
expect(body.limit_per_window).toBe(100);
expect(headers['Retry-After']).toBe('60');
});
});

it('uses distinct problem type URIs per condition', () => {
expect(PROBLEM_TYPE_PAYMENT_REQUIRED).not.toBe(PROBLEM_TYPE_RATE_LIMITED);
for (const uri of [PROBLEM_TYPE_PAYMENT_REQUIRED, PROBLEM_TYPE_RATE_LIMITED]) {
expect(() => new URL(uri)).not.toThrow();
}
});
});
37 changes: 34 additions & 3 deletions packages/@eep-dev/gates/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,38 @@ export interface AccessResult {

// ── HTTP 402 Response ─────────────────────────────────────────────────────────

export interface AccessRestrictionResponse {
/**
* RFC 9457 problem-details members, common to every EEP error response
* (SPECIFICATION.md §3.3.1).
*
* Everything EEP already defined on these responses stays, as RFC 9457
* *extension members* — so a client reading the existing fields keeps
* working, and a client that speaks problem details gains a shape it
* already knows.
*/
export interface ProblemDetails {
/** URI identifying the problem type. Clients match on this, not the status. */
type: string;
/** Short summary of the type. Does not vary between occurrences. */
title: string;
/** The HTTP status, repeated so the document survives being logged. */
status: number;
/** Explanation specific to this occurrence, for a developer reading a log. */
detail?: string;
/** URI reference identifying this occurrence. */
instance?: string;
}

/** Registered EEP problem type URIs (SPECIFICATION.md §3.3.1). */
export const PROBLEM_TYPE_PAYMENT_REQUIRED = 'https://eep.dev/problems/payment-required';
export const PROBLEM_TYPE_ACCESS_RESTRICTED = 'https://eep.dev/problems/access-restricted';
export const PROBLEM_TYPE_RATE_LIMITED = 'https://eep.dev/problems/rate-limited';
export const PROBLEM_TYPE_LEGALLY_RESTRICTED = 'https://eep.dev/problems/legally-restricted';

/** Media type for every EEP error response (RFC 9457). */
export const PROBLEM_JSON_CONTENT_TYPE = 'application/problem+json';

export interface AccessRestrictionResponse extends ProblemDetails {
error: 'access_restricted';
resource: string;
current_tier: string;
Expand All @@ -311,7 +342,7 @@ export interface AccessRestrictionResponse {
// ── 403 Forbidden Response ───────────────────────────────────────────────────

/** HTTP 403 response for credential/agreement/identity gate failure (G6) */
export interface ForbiddenResponse {
export interface ForbiddenResponse extends ProblemDetails {
error: 'access_forbidden';
resource: string;
current_tier: string;
Expand Down Expand Up @@ -740,7 +771,7 @@ export interface RFPClosedEvent {
// ── G30: Rate-Limit 429 Response ─────────────────────────────────────────────

/** HTTP 429 Too Many Requests response body per Whitepaper §10.5 / SPECIFICATION.md §3.4.6 */
export interface RateLimitResponse {
export interface RateLimitResponse extends ProblemDetails {
error: 'rate_limited';
did_rate_limit_key: string;
retry_after_seconds: number;
Expand Down
8 changes: 5 additions & 3 deletions packages/@eep-dev/middleware/src/core/eep-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
type AccessRestrictionResponse,
type GateConfig,
type GateProof,
type ProofVerifier
type ProofVerifier,
PROBLEM_JSON_CONTENT_TYPE
} from "@eep-dev/gates";
import { SSRFError, validateEventTypePattern, validateSSRF } from "@eep-dev/validator";
import { withConditional } from "./conditional.js";
Expand Down Expand Up @@ -271,7 +272,7 @@ export class EEPServer {

if (!access.granted) {
const payload = await build402Response(this.gateConfig, resource, proofs);
return { status: 402, body: payload };
return { status: 402, headers: { "Content-Type": PROBLEM_JSON_CONTENT_TYPE }, body: payload };
}

return {
Expand Down Expand Up @@ -381,7 +382,7 @@ export class EEPServer {
});
if (!access.granted) {
const payload: AccessRestrictionResponse = await build402Response(this.gateConfig, sentinelResource, proofs);
return { status: 402, body: payload };
return { status: 402, headers: { "Content-Type": PROBLEM_JSON_CONTENT_TYPE }, body: payload };
}
}
}
Expand Down Expand Up @@ -650,6 +651,7 @@ export class EEPServer {
get402Handler(resource: string, proofs: GateProof[]): Promise<OutgoingResponse> {
return build402Response(this.gateConfig, resource, proofs).then((body) => ({
status: 402,
headers: { "Content-Type": PROBLEM_JSON_CONTENT_TYPE },
body
}));
}
Expand Down
Loading