Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/merry-dogs-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added `RedisStore` and `SQLiteStore` backends to `mpp.stores` for replay protection, with optional extras (`pympp[redis]`, `pympp[sqlite]`). Added `store` parameter to `Mpp.__init__` and `Mpp.create()` that automatically wires the store into intents supporting replay protection.
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 10

tempo:
image: ghcr.io/tempoxyz/tempo:latest
ports:
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ tempo = [
"pydantic>=2.0",
]
server = ["pydantic>=2.0", "python-dotenv>=1.0"]
redis = ["redis>=5.0"]
sqlite = ["aiosqlite>=0.20"]
mcp = ["mcp>=1.1.0"]
dev = [
"pytest>=8.0",
Expand All @@ -47,6 +49,8 @@ dev = [
"pyright>=1.1",
"build>=1.0",
"twine>=6.0",
"aiosqlite>=0.20",
"redis>=5.0",
]

[build-system]
Expand Down Expand Up @@ -79,4 +83,7 @@ include = ["src", "tests"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = ["integration: requires TEMPO_RPC_URL (real Tempo node)"]
markers = [
"integration: requires TEMPO_RPC_URL (real Tempo node)",
"redis: requires REDIS_URL (real Redis instance)",
]
1 change: 1 addition & 0 deletions src/mpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,5 @@ def success(

from . import _body_digest as BodyDigest # noqa: E402
from . import _expires as Expires # noqa: E402
from . import stores # noqa: E402
from .store import MemoryStore, Store # noqa: E402
21 changes: 21 additions & 0 deletions src/mpp/server/mpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from mpp.server.decorator import wrap_payment_handler
from mpp.server.method import transform_request
from mpp.server.verify import verify_or_challenge
from mpp.store import Store

if TYPE_CHECKING:
from mpp.server.method import Method
Expand Down Expand Up @@ -58,6 +59,7 @@ def __init__(
realm: str,
secret_key: str,
defaults: dict[str, Any] | None = None,
store: Store | None = None,
) -> None:
"""Initialize the payment handler.

Expand All @@ -67,30 +69,49 @@ def __init__(
secret_key: Server secret for HMAC-bound challenge IDs.
Enables stateless challenge verification.
defaults: Default request values merged with per-call request params.
store: Optional key-value store for replay protection.
When provided, automatically wired into intents that
accept a ``store`` (e.g., ``ChargeIntent``).
"""
self.method = method
self.realm = realm
self.secret_key = secret_key
self.defaults = defaults or {}

if store is not None:
self._wire_store(store)

def _wire_store(self, store: Store) -> None:
"""Inject *store* into intents that have a ``_store`` attribute set to None."""
intents = getattr(self.method, "intents", None)
if not isinstance(intents, dict):
return
for intent_obj in intents.values():
if hasattr(intent_obj, "_store") and intent_obj._store is None:
intent_obj._store = store

@classmethod
def create(
cls,
method: Method,
realm: str | None = None,
secret_key: str | None = None,
store: Store | None = None,
) -> Mpp:
"""Create an Mpp instance with smart defaults.

Args:
method: Payment method (e.g., tempo(currency=..., recipient=...)).
realm: Server realm. Auto-detected from environment if omitted.
secret_key: HMAC secret. Required unless `MPP_SECRET_KEY` is set.
store: Optional key-value store for replay protection.
Automatically wired into intents that accept a store.
"""
return cls(
method=method,
realm=detect_realm() if realm is None else realm,
secret_key=detect_secret_key() if secret_key is None else secret_key,
store=store,
)

async def charge(
Expand Down
24 changes: 24 additions & 0 deletions src/mpp/stores/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Concrete store backends for replay protection.

Available backends:

- ``MemoryStore`` – in-memory ``dict``, for development/testing.
- ``RedisStore`` – Redis/Valkey, for multi-instance production deployments.
- ``SQLiteStore`` – local SQLite file, for single-instance production deployments.
"""

from mpp.store import MemoryStore

__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"]

Check warning on line 12 in src/mpp/stores/__init__.py

View workflow job for this annotation

GitHub Actions / test (3.12)

"SQLiteStore" is specified in __all__ but is not present in module (reportUnsupportedDunderAll)

Check warning on line 12 in src/mpp/stores/__init__.py

View workflow job for this annotation

GitHub Actions / test (3.12)

"RedisStore" is specified in __all__ but is not present in module (reportUnsupportedDunderAll)


def __getattr__(name: str): # type: ignore[reportReturnType]
if name == "RedisStore":
from mpp.stores.redis import RedisStore

return RedisStore
if name == "SQLiteStore":
from mpp.stores.sqlite import SQLiteStore

return SQLiteStore
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
60 changes: 60 additions & 0 deletions src/mpp/stores/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Redis-backed store for multi-instance deployments.

Uses ``redis-py`` (``redis.asyncio``) as the async driver. Install with::

pip install pympp[redis]

Example::

from redis.asyncio import from_url
from mpp.stores import RedisStore

store = RedisStore(await from_url("redis://localhost:6379"))
"""

from __future__ import annotations

from typing import Any


class RedisStore:
"""Async key-value store backed by Redis.

Each key is prefixed with ``key_prefix`` (default ``"mpp:"``) and
automatically expires after ``ttl_seconds`` (default 300 — 5 minutes).

``put_if_absent`` maps to ``SET key value NX EX ttl`` — a single atomic
Redis command with no TOCTOU race.
"""

def __init__(
self,
client: Any,
*,
key_prefix: str = "mpp:",
ttl_seconds: int = 300,
) -> None:
self._redis = client
self._prefix = key_prefix
self._ttl = ttl_seconds

def _key(self, key: str) -> str:
return f"{self._prefix}{key}"

async def get(self, key: str) -> Any | None:
return await self._redis.get(self._key(key))

async def put(self, key: str, value: Any) -> None:
await self._redis.set(self._key(key), value, ex=self._ttl)

async def delete(self, key: str) -> None:
await self._redis.delete(self._key(key))

async def put_if_absent(self, key: str, value: Any) -> bool:
"""Atomic ``SETNX`` with TTL.

Returns ``True`` when the key was new and the write succeeded,
``False`` when the key already existed (duplicate).
"""
result = await self._redis.set(self._key(key), value, nx=True, ex=self._ttl)
return result is not None
118 changes: 118 additions & 0 deletions src/mpp/stores/sqlite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""SQLite-backed store for single-instance production deployments.

Uses ``aiosqlite`` for async access to Python's built-in ``sqlite3``.
Install with::

pip install pympp[sqlite]

Example::

from mpp.stores import SQLiteStore

store = await SQLiteStore.create("mpp.db")
"""

from __future__ import annotations

import time
from typing import Any


class SQLiteStore:
"""Async key-value store backed by a local SQLite file.

Keys are stored in a ``kv`` table with optional TTL. Expired rows
are lazily pruned on ``get`` and ``put_if_absent``.

``put_if_absent`` uses ``INSERT OR IGNORE`` — a single atomic SQL
statement with no TOCTOU race.
"""

def __init__(
self,
db: Any,
*,
ttl_seconds: int = 300,
) -> None:
self._db = db
self._ttl = ttl_seconds

@classmethod
async def create(
cls,
path: str = "mpp.db",
*,
ttl_seconds: int = 300,
) -> SQLiteStore:
"""Open (or create) a SQLite database and initialize the schema.

Args:
path: Filesystem path for the database file.
Use ``":memory:"`` for an ephemeral in-memory database.
ttl_seconds: Seconds before a key expires (default 300).
"""
import aiosqlite

db = await aiosqlite.connect(path)
await db.execute(
"CREATE TABLE IF NOT EXISTS kv ("
" key TEXT PRIMARY KEY,"
" value TEXT NOT NULL,"
" expires_at REAL NOT NULL"
")"
)
await db.commit()
return cls(db, ttl_seconds=ttl_seconds)

async def close(self) -> None:
"""Close the underlying database connection."""
await self._db.close()

async def __aenter__(self) -> SQLiteStore:
return self

async def __aexit__(self, *args: Any) -> None:
await self.close()

def _expires_at(self) -> float:
return time.time() + self._ttl

async def get(self, key: str) -> Any | None:
now = time.time()
cursor = await self._db.execute(
"SELECT value FROM kv WHERE key = ? AND expires_at > ?",
(key, now),
)
row = await cursor.fetchone()
return row[0] if row else None

async def put(self, key: str, value: Any) -> None:
await self._db.execute(
"INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
" expires_at = excluded.expires_at",
(key, value, self._expires_at()),
)
await self._db.commit()

async def delete(self, key: str) -> None:
await self._db.execute("DELETE FROM kv WHERE key = ?", (key,))
await self._db.commit()

async def put_if_absent(self, key: str, value: Any) -> bool:
"""Atomic conditional insert.

Deletes any expired row for *key* first, then uses
``INSERT OR IGNORE`` so the write only succeeds when the
key does not already exist.

Returns ``True`` if the key was new, ``False`` if it existed.
"""
now = time.time()
await self._db.execute("DELETE FROM kv WHERE key = ? AND expires_at <= ?", (key, now))
cursor = await self._db.execute(
"INSERT OR IGNORE INTO kv (key, value, expires_at) VALUES (?, ?, ?)",
(key, value, self._expires_at()),
)
await self._db.commit()
return cursor.rowcount > 0
70 changes: 70 additions & 0 deletions tests/test_stores_redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Tests for RedisStore."""

from __future__ import annotations

from unittest.mock import AsyncMock

import pytest

from mpp.stores.redis import RedisStore


@pytest.fixture
def mock_redis():
return AsyncMock()


@pytest.fixture
def store(mock_redis):
return RedisStore(mock_redis, ttl_seconds=300)


class TestRedisStore:
@pytest.mark.asyncio
async def test_get_returns_value(self, store, mock_redis) -> None:
mock_redis.get.return_value = b"some-value"
result = await store.get("foo")
assert result == b"some-value"
mock_redis.get.assert_awaited_once_with("mpp:foo")

@pytest.mark.asyncio
async def test_get_returns_none_when_missing(self, store, mock_redis) -> None:
mock_redis.get.return_value = None
result = await store.get("missing")
assert result is None

@pytest.mark.asyncio
async def test_put(self, store, mock_redis) -> None:
await store.put("key1", "val1")
mock_redis.set.assert_awaited_once_with("mpp:key1", "val1", ex=300)

@pytest.mark.asyncio
async def test_delete(self, store, mock_redis) -> None:
await store.delete("key1")
mock_redis.delete.assert_awaited_once_with("mpp:key1")

@pytest.mark.asyncio
async def test_put_if_absent_returns_true_when_key_absent(self, store, mock_redis) -> None:
mock_redis.set.return_value = True # Redis SET NX returns True on success
result = await store.put_if_absent("new-key", "val")
assert result is True
mock_redis.set.assert_awaited_once_with("mpp:new-key", "val", nx=True, ex=300)

@pytest.mark.asyncio
async def test_put_if_absent_returns_false_when_key_exists(self, store, mock_redis) -> None:
mock_redis.set.return_value = None # Redis SET NX returns None on conflict
result = await store.put_if_absent("existing", "val")
assert result is False

@pytest.mark.asyncio
async def test_key_prefix(self, mock_redis) -> None:
store = RedisStore(mock_redis, key_prefix="custom:")
mock_redis.get.return_value = b"x"
await store.get("abc")
mock_redis.get.assert_awaited_once_with("custom:abc")

@pytest.mark.asyncio
async def test_custom_ttl(self, mock_redis) -> None:
store = RedisStore(mock_redis, ttl_seconds=60)
await store.put("k", "v")
mock_redis.set.assert_awaited_once_with("mpp:k", "v", ex=60)
Loading
Loading