access402-fastapi 0.2.0__tar.gz → 0.3.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
1
  Metadata-Version: 2.4
2
2
  Name: access402-fastapi
3
- Version: 0.2.0
3
+ Version: 0.3.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.
@@ -73,13 +73,13 @@ access402.install(app)
73
73
 
74
74
  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
75
 
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.
76
+ 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
77
 
78
78
  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
79
 
80
80
  `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
81
 
82
- Live mode requires both the dashboard toggle and `ACCESS402_ALLOW_LIVE=true`. This second switch is a deployment safety ceiling, not another credential.
82
+ 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
83
 
84
84
  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
85
 
@@ -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.
@@ -44,13 +44,13 @@ access402.install(app)
44
44
 
45
45
  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
46
 
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.
47
+ 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
48
 
49
49
  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
50
 
51
51
  `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
52
 
53
- Live mode requires both the dashboard toggle and `ACCESS402_ALLOW_LIVE=true`. This second switch is a deployment safety ceiling, not another credential.
53
+ 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
54
 
55
55
  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
56
 
@@ -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.3.0"
8
8
  description = "Managed Access402 x402 v2 protection for FastAPI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -45,11 +45,11 @@ class Access402Settings:
45
45
  installation_id: str
46
46
  api_key: str
47
47
  public_base_url: str
48
+ mode: str = "sandbox"
48
49
  api_base_url: str = "https://sfowjygkbsubaeggmckp.supabase.co/functions/v1"
49
50
  refresh_seconds: int = 60
50
51
  catalog_refresh_seconds: int = 300
51
52
  stale_after_seconds: int = 900
52
- allow_live: bool = False
53
53
  fail_closed: bool = True
54
54
  cors_allow_origin: str = "*"
55
55
  excluded_paths: tuple[str, ...] = ("/health", "/docs", "/redoc", "/openapi.json")
@@ -58,20 +58,23 @@ class Access402Settings:
58
58
  def from_env(cls) -> "Access402Settings":
59
59
  required = {
60
60
  name: os.environ.get(name, "").strip()
61
- for name in ("ACCESS402_INSTALLATION_ID", "ACCESS402_API_KEY", "ACCESS402_PUBLIC_BASE_URL")
61
+ for name in ("ACCESS402_INSTALLATION_ID", "ACCESS402_API_KEY", "ACCESS402_PUBLIC_BASE_URL", "ACCESS402_MODE")
62
62
  }
63
63
  missing = [name for name, value in required.items() if not value]
64
64
  if missing:
65
65
  raise RuntimeError(f"Missing Access402 environment variables: {', '.join(missing)}")
66
+ mode = required["ACCESS402_MODE"].lower()
67
+ if mode not in ("sandbox", "live"):
68
+ raise RuntimeError("ACCESS402_MODE must be either sandbox or live")
66
69
  return cls(
67
70
  installation_id=required["ACCESS402_INSTALLATION_ID"],
68
71
  api_key=required["ACCESS402_API_KEY"],
69
72
  public_base_url=required["ACCESS402_PUBLIC_BASE_URL"].rstrip("/"),
73
+ mode=mode,
70
74
  api_base_url=os.environ.get("ACCESS402_API_BASE_URL", cls.api_base_url).rstrip("/"),
71
75
  refresh_seconds=int(os.environ.get("ACCESS402_REFRESH_SECONDS", "60")),
72
76
  catalog_refresh_seconds=int(os.environ.get("ACCESS402_CATALOG_REFRESH_SECONDS", "300")),
73
77
  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
78
  cors_allow_origin=os.environ.get("ACCESS402_CORS_ALLOW_ORIGIN", "*"),
76
79
  )
77
80
 
@@ -211,11 +214,12 @@ class Access402:
211
214
  if self.configuration and not force and now - self._loaded_at < self.settings.refresh_seconds:
212
215
  return self.configuration
213
216
  if not self._catalog_at or now - self._catalog_at >= self.settings.catalog_refresh_seconds:
214
- await self.client.sync_catalog(self.route_catalog())
217
+ await self.client.sync_catalog(self.route_catalog(), self.settings.mode)
215
218
  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")
