Skip to content

feat(python): add route metadata paywall config#209

Open
lgalabru wants to merge 2 commits into
chore/pay-kit-0-4-0from
feat/python-paywall-config
Open

feat(python): add route metadata paywall config#209
lgalabru wants to merge 2 commits into
chore/pay-kit-0-4-0from
feat/python-paywall-config

Conversation

@lgalabru

@lgalabru lgalabru commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds PayConfig as an app-level Python SDK config object for pay-gated route surfaces.
  • Adds FastAPI route-metadata paywall helpers so apps can gate routes by decorators/tags/default policy without duplicating endpoint paths.
  • Adds PayConfig.from_sources(...) and install_paywall(..., env_prefix=...) so host apps can pass config mappings and env prefixes through the SDK.
  • Bumps the Python package minor version to 0.3.0.
  • Documents the FastAPI paywall pattern and adds focused Python tests.

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.py
  • uv 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.py
  • uv run pytest tests/test_pk_config.py tests/test_pk_fastapi_paywall.py
  • uv lock

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Django/DRF-style route-metadata paywall for FastAPI to the Python SDK, alongside a new PayConfig app-level config object that can be loaded from mappings and env vars, and bumps the package to 0.3.0.

  • PayConfig + from_sources(): a frozen Pydantic model that merges a mapping with env-var overrides ({prefix}NETWORK, {prefix}NO_PREFLIGHT, etc.) and exposes .configure() / .gate_ref() for bootstrapping the SDK.
  • install_paywall_from_config() / install_paywall(): Starlette HTTP middleware that inspects the route table at request time and gates routes based on @pay_required() / @pay_not_required() decorators, FastAPI tags, or a configurable default policy (\"public\" or \"paid\").
  • Tests: focused unit + integration tests for env-override merging, bool-env validation, and paywall middleware behavior across the public/paid policy modes.

Confidence Score: 4/5

Safe to merge; all three findings are non-blocking quality issues that do not affect correctness of the happy path.

The paywall middleware and PayConfig machinery work correctly end-to-end. The redundant settlement-header write is harmless since header assignment is idempotent. The NO_PREFLIGHT=false no-op could silently confuse operators who expect to re-enable preflight via that flag. The global-config replacement in install_paywall could drop previously configured mpp/x402 sub-settings if both configuration patterns are accidentally mixed, but the intended usage is to pick one pattern exclusively.

python/src/solana_pay_kit/config.py (NO_PREFLIGHT=false handling) and python/src/solana_pay_kit/fastapi.py (redundant settlement-header write and global-config side effect in install_paywall)

Important Files Changed

Filename Overview
python/src/solana_pay_kit/config.py Adds PayConfig model with from_sources() env-merge helper; _apply_pay_config_env has a subtle NO_PREFLIGHT=false no-op where setting the flag to false is silently ignored instead of re-enabling preflight.
python/src/solana_pay_kit/fastapi.py Adds PaywallConfig, pay_required/pay_not_required decorators, install_paywall_from_config and install_paywall; settlement headers are written twice in _paykit_paywall; install_paywall silently replaces the global Config singleton, stripping any mpp/x402 sub-configs previously set.
python/tests/test_pk_fastapi_paywall.py New test file covering public/paid default policies, tag-based gating, pay_not_required, success settlement header propagation, and install_paywall mapping + env-override path.
python/tests/test_pk_config.py Adds three PayConfig tests: wire-value coercion + configure(), env override via from_sources(), and invalid bool env rejection; no test for NO_PREFLIGHT=false behavior.
python/src/solana_pay_kit/init.py Exports PayConfig from the top-level package; change is minimal and correct.
python/pyproject.toml Version bumped from 0.2.0 to 0.3.0; no other dependency changes.
python/README.md Documents the FastAPI paywall pattern with install_paywall_from_config and the default_policy="paid" Django-style mode; accurate and consistent with the implementation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant _paykit_paywall as _paykit_paywall (outer MW)
    participant _paykit_settlement_headers as _paykit_settlement_headers (inner MW)
    participant Handler

    Client->>_paykit_paywall: HTTP request
    _paykit_paywall->>_paykit_paywall: _matched_route(app, request)
    _paykit_paywall->>_paykit_paywall: _paywall_requirement(paywall, route)
    alt requirement is None (free route)
        _paykit_paywall->>_paykit_settlement_headers: call_next(request)
        _paykit_settlement_headers->>Handler: call_next(request)
        Handler-->>_paykit_settlement_headers: response
        _paykit_settlement_headers-->>_paykit_paywall: response
        _paykit_paywall-->>Client: response
    else route is pay-gated
        _paykit_paywall->>_paykit_paywall: core.process(gate_ref, pricing, request)
        alt payment valid
            _paykit_paywall->>_paykit_paywall: set PAYMENT_ATTR + _SETTLEMENT_STATE_ATTR
            _paykit_paywall->>_paykit_settlement_headers: call_next(request)
            _paykit_settlement_headers->>Handler: call_next(request)
            Handler-->>_paykit_settlement_headers: response
            _paykit_settlement_headers->>_paykit_settlement_headers: read _SETTLEMENT_STATE_ATTR → write headers [1st write]
            _paykit_settlement_headers-->>_paykit_paywall: response (headers set)
            _paykit_paywall->>_paykit_paywall: write settlement headers again [2nd write — redundant]
            _paykit_paywall-->>Client: response
        else payment invalid (PayKitError)
            _paykit_paywall-->>Client: JSONResponse 402 + challenge headers
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant _paykit_paywall as _paykit_paywall (outer MW)
    participant _paykit_settlement_headers as _paykit_settlement_headers (inner MW)
    participant Handler

    Client->>_paykit_paywall: HTTP request
    _paykit_paywall->>_paykit_paywall: _matched_route(app, request)
    _paykit_paywall->>_paykit_paywall: _paywall_requirement(paywall, route)
    alt requirement is None (free route)
        _paykit_paywall->>_paykit_settlement_headers: call_next(request)
        _paykit_settlement_headers->>Handler: call_next(request)
        Handler-->>_paykit_settlement_headers: response
        _paykit_settlement_headers-->>_paykit_paywall: response
        _paykit_paywall-->>Client: response
    else route is pay-gated
        _paykit_paywall->>_paykit_paywall: core.process(gate_ref, pricing, request)
        alt payment valid
            _paykit_paywall->>_paykit_paywall: set PAYMENT_ATTR + _SETTLEMENT_STATE_ATTR
            _paykit_paywall->>_paykit_settlement_headers: call_next(request)
            _paykit_settlement_headers->>Handler: call_next(request)
            Handler-->>_paykit_settlement_headers: response
            _paykit_settlement_headers->>_paykit_settlement_headers: read _SETTLEMENT_STATE_ATTR → write headers [1st write]
            _paykit_settlement_headers-->>_paykit_paywall: response (headers set)
            _paykit_paywall->>_paykit_paywall: write settlement headers again [2nd write — redundant]
            _paykit_paywall-->>Client: response
        else payment invalid (PayKitError)
            _paykit_paywall-->>Client: JSONResponse 402 + challenge headers
        end
    end
Loading

Reviews (1): Last reviewed commit: "chore(python): bump version to 0.3.0" | Re-trigger Greptile

Comment on lines +382 to +388
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +266 to +268
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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)

Comment on lines +392 to +423
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,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant