feat(python): add route metadata paywall config#209
Conversation
| setattr(request.state, PAYMENT_ATTR, verified) | ||
| if verified.settlement_headers: | ||
| setattr(request.state, _SETTLEMENT_STATE_ATTR, dict(verified.settlement_headers)) | ||
|
|
||
| response = await call_next(request) | ||
| for name, value in verified.settlement_headers.items(): | ||
| response.headers[name] = value |
There was a problem hiding this comment.
Redundant settlement-header write in
_paykit_paywall
install_paywall_from_config calls install_exception_handler, which registers the _paykit_settlement_headers inner middleware that reads _SETTLEMENT_STATE_ATTR and writes the settlement headers to the response. The paywall middleware also writes those same headers directly after call_next on lines 386-388. Because _paykit_settlement_headers executes inside call_next, the response returned to _paykit_paywall already carries the settlement headers; the loop on lines 386-388 writes them again to the same keys with the same values.
The assignment is idempotent, so the final response is correct, but the direct write is dead code once _SETTLEMENT_STATE_ATTR is set. Removing lines 386-388 and relying solely on _SETTLEMENT_STATE_ATTR would keep the logic consistent with how RequirePayment (the Depends-style gate) handles settlement headers.
| no_preflight = environ.get(f"{env_prefix}NO_PREFLIGHT") | ||
| if no_preflight is not None and _parse_bool_env(f"{env_prefix}NO_PREFLIGHT", no_preflight): | ||
| values["preflight"] = False |
There was a problem hiding this comment.
NO_PREFLIGHT=false is silently ignored
The condition if no_preflight is not None and _parse_bool_env(...) short-circuits when the env var is set to a falsy value like "false". If a mapping already has preflight=False and a caller sets {prefix}NO_PREFLIGHT=false expecting to re-enable preflight, the assignment never happens. Using {prefix}PREFLIGHT=true does work, so there is a workaround, but the asymmetry is surprising and worth fixing by handling NO_PREFLIGHT=false explicitly as a preflight-enable signal.
| no_preflight = environ.get(f"{env_prefix}NO_PREFLIGHT") | |
| if no_preflight is not None and _parse_bool_env(f"{env_prefix}NO_PREFLIGHT", no_preflight): | |
| values["preflight"] = False | |
| no_preflight = environ.get(f"{env_prefix}NO_PREFLIGHT") | |
| if no_preflight is not None: | |
| values["preflight"] = not _parse_bool_env(f"{env_prefix}NO_PREFLIGHT", no_preflight) |
| def install_paywall( | ||
| app: Any, | ||
| config: PayConfig | Mapping[str, Any], | ||
| *, | ||
| env_prefix: str | None = None, | ||
| default_policy: PaywallDefaultPolicy = "public", | ||
| paid_tags: tuple[str, ...] = ("paid", "pay"), | ||
| public_tags: tuple[str, ...] = ("public", "free"), | ||
| cors_origins: Sequence[str] | None = ("*",), | ||
| ) -> None: | ||
| """Install a route-metadata paywall from app-level Pay settings. | ||
|
|
||
| ``config`` may be a :class:`~solana_pay_kit.config.PayConfig` or a plain | ||
| mapping loaded from TOML/YAML/env. Disabled configs are a no-op. | ||
| """ | ||
| from solana_pay_kit.config import PayConfig as _PayConfig | ||
|
|
||
| pay_config = _PayConfig.from_sources(config, env_prefix=env_prefix) | ||
| if not pay_config.enabled: | ||
| return | ||
|
|
||
| install_paywall_from_config( | ||
| app, | ||
| PaywallConfig( | ||
| gate_ref=pay_config.gate_ref(), | ||
| config=pay_config.configure(), | ||
| default_policy=default_policy, | ||
| paid_tags=paid_tags, | ||
| public_tags=public_tags, | ||
| ), | ||
| cors_origins=cors_origins, | ||
| ) |
There was a problem hiding this comment.
install_paywall() silently replaces the global config
pay_config.configure() calls the module-level configure() function, which overwrites the global _config singleton. Because PayConfig has no fields for mpp or x402 sub-configs, any MppConfig(challenge_binding_secret=...) or X402Config(facilitator_url=...) set by a prior explicit configure() call is permanently dropped. The paywall middleware itself does not require this global side effect — it already receives the resolved Config via PaywallConfig(config=...) — but callers who combine a direct configure() call with install_paywall() will lose those settings silently.
Consider documenting that install_paywall() is the sole configuration entry-point (must not be mixed with a prior configure()) or exposing mpp/x402 passthrough on PayConfig.
Summary
PayConfigas an app-level Python SDK config object for pay-gated route surfaces.PayConfig.from_sources(...)andinstall_paywall(..., env_prefix=...)so host apps can pass config mappings and env prefixes through the SDK.0.3.0.Test Plan
uv run pyright src/solana_pay_kit/config.py src/solana_pay_kit/fastapi.py tests/test_pk_config.py tests/test_pk_fastapi_paywall.pyuv run ruff check src/solana_pay_kit/config.py src/solana_pay_kit/fastapi.py src/solana_pay_kit/__init__.py tests/test_pk_config.py tests/test_pk_fastapi_paywall.pyuv run pytest tests/test_pk_config.py tests/test_pk_fastapi_paywall.pyuv lock