219
+ configuration = await self.client.configuration(self.settings.mode)
220
+ expected_environment = "live" if self.settings.mode == "live" else "test"
221
+ if configuration.environment != expected_environment:
222
+ raise RuntimeError("Access402 returned configuration for the wrong payment mode")
219
223
  self.configuration = configuration
220
224
  self._loaded_at = now
221
225
  return configuration
@@ -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.3.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()
@@ -36,7 +36,7 @@ def configured_app(handler=None, **policy_changes):
36
36
  return httpx.Response(200, json={"settled": True, "payer": "0xpayer", "payment_event_id": "33333333-3333-4333-8333-333333333333", "payment_response": "receipt"})
37
37
 
38
38
  app = FastAPI()
39
- settings = Access402Settings("installation", "a402_000000000000_" + "a" * 43, "https://api.example.com", allow_live=True)
39
+ settings = Access402Settings("installation", "a402_000000000000_" + "a" * 43, "https://api.example.com")
40
40
  manager = Access402(settings, transport=httpx.MockTransport(backend))
41
41
  @app.get("/reports/{report_id}")
42
42
  @manager.protect(price="0.01")
@@ -170,6 +170,53 @@ def test_rejects_invalid_code_owned_policy():
170
170
  manager.protect(price="0.01", access_type="time_limited")
171
171
 
172
172
 
173
+ def test_environment_requires_an_explicit_valid_payment_mode(monkeypatch):
174
+ values = {
175
+ "ACCESS402_INSTALLATION_ID": "installation",
176
+ "ACCESS402_API_KEY": "a402_000000000000_" + "a" * 43,
177
+ "ACCESS402_PUBLIC_BASE_URL": "https://api.example.com",
178
+ }
179
+ for name, value in values.items():
180
+ monkeypatch.setenv(name, value)
181
+ monkeypatch.delenv("ACCESS402_MODE", raising=False)
182
+ with pytest.raises(RuntimeError, match="ACCESS402_MODE"):
183
+ Access402Settings.from_env()
184
+ monkeypatch.setenv("ACCESS402_MODE", "production")
185
+ with pytest.raises(RuntimeError, match="sandbox or live"):
186
+ Access402Settings.from_env()
187
+ monkeypatch.setenv("ACCESS402_MODE", "live")
188
+ assert Access402Settings.from_env().mode == "live"
189
+
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_live_mode_is_sent_by_the_deployment_for_catalog_and_configuration():
193
+ api_key = "a402_000000000000_" + "a" * 43
194
+ seen_modes = []
195
+
196
+ async def handler(request):
197
+ seen_modes.append(request.headers.get("X-Access402-Mode"))
198
+ if request.url.path.endswith("/adapter/catalog"):
199
+ return httpx.Response(200, json={"configuration_version": "v1"})
200
+ body = json.dumps({
201
+ "x402_version": 2, "installation_id": "installation", "configuration_version": "v1",
202
+ "generated_at": "now", "environment": "live", "network": "eip155:8453",
203
+ "pay_to": "0x0000000000000000000000000000000000000001", "resources": [],
204
+ }, separators=(",", ":")).encode()
205
+ signature = base64.urlsafe_b64encode(hmac.new(api_key.encode(), body, hashlib.sha256).digest()).decode().rstrip("=")
206
+ return httpx.Response(200, content=body, headers={"X-Access402-Configuration-Signature": f"v1={signature}"})
207
+
208
+ app = FastAPI()
209
+ manager = Access402(
210
+ Access402Settings("installation", api_key, "https://api.example.com", mode="live"),
211
+ transport=httpx.MockTransport(handler),
212
+ )
213
+ manager.install(app)
214
+ configuration = await manager.refresh(force=True)
215
+ await manager.close()
216
+ assert configuration.environment == "live"
217
+ assert seen_modes == ["live", "live"]
218
+
219
+
173
220
  @pytest.mark.asyncio
174
221
  async def test_configuration_rejects_the_wrong_installation_scope():
175
222
  api_key = "a402_000000000000_" + "a" * 43