access402-fastapi 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: access402-fastapi
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Managed Access402 x402 v2 protection for FastAPI
5
5
  Project-URL: Homepage, https://access402.com
6
6
  Project-URL: Documentation, https://access402.com/docs/fastapi/get-started
@@ -64,6 +64,9 @@ access402 = Access402.from_env()
64
64
  price="0.05",
65
65
  access_type="per_request",
66
66
  description="Read one premium report",
67
+ discovery_enabled=True,
68
+ representative_queries=("Read report 42", "Get a premium research report"),
69
+ capabilities=("x402", "research"),
67
70
  )
68
71
  async def report(report_id: str):
69
72
  return {"id": report_id, "result": "..."}
@@ -71,6 +74,12 @@ async def report(report_id: str):
71
74
  access402.install(app)
72
75
  ```
73
76
 
77
+ Live discoverable routes are published in an ARD 0.91 manifest at
78
+ `/.well-known/ard.json`; `/.well-known/ai-catalog.json` is retained as a
79
+ compatibility path. Each entry includes the route's media type, canonical URL,
80
+ representative queries, capabilities, and Access402 payment metadata. Sandbox
81
+ manifests remain valid but intentionally contain no public entries.
82
+
74
83
  On startup, the adapter authenticates the installation, uploads only decorated payment policies, and downloads HMAC-authenticated configuration. The configuration is cached in memory and refreshed periodically; payment requests go directly to the Access402 settlement function. No CDP credential is installed in this package.
75
84
 
76
85
  The dashboard displays a read-only mirror of code-owned policies and the mode reported by the deployment. The FastAPI environment controls the payment network, while protected routes fail closed if Access402 cannot confirm a matching server-side policy.
@@ -35,6 +35,9 @@ access402 = Access402.from_env()
35
35
  price="0.05",
36
36
  access_type="per_request",
37
37
  description="Read one premium report",
38
+ discovery_enabled=True,
39
+ representative_queries=("Read report 42", "Get a premium research report"),
40
+ capabilities=("x402", "research"),
38
41
  )
39
42
  async def report(report_id: str):
40
43
  return {"id": report_id, "result": "..."}
@@ -42,6 +45,12 @@ async def report(report_id: str):
42
45
  access402.install(app)
43
46
  ```
44
47
 
48
+ Live discoverable routes are published in an ARD 0.91 manifest at
49
+ `/.well-known/ard.json`; `/.well-known/ai-catalog.json` is retained as a
50
+ compatibility path. Each entry includes the route's media type, canonical URL,
51
+ representative queries, capabilities, and Access402 payment metadata. Sandbox
52
+ manifests remain valid but intentionally contain no public entries.
53
+
45
54
  On startup, the adapter authenticates the installation, uploads only decorated payment policies, and downloads HMAC-authenticated configuration. The configuration is cached in memory and refreshed periodically; payment requests go directly to the Access402 settlement function. No CDP credential is installed in this package.
46
55
 
47
56
  The dashboard displays a read-only mirror of code-owned policies and the mode reported by the deployment. The FastAPI environment controls the payment network, while protected routes fail closed if Access402 cannot confirm a matching server-side policy.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "access402-fastapi"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Managed Access402 x402 v2 protection for FastAPI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,13 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import base64
5
+ import hashlib
5
6
  import inspect
6
7
  import json
7
8
  import logging
8
9
  import os
9
10
  import time
10
11
  import uuid
11
- from dataclasses import dataclass
12
+ from dataclasses import dataclass, replace
12
13
  from datetime import datetime, timezone
13
14
  from decimal import Decimal, InvalidOperation
14
15
  from typing import Any, Awaitable, Callable
@@ -16,11 +17,13 @@ from urllib.parse import quote, urlsplit
16
17
 
17
18
  import httpx
18
19
  from fastapi import FastAPI
20
+ from fastapi.responses import JSONResponse
19
21
  from fastapi.routing import APIRoute
20
22
 
21
23
  from .client import Access402Client, stable_hash
22
24
  from .models import Configuration, ResourcePolicy
23
25
  from .security import canonical_request_path, sign_grant, template_matches, valid_grant
26
+ from .ard import ARD_COMPATIBILITY_PATH, ARD_PATH, build_manifest
24
27
 
25
28
  BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
26
29
  BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
@@ -38,6 +41,8 @@ class CodePolicy:
38
41
  description: str | None
39
42
  discovery_enabled: bool
