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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: access402-fastapi
3
- Version: 0.2.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
- ACCESS402_ALLOW_LIVE=false
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. It controls the installation payment environment, while protected routes fail closed if Access402 cannot confirm a matching server-side policy.
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
- Live mode requires both the dashboard toggle and `ACCESS402_ALLOW_LIVE=true`. This second switch is a deployment safety ceiling, not another credential.
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
- ACCESS402_ALLOW_LIVE=false
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. It controls the installation payment environment, while protected routes fail closed if Access402 cannot confirm a matching server-side policy.
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
- Live mode requires both the dashboard toggle and `ACCESS402_ALLOW_LIVE=true`. This second switch is a deployment safety ceiling, not another credential.
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "access402-fastapi"
7
- version = "0.2.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)
@@ -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 configuration.environment == "live" and not self.settings.allow_live:
218
- raise RuntimeError("Live settlement is disabled by ACCESS402_ALLOW_LIVE")
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": "2", "X-Access402-Mode": mode,
24
- "User-Agent": "Access402-FastAPI/0.2.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", allow_live=True)
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()