access402-fastapi 0.2.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.
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/PKG-INFO +14 -5
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/README.md +12 -3
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/pyproject.toml +1 -1
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/src/access402_fastapi/adapter.py +104 -9
- access402_fastapi-0.4.0/src/access402_fastapi/ard.py +51 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/src/access402_fastapi/client.py +18 -6
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/src/access402_fastapi/models.py +6 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/tests/test_adapter.py +111 -1
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/.gitignore +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/src/access402_fastapi/__init__.py +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/src/access402_fastapi/py.typed +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.4.0}/src/access402_fastapi/security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: access402-fastapi
|
|
3
|
-
Version: 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
|
|
@@ -45,7 +45,7 @@ Create a **FastAPI** installation in the Access402 dashboard, then configure ser
|
|
|
45
45
|
ACCESS402_INSTALLATION_ID=your-installation-uuid
|
|
46
46
|
ACCESS402_API_KEY=your-installation-key
|
|
47
47
|
ACCESS402_PUBLIC_BASE_URL=https://api.example.com
|
|
48
|
-
|
|
48
|
+
ACCESS402_MODE=sandbox
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
The API key belongs only in the FastAPI server environment. Never expose it in browser code, logs, OpenAPI documents, or a committed `.env` file. `ACCESS402_API_BASE_URL` is intentionally optional and should only be overridden for local Access402 backend development.
|
|
@@ -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,15 +74,21 @@ 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
|
-
The dashboard displays a read-only mirror of code-owned policies.
|
|
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.
|
|
77
86
|
|
|
78
87
|
Dynamic path routes such as `/reports/{report_id}` can be protected. Discovery publication for those routes stays disabled until Access402 supports a concrete path-parameter example; publishing a literal template URL would create a broken Bazaar entry.
|
|
79
88
|
|
|
80
89
|
`ACCESS402_PUBLIC_BASE_URL` remains required. It is the trusted canonical origin placed in x402 resource URLs and validated against the installation's authorized origins. Inferring it from an incoming `Host` or forwarding header would allow a spoofed request to alter payment and discovery metadata. Use a separate FastAPI installation for local, staging, and production deployments; replicas of the same deployment may share one installation.
|
|
81
90
|
|
|
82
|
-
|
|
91
|
+
Set `ACCESS402_MODE=sandbox` for Base Sepolia or `ACCESS402_MODE=live` for Base mainnet, then restart the deployment. There is no dashboard mode override.
|
|
83
92
|
|
|
84
93
|
The adapter answers its own 402 responses with `Access-Control-Allow-Origin: *` by default so agent and browser clients can read the payment challenge. Set `ACCESS402_CORS_ALLOW_ORIGIN` to the API's exact browser origin when credentials are involved. CORS preflight (`OPTIONS`) is never paywalled.
|
|
85
94
|
|
|
@@ -16,7 +16,7 @@ Create a **FastAPI** installation in the Access402 dashboard, then configure ser
|
|
|
16
16
|
ACCESS402_INSTALLATION_ID=your-installation-uuid
|
|
17
17
|
ACCESS402_API_KEY=your-installation-key
|
|
18
18
|
ACCESS402_PUBLIC_BASE_URL=https://api.example.com
|
|
19
|
-
|
|
19
|
+
ACCESS402_MODE=sandbox
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
The API key belongs only in the FastAPI server environment. Never expose it in browser code, logs, OpenAPI documents, or a committed `.env` file. `ACCESS402_API_BASE_URL` is intentionally optional and should only be overridden for local Access402 backend development.
|
|
@@ -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,15 +45,21 @@ 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
|
-
The dashboard displays a read-only mirror of code-owned policies.
|
|
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.
|
|
48
57
|
|
|
49
58
|
Dynamic path routes such as `/reports/{report_id}` can be protected. Discovery publication for those routes stays disabled until Access402 supports a concrete path-parameter example; publishing a literal template URL would create a broken Bazaar entry.
|
|
50
59
|
|
|
51
60
|
`ACCESS402_PUBLIC_BASE_URL` remains required. It is the trusted canonical origin placed in x402 resource URLs and validated against the installation's authorized origins. Inferring it from an incoming `Host` or forwarding header would allow a spoofed request to alter payment and discovery metadata. Use a separate FastAPI installation for local, staging, and production deployments; replicas of the same deployment may share one installation.
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
Set `ACCESS402_MODE=sandbox` for Base Sepolia or `ACCESS402_MODE=live` for Base mainnet, then restart the deployment. There is no dashboard mode override.
|
|
54
63
|
|
|
55
64
|
The adapter answers its own 402 responses with `Access-Control-Allow-Origin: *` by default so agent and browser clients can read the payment challenge. Set `ACCESS402_CORS_ALLOW_ORIGIN` to the API's exact browser origin when credentials are involved. CORS preflight (`OPTIONS`) is never paywalled.
|
|
56
65
|
|
|
@@ -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)
|
|
@@ -45,33 +50,36 @@ class Access402Settings:
|
|
|
45
50
|
installation_id: str
|
|
46
51
|
api_key: str
|
|
47
52
|
public_base_url: str
|
|
53
|
+
mode: str = "sandbox"
|
|
48
54
|
api_base_url: str = "https://sfowjygkbsubaeggmckp.supabase.co/functions/v1"
|
|
49
55
|
refresh_seconds: int = 60
|
|
50
56
|
catalog_refresh_seconds: int = 300
|
|
51
57
|
stale_after_seconds: int = 900
|
|
52
|
-
allow_live: bool = False
|
|
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":
|
|
59
64
|
required = {
|
|
60
65
|
name: os.environ.get(name, "").strip()
|
|
61
|
-
for name in ("ACCESS402_INSTALLATION_ID", "ACCESS402_API_KEY", "ACCESS402_PUBLIC_BASE_URL")
|
|
66
|
+
for name in ("ACCESS402_INSTALLATION_ID", "ACCESS402_API_KEY", "ACCESS402_PUBLIC_BASE_URL", "ACCESS402_MODE")
|
|
62
67
|
}
|
|
63
68
|
missing = [name for name, value in required.items() if not value]
|
|
64
69
|
if missing:
|
|
65
70
|
raise RuntimeError(f"Missing Access402 environment variables: {', '.join(missing)}")
|
|
71
|
+
mode = required["ACCESS402_MODE"].lower()
|
|
72
|
+
if mode not in ("sandbox", "live"):
|
|
73
|
+
raise RuntimeError("ACCESS402_MODE must be either sandbox or live")
|
|
66
74
|
return cls(
|
|
67
75
|
installation_id=required["ACCESS402_INSTALLATION_ID"],
|
|
68
76
|
api_key=required["ACCESS402_API_KEY"],
|
|
69
77
|
public_base_url=required["ACCESS402_PUBLIC_BASE_URL"].rstrip("/"),
|
|
78
|
+
mode=mode,
|
|
70
79
|
api_base_url=os.environ.get("ACCESS402_API_BASE_URL", cls.api_base_url).rstrip("/"),
|
|
71
80
|
refresh_seconds=int(os.environ.get("ACCESS402_REFRESH_SECONDS", "60")),
|
|
72
81
|
catalog_refresh_seconds=int(os.environ.get("ACCESS402_CATALOG_REFRESH_SECONDS", "300")),
|
|
73
82
|
stale_after_seconds=int(os.environ.get("ACCESS402_STALE_AFTER_SECONDS", "900")),
|
|
74
|
-
allow_live=os.environ.get("ACCESS402_ALLOW_LIVE", "false").lower() == "true",
|
|
75
83
|
cors_allow_origin=os.environ.get("ACCESS402_CORS_ALLOW_ORIGIN", "*"),
|
|
76
84
|
)
|
|
77
85
|
|
|
@@ -101,6 +109,8 @@ class Access402:
|
|
|
101
109
|
description: str | None = None,
|
|
102
110
|
discovery_enabled: bool = False,
|
|
103
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,
|
|
104
114
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
105
115
|
"""Declare an Access402 payment policy on a FastAPI endpoint."""
|
|
106
116
|
try:
|
|
@@ -120,6 +130,12 @@ class Access402:
|
|
|
120
130
|
raise ValueError("Access402 description must be 1 to 4000 characters")
|
|
121
131
|
if not isinstance(discovery_input_example or {}, dict):
|
|
122
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")
|
|
123
139
|
policy = CodePolicy(
|
|
124
140
|
price=format(normalized_price, "f"),
|
|
125
141
|
access_type=access_type,
|
|
@@ -127,6 +143,8 @@ class Access402:
|
|
|
127
143
|
description=description.strip() if description else None,
|
|
128
144
|
discovery_enabled=discovery_enabled,
|
|
129
145
|
discovery_input_example=discovery_input_example or {},
|
|
146
|
+
representative_queries=normalized_queries,
|
|
147
|
+
capabilities=normalized_capabilities,
|
|
130
148
|
)
|
|
131
149
|
|
|
132
150
|
def decorator(endpoint: Callable[..., Any]) -> Callable[..., Any]:
|
|
@@ -140,9 +158,47 @@ class Access402:
|
|
|
140
158
|
# Build OpenAPI once at installation so invalid code-owned policy combinations
|
|
141
159
|
# fail during application startup instead of on the first paid request.
|
|
142
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")
|
|
143
168
|
app.router.add_event_handler("startup", self._startup_sync)
|
|
144
169
|
app.add_middleware(Access402Middleware, manager=self)
|
|
145
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
|
+
|
|
146
202
|
async def _startup_sync(self) -> None:
|
|
147
203
|
try:
|
|
148
204
|
await self.refresh(force=True)
|
|
@@ -211,11 +267,12 @@ class Access402:
|
|
|
211
267
|
if self.configuration and not force and now - self._loaded_at < self.settings.refresh_seconds:
|
|
212
268
|
return self.configuration
|
|
213
269
|
if not self._catalog_at or now - self._catalog_at >= self.settings.catalog_refresh_seconds:
|
|
214
|
-
await self.client.sync_catalog(self.route_catalog())
|
|
270
|
+
await self.client.sync_catalog(self.route_catalog(), self.settings.mode)
|
|
215
271
|
self._catalog_at = now
|
|
216
|
-
configuration = await self.client.configuration()
|
|
217
|
-
if
|
|
218
|
-
|
|
272
|
+
configuration = await self.client.configuration(self.settings.mode)
|
|
273
|
+
expected_environment = "live" if self.settings.mode == "live" else "test"
|
|
274
|
+
if configuration.environment != expected_environment:
|
|
275
|
+
raise RuntimeError("Access402 returned configuration for the wrong payment mode")
|
|
219
276
|
self.configuration = configuration
|
|
220
277
|
self._loaded_at = now
|
|
221
278
|
return configuration
|
|
@@ -300,6 +357,44 @@ class Access402Middleware:
|
|
|
300
357
|
return
|
|
301
358
|
await self.app(scope, receive, send)
|
|
302
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
|
+
|
|
303
398
|
if self.manager.bypass:
|
|
304
399
|
result = self.manager.bypass(scope)
|
|
305
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
|
+
}
|
|
@@ -20,21 +20,21 @@ class Access402Client:
|
|
|
20
20
|
def headers(self, mode: str = "sandbox") -> dict[str, str]:
|
|
21
21
|
return {
|
|
22
22
|
"Authorization": f"Bearer {self.api_key}", "Accept": "application/json",
|
|
23
|
-
"X-Access402-Adapter-Version": "
|
|
24
|
-
"User-Agent": "Access402-FastAPI/0.
|
|
23
|
+
"X-Access402-Adapter-Version": "3", "X-Access402-Mode": mode,
|
|
24
|
+
"User-Agent": "Access402-FastAPI/0.4.0",
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
async def close(self) -> None:
|
|
28
28
|
await self.http.aclose()
|
|
29
29
|
|
|
30
|
-
async def sync_catalog(self, routes: list[dict[str, Any]]) -> str:
|
|
31
|
-
response = await self.http.put(f"{self.base_url}/adapter/catalog", headers=self.headers(), json={"routes": routes})
|
|
30
|
+
async def sync_catalog(self, routes: list[dict[str, Any]], mode: str) -> str:
|
|
31
|
+
response = await self.http.put(f"{self.base_url}/adapter/catalog", headers=self.headers(mode), json={"routes": routes})
|
|
32
32
|
response.raise_for_status()
|
|
33
33
|
value = response.json()
|
|
34
34
|
return str(value["configuration_version"])
|
|
35
35
|
|
|
36
|
-
async def configuration(self) -> Configuration:
|
|
37
|
-
response = await self.http.get(f"{self.base_url}/adapter/configuration", headers=self.headers())
|
|
36
|
+
async def configuration(self, mode: str) -> Configuration:
|
|
37
|
+
response = await self.http.get(f"{self.base_url}/adapter/configuration", headers=self.headers(mode))
|
|
38
38
|
response.raise_for_status()
|
|
39
39
|
verify_configuration_signature(self.api_key, response.content, response.headers.get("X-Access402-Configuration-Signature"))
|
|
40
40
|
value = response.json()
|
|
@@ -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,12 +31,14 @@ 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"})
|
|
37
39
|
|
|
38
40
|
app = FastAPI()
|
|
39
|
-
settings = Access402Settings("installation", "a402_000000000000_" + "a" * 43, "https://api.example.com"
|
|
41
|
+
settings = Access402Settings("installation", "a402_000000000000_" + "a" * 43, "https://api.example.com")
|
|
40
42
|
manager = Access402(settings, transport=httpx.MockTransport(backend))
|
|
41
43
|
@app.get("/reports/{report_id}")
|
|
42
44
|
@manager.protect(price="0.01")
|
|
@@ -168,6 +170,87 @@ 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
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_environment_requires_an_explicit_valid_payment_mode(monkeypatch):
|
|
210
|
+
values = {
|
|
211
|
+
"ACCESS402_INSTALLATION_ID": "installation",
|
|
212
|
+
"ACCESS402_API_KEY": "a402_000000000000_" + "a" * 43,
|
|
213
|
+
"ACCESS402_PUBLIC_BASE_URL": "https://api.example.com",
|
|
214
|
+
}
|
|
215
|
+
for name, value in values.items():
|
|
216
|
+
monkeypatch.setenv(name, value)
|
|
217
|
+
monkeypatch.delenv("ACCESS402_MODE", raising=False)
|
|
218
|
+
with pytest.raises(RuntimeError, match="ACCESS402_MODE"):
|
|
219
|
+
Access402Settings.from_env()
|
|
220
|
+
monkeypatch.setenv("ACCESS402_MODE", "production")
|
|
221
|
+
with pytest.raises(RuntimeError, match="sandbox or live"):
|
|
222
|
+
Access402Settings.from_env()
|
|
223
|
+
monkeypatch.setenv("ACCESS402_MODE", "live")
|
|
224
|
+
assert Access402Settings.from_env().mode == "live"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.asyncio
|
|
228
|
+
async def test_live_mode_is_sent_by_the_deployment_for_catalog_and_configuration():
|
|
229
|
+
api_key = "a402_000000000000_" + "a" * 43
|
|
230
|
+
seen_modes = []
|
|
231
|
+
|
|
232
|
+
async def handler(request):
|
|
233
|
+
seen_modes.append(request.headers.get("X-Access402-Mode"))
|
|
234
|
+
if request.url.path.endswith("/adapter/catalog"):
|
|
235
|
+
return httpx.Response(200, json={"configuration_version": "v1"})
|
|
236
|
+
body = json.dumps({
|
|
237
|
+
"x402_version": 2, "installation_id": "installation", "configuration_version": "v1",
|
|
238
|
+
"generated_at": "now", "environment": "live", "network": "eip155:8453",
|
|
239
|
+
"pay_to": "0x0000000000000000000000000000000000000001", "resources": [],
|
|
240
|
+
}, separators=(",", ":")).encode()
|
|
241
|
+
signature = base64.urlsafe_b64encode(hmac.new(api_key.encode(), body, hashlib.sha256).digest()).decode().rstrip("=")
|
|
242
|
+
return httpx.Response(200, content=body, headers={"X-Access402-Configuration-Signature": f"v1={signature}"})
|
|
243
|
+
|
|
244
|
+
app = FastAPI()
|
|
245
|
+
manager = Access402(
|
|
246
|
+
Access402Settings("installation", api_key, "https://api.example.com", mode="live"),
|
|
247
|
+
transport=httpx.MockTransport(handler),
|
|
248
|
+
)
|
|
249
|
+
manager.install(app)
|
|
250
|
+
configuration = await manager.refresh(force=True)
|
|
251
|
+
await manager.close()
|
|
252
|
+
assert configuration.environment == "live"
|
|
253
|
+
assert seen_modes == ["live", "live"]
|
|
171
254
|
|
|
172
255
|
|
|
173
256
|
@pytest.mark.asyncio
|
|
@@ -193,3 +276,30 @@ async def test_configuration_rejects_the_wrong_installation_scope():
|
|
|
193
276
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as client:
|
|
194
277
|
response = await client.get("/route")
|
|
195
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|