40
43
  discovery_input_example: dict[str, Any]
44
+ representative_queries: tuple[str, ...]
45
+ capabilities: tuple[str, ...]
41
46
 
42
47
 
43
48
  @dataclass(frozen=True)
@@ -52,7 +57,7 @@ class Access402Settings:
52
57
  stale_after_seconds: int = 900
53
58
  fail_closed: bool = True
54
59
  cors_allow_origin: str = "*"
55
- excluded_paths: tuple[str, ...] = ("/health", "/docs", "/redoc", "/openapi.json")
60
+ excluded_paths: tuple[str, ...] = ("/health", "/docs", "/redoc", "/openapi.json", ARD_PATH, ARD_COMPATIBILITY_PATH)
56
61
 
57
62
  @classmethod
58
63
  def from_env(cls) -> "Access402Settings":
@@ -104,6 +109,8 @@ class Access402:
104
109
  description: str | None = None,
105
110
  discovery_enabled: bool = False,
106
111
  discovery_input_example: dict[str, Any] | None = None,
112
+ representative_queries: tuple[str, ...] | list[str] | None = None,
113
+ capabilities: tuple[str, ...] | list[str] | None = None,
107
114
  ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
108
115
  """Declare an Access402 payment policy on a FastAPI endpoint."""
109
116
  try:
@@ -123,6 +130,12 @@ class Access402:
123
130
  raise ValueError("Access402 description must be 1 to 4000 characters")
124
131
  if not isinstance(discovery_input_example or {}, dict):
125
132
  raise ValueError("discovery_input_example must be a JSON object")
133
+ normalized_queries = tuple(item.strip() for item in (representative_queries or ()) if isinstance(item, str))
134
+ if representative_queries is not None and (len(normalized_queries) < 2 or len(normalized_queries) > 5 or any(len(item) < 3 or len(item) > 200 for item in normalized_queries)):
135
+ raise ValueError("representative_queries must contain 2 to 5 short strings")
136
+ normalized_capabilities = tuple(item.strip() for item in (capabilities or ()) if isinstance(item, str))
137
+ if capabilities is not None and (not normalized_capabilities or len(normalized_capabilities) > 20 or any(len(item) > 80 for item in normalized_capabilities)):
138
+ raise ValueError("capabilities must contain 1 to 20 short strings")
126
139
  policy = CodePolicy(
127
140
  price=format(normalized_price, "f"),
128
141
  access_type=access_type,
@@ -130,6 +143,8 @@ class Access402:
130
143
  description=description.strip() if description else None,
131
144
  discovery_enabled=discovery_enabled,
132
145
  discovery_input_example=discovery_input_example or {},
146
+ representative_queries=normalized_queries,
147
+ capabilities=normalized_capabilities,
133
148
  )
134
149
 
135
150
  def decorator(endpoint: Callable[..., Any]) -> Callable[..., Any]:
@@ -143,9 +158,47 @@ class Access402:
143
158
  # Build OpenAPI once at installation so invalid code-owned policy combinations
144
159
  # fail during application startup instead of on the first paid request.
145
160
  self.route_catalog()
161
+ async def ard_manifest() -> JSONResponse:
162
+ return JSONResponse(
163
+ self.ard_manifest(),
164
+ headers={"Cache-Control": "public, max-age=300", "Link": f'<{ARD_PATH}>; rel="ard"', "Access-Control-Allow-Origin": "*", "X-Content-Type-Options": "nosniff"},
165
+ )
166
+ app.add_api_route(ARD_PATH, ard_manifest, methods=["GET", "HEAD"], include_in_schema=False, name="access402_ard")
167
+ app.add_api_route(ARD_COMPATIBILITY_PATH, ard_manifest, methods=["GET", "HEAD"], include_in_schema=False, name="access402_ard_compatibility")
146
168
  app.router.add_event_handler("startup", self._startup_sync)
147
169
  app.add_middleware(Access402Middleware, manager=self)
148
170
 
