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.
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/PKG-INFO +11 -2
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/README.md +9 -0
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/pyproject.toml +1 -1
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/src/access402_fastapi/adapter.py +93 -2
- access402_fastapi-0.4.0/src/access402_fastapi/ard.py +51 -0
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/src/access402_fastapi/client.py +13 -1
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/src/access402_fastapi/models.py +6 -0
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/tests/test_adapter.py +63 -0
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/.gitignore +0 -0
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/src/access402_fastapi/__init__.py +0 -0
- {access402_fastapi-0.3.0 → access402_fastapi-0.4.0}/src/access402_fastapi/py.typed +0 -0
- {access402_fastapi-0.3.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
|
|
@@ -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.
|
|
@@ -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.
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|