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.
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/PKG-INFO +4 -4
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/README.md +3 -3
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/pyproject.toml +1 -1
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/src/access402_fastapi/adapter.py +11 -7
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/src/access402_fastapi/client.py +6 -6
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/tests/test_adapter.py +48 -1
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/.gitignore +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/src/access402_fastapi/__init__.py +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/src/access402_fastapi/models.py +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/src/access402_fastapi/py.typed +0 -0
- {access402_fastapi-0.2.0 → access402_fastapi-0.3.0}/src/access402_fastapi/security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: access402-fastapi
|
|
3
|
-
Version: 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
218
|
-
|
|
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": "
|
|
24
|
-
"User-Agent": "Access402-FastAPI/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"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|