171
+ def ard_manifest(self) -> dict[str, Any]:
172
+ if not self.app:
173
+ raise RuntimeError("Access402 is not installed on a FastAPI app")
174
+ schema = self.app.openapi()
175
+ resources: list[dict[str, Any]] = []
176
+ for route in self.app.routes:
177
+ if not isinstance(route, APIRoute):
178
+ continue
179
+ policy = getattr(route.endpoint, PROTECTED_POLICY_ATTRIBUTE, None)
180
+ if not isinstance(policy, CodePolicy):
181
+ continue
182
+ for method in sorted(route.methods or []):
183
+ operation = schema.get("paths", {}).get(route.path, {}).get(method.lower(), {})
184
+ responses = operation.get("responses", {})
185
+ success = next((value for key, value in responses.items() if str(key).startswith("2")), {})
186
+ output_content = success.get("content", {}) if isinstance(success, dict) else {}
187
+ resources.append({
188
+ "operation_id": operation.get("operationId") or route.name,
189
+ "display_name": operation.get("summary") or route.name or f"{method} {route.path}",
190
+ "description": operation.get("description") or route.description or policy.description,
191
+ "mime_type": next(iter(output_content), "application/json"),
192
+ "url": f"{self.settings.public_base_url}{route.path}",
193
+ "method": method,
194
+ "price": policy.price,
195
+ "access_type": policy.access_type,
196
+ "discovery_enabled": policy.discovery_enabled,
197
+ "representative_queries": policy.representative_queries,
198
+ "capabilities": policy.capabilities,
199
+ })
200
+ return build_manifest(public_base_url=self.settings.public_base_url, mode=self.settings.mode, resources=resources)
201
+
149
202
  async def _startup_sync(self) -> None:
150
203
  try:
151
204
  await self.refresh(force=True)
@@ -304,6 +357,44 @@ class Access402Middleware:
304
357
  return
305
358
  await self.app(scope, receive, send)
306
359
  return
360
+ headers = {key.decode("latin1").lower(): value.decode("latin1") for key, value in scope.get("headers", [])}
361
+ token = headers.get("x-access402-bootstrap", "")
362
+ try:
363
+ body = b""
364
+ if token:
365
+ if len(token) > 4096:
366
+ raise ValueError("Bootstrap header too large")
367
+ async with asyncio.timeout(10):
368
+ while True:
369
+ chunk = await receive()
370
+ if chunk["type"] != "http.request":
371
+ raise ValueError("Bootstrap request disconnected")
372
+ body += chunk.get("body", b"")
373
+ if len(body) > 65536:
374
+ raise ValueError("Bootstrap body too large")
375
+ if not chunk.get("more_body"):
376
+ break
377
+ request_url = self._challenge(configuration, policy, path)[0]["resource"]["url"]
378
+ query = scope.get("query_string", b"").decode("ascii")
379
+ if query:
380
+ request_url += "?" + query
381
+ gate = await self.manager.client.lifecycle(
382
+ policy, "live" if configuration.environment == "live" else "sandbox",
383
+ token=token, method=method, url=request_url,
384
+ body_hash=hashlib.sha256(body).hexdigest() if token else "",
385
+ payment=headers.get("payment-signature") or headers.get("x-payment", ""),
386
+ )
387
+ if gate["action"] == "respond":
388
+ await self._json(send, int(gate["status"]), gate["body"],
389
+ [(k.lower().encode(), v.encode()) for k, v in gate.get("headers", {}).items()])
390
+ return
391
+ if token or gate.get("lifecycle_state") != "active":
392
+ raise ValueError("Invalid active lifecycle")
393
+ policy = replace(policy, price=str(gate["configured_price"]))
394
+ except Exception:
395
+ await self._json(send, 503, {"error": "access402_lifecycle_unavailable"}, [(b"cache-control", b"private, no-store")])
396
+ return
397
+
307
398
  if self.manager.bypass:
308
399
  result = self.manager.bypass(scope)
309
400
  if await result if inspect.isawaitable(result) else result:
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Iterable
5
+ from urllib.parse import urlsplit
6
+
7
+ ARD_CONTEXT = "https://agenticresourcediscovery.org/context/v1"
8
+ ARD_PATH = "/.well-known/ard.json"
9
+ ARD_COMPATIBILITY_PATH = "/.well-known/ai-catalog.json"
10
+
11
+
12
+ def _slug(value: str) -> str:
13
+ return re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-")[:100] or "resource"
14
+
15
+
16
+ def build_manifest(*, public_base_url: str, mode: str, resources: Iterable[dict[str, Any]]) -> dict[str, Any]:
17
+ origin = urlsplit(public_base_url)
18
+ entries = []
19
+ if mode == "live":
20
+ for resource in resources:
21
+ if not resource.get("discovery_enabled"):
22
+ continue
23
+ display_name = str(resource["display_name"])
24
+ description = str(resource.get("description") or display_name)
25
+ queries = resource.get("representative_queries") or (
26
+ f"Use the {display_name} API",
27
+ f"Get {description.rstrip('.!?').lower()}",
28
+ )
29
+ entries.append({
30
+ "@context": ARD_CONTEXT,
31
+ "identifier": f"urn:air:{origin.hostname}:api:{_slug(str(resource['operation_id']))}",
32
+ "displayName": display_name,
33
+ "type": str(resource.get("mime_type") or "application/json"),
34
+ "url": str(resource["url"]),
35
+ "description": description,
36
+ "representativeQueries": list(queries),
37
+ "capabilities": list(resource.get("capabilities") or ("x402", "HTTP API")),
38
+ "metadata": {
39
+ "access402.paymentProtocol": "x402",
40
+ "access402.asset": "USDC",
41
+ "access402.price": str(resource["price"]),
42
+ "access402.method": str(resource["method"]),
43
+ "access402.accessType": str(resource["access_type"]),
44
+ },
45
+ })
46
+ return {
47
+ "@context": ARD_CONTEXT,
48
+ "version": "0.91",
49
+ "publisher": {"name": origin.hostname, "url": f"{origin.scheme}://{origin.netloc}"},
50
+ "entries": entries,
51
+ }
@@ -21,7 +21,7 @@ class Access402Client:
21
21
  return {
22
22
  "Authorization": f"Bearer {self.api_key}", "Accept": "application/json",
23
23
  "X-Access402-Adapter-Version": "3", "X-Access402-Mode": mode,
24
- "User-Agent": "Access402-FastAPI/0.3.0",
24
+ "User-Agent": "Access402-FastAPI/0.4.0",
25
25
  }
26
26
 
27
27
  async def close(self) -> None:
@@ -47,6 +47,18 @@ class Access402Client:
47
47
  raise ValueError("Invalid Access402 configuration")
48
48
  return Configuration(str(value.get("configuration_version")), str(value.get("generated_at")), environment, network, pay_to, resources)
49
49
 
50
+ async def lifecycle(self, policy: Any, mode: str, *, token: str = "", method: str = "", url: str = "", body_hash: str = "", payment: str = "") -> dict[str, Any]:
51
+ response = await self.http.post(
52
+ f"{self.base_url}/adapter/lifecycle", headers=self.headers(mode), timeout=40.0,
53
+ json={"rule_id": policy.local_rule_id, "bootstrap_token": token, "method": method,
54
+ "url": url, "body_hash": body_hash, "payment_payload": payment},
55
+ )
56
+ response.raise_for_status()
57
+ result = response.json()
58
+ if result.get("action") not in ("active", "respond"):
59
+ raise ValueError("Invalid lifecycle response")
60
+ return result
61
+
50
62
  async def settle(self, policy: Any, payment: str, requirements: dict[str, Any], mode: str) -> dict[str, Any]:
51
63
  idempotency = hashlib.sha256(f"{policy.local_rule_id}|{payment}".encode()).hexdigest()
52
64
  response = await self.http.post(
@@ -24,6 +24,10 @@ class ResourcePolicy:
24
24
  route_method: str
25
25
  route_template: str
26
26
 
27
+ lifecycle_state: str = "active"
28
+ configured_price: str | None = None
29
+ effective_price: str | None = None
30
+
27
31
  @classmethod
28
32
  def from_dict(cls, value: dict[str, Any]) -> "ResourcePolicy":
29
33
  required = ("id", "local_rule_id", "resource_key", "resource_url", "route_method", "route_template")
@@ -33,6 +37,8 @@ class ResourcePolicy:
33
37
  if not isinstance(price, (str, int, float)) or not str(price):
34
38
  raise ValueError("Invalid Access402 resource policy price")
35
39
  return cls(
40
+ lifecycle_state=str(value.get("lifecycle_state", "active")),
41
+ configured_price=str(value.get("configured_price", price)), effective_price=str(value.get("effective_price", price)),
36
42
  id=value["id"], local_rule_id=value["local_rule_id"], resource_key=value["resource_key"],
37
43
  resource_url=value["resource_url"], display_name=str(value.get("display_name", "")),
38
44
  description=value.get("description") if isinstance(value.get("description"), str) else None,
@@ -31,6 +31,8 @@ def policy(**changes):
31
31
 
32
32
  def configured_app(handler=None, **policy_changes):
33
33
  async def backend(request: httpx.Request):
34
+ if request.url.path.endswith("/adapter/lifecycle"):
35
+ return httpx.Response(200, json={"action": "active", "lifecycle_state": "active", "configured_price": policy_changes.get("price", "0.01")})
34
36
  if handler:
35
37
  return await handler(request)
36
38
  return httpx.Response(200, json={"settled": True, "payer": "0xpayer", "payment_event_id": "33333333-3333-4333-8333-333333333333", "payment_response": "receipt"})
@@ -168,6 +170,40 @@ def test_rejects_invalid_code_owned_policy():
168
170
  manager.protect(price="0.0000001")
169
171
  with pytest.raises(ValueError, match="requires a positive"):
170
172
  manager.protect(price="0.01", access_type="time_limited")
173
+ with pytest.raises(ValueError, match="2 to 5"):
174
+ manager.protect(price="0.01", representative_queries=["Only one"])
175
+
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_ard_manifest_publishes_live_discoverable_routes():
179
+ app = FastAPI()
180
+ manager = Access402(Access402Settings(
181
+ "installation", "a402_000000000000_" + "a" * 43, "https://weather.example", mode="live"
182
+ ))
183
+
184
+ @app.get("/forecast", summary="Local forecast")
185
+ @manager.protect(
186
+ price="0.01",
187
+ description="A hyper-local weather forecast",
188
+ discovery_enabled=True,
189
+ representative_queries=("Get a local forecast", "Check the weather for a trip"),
190
+ capabilities=("x402", "weather"),
191
+ )
192
+ async def forecast():
193
+ return {"temperature": 72}
194
+
195
+ manager.install(app)
196
+ manifest = manager.ard_manifest()
197
+ assert manifest["version"] == "0.91"
198
+ assert manifest["entries"][0]["identifier"].startswith("urn:air:weather.example:api:")
199
+ assert manifest["entries"][0]["metadata"]["access402.price"] == "0.01"
200
+ assert manifest["entries"][0]["representativeQueries"] == ["Get a local forecast", "Check the weather for a trip"]
201
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://weather.example") as client:
202
+ response = await client.get("/.well-known/ard.json")
203
+ compatibility = await client.get("/.well-known/ai-catalog.json")
204
+ assert response.status_code == compatibility.status_code == 200
205
+ assert response.headers["link"] == '</.well-known/ard.json>; rel="ard"'
206
+ assert response.json() == compatibility.json() == manifest
171
207
 
172
208
 
173
209
  def test_environment_requires_an_explicit_valid_payment_mode(monkeypatch):
@@ -240,3 +276,30 @@ async def test_configuration_rejects_the_wrong_installation_scope():
240
276
  async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as client:
241
277
  response = await client.get("/route")
242
278
  assert response.status_code == 503
279
+
280
+ @pytest.mark.asyncio
281
+ @pytest.mark.parametrize("gate", [
282
+ {"action": "respond", "status": 503, "body": {"error": "resource_not_active"}, "headers": {}},
283
+ {"action": "respond", "status": 200, "body": {"bootstrap": True, "settled": True}, "headers": {"PAYMENT-RESPONSE": "receipt"}},
284
+ ])
285
+ async def test_lifecycle_response_never_executes_handler_or_issues_grant(gate):
286
+ calls = []
287
+ async def backend(request):
288
+ calls.append(request.url.path)
289
+ return httpx.Response(200, json=gate)
290
+ app = FastAPI()
291
+ manager = Access402(Access402Settings("installation", "key", "https://api.example.com"), transport=httpx.MockTransport(backend))
292
+ @app.post("/reports/42")
293
+ @manager.protect(price="0.05")
294
+ async def protected():
295
+ pytest.fail("Bootstrap must never execute the customer handler")
296
+ manager.configuration = Configuration("v1", "now", "live", "eip155:8453", "0x" + "1" * 40, (policy(route_method="POST", route_template="/reports/42", access_type="wallet_once"),))
297
+ manager._loaded_at = manager._catalog_at = time.monotonic()
298
+ manager.install(app)
299
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as client:
300
+ response = await client.post("/reports/42", json={"test": True}, headers={"X-Access402-Bootstrap": "server-token", "PAYMENT-SIGNATURE": "proof", "X-Access402-Grant": "old"})
301
+ assert response.status_code == gate["status"]
302
+ assert response.json() == gate["body"]
303
+ assert "X-Access402-Grant" not in response.headers
304
+ assert calls == ["/functions/v1/adapter/lifecycle"]
305
+ await manager.close()