Skip to content

Commit e1a9d76

Browse files
feat: add RedisStore, SQLiteStore, and wire store into Mpp.create() (#98)
* feat: add RedisStore, SQLiteStore, and wire store into Mpp.create() - RedisStore: production-ready store using redis-py (SET NX EX for atomic put_if_absent, configurable TTL and key prefix) - SQLiteStore: zero-infra production store using aiosqlite (INSERT OR IGNORE for atomic put_if_absent, lazy TTL expiry) - Mpp.create(store=...) and Mpp(store=...) automatically inject the store into intents that have a _store attribute (e.g. ChargeIntent) - New optional deps: pympp[redis], pympp[sqlite] - 27 new tests covering all store backends and wiring behavior * chore: add changelog * style: fix ruff formatting in store backends * test: add Redis integration tests against real instance - Add redis service to docker-compose.yml - 10 integration tests: CRUD, TTL verification, atomicity of put_if_absent under concurrent access, key prefix isolation - Skipped automatically when REDIS_URL is not set - Run with: docker compose up -d redis && REDIS_URL=redis://localhost:6379 uv run pytest -m redis -v * fix: narrow REDIS_URL type for pyright * fix: preserve replay protection in store backends --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 1d7fbd9 commit e1a9d76

12 files changed

Lines changed: 740 additions & 1 deletion

.changelog/merry-dogs-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pympp: minor
3+
---
4+
5+
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.

docker-compose.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
services:
2+
redis:
3+
image: redis:7-alpine
4+
ports:
5+
- "6379:6379"
6+
healthcheck:
7+
test: ["CMD", "redis-cli", "ping"]
8+
interval: 2s
9+
timeout: 5s
10+
retries: 10
11+
212
tempo:
313
image: ghcr.io/tempoxyz/tempo:latest
414
ports:

pyproject.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ tempo = [
2828
"pydantic>=2.0",
2929
]
3030
server = ["pydantic>=2.0", "python-dotenv>=1.0"]
31+
redis = ["redis>=5.0"]
32+
sqlite = ["aiosqlite>=0.20"]
3133
mcp = ["mcp>=1.1.0"]
3234
dev = [
3335
"pytest>=8.0",
@@ -47,6 +49,8 @@ dev = [
4749
"pyright>=1.1",
4850
"build>=1.0",
4951
"twine>=6.0",
52+
"aiosqlite>=0.20",
53+
"redis>=5.0",
5054
]
5155

5256
[build-system]
@@ -79,4 +83,7 @@ include = ["src", "tests"]
7983
[tool.pytest.ini_options]
8084
asyncio_mode = "auto"
8185
asyncio_default_fixture_loop_scope = "function"
82-
markers = ["integration: requires TEMPO_RPC_URL (real Tempo node)"]
86+
markers = [
87+
"integration: requires TEMPO_RPC_URL (real Tempo node)",
88+
"redis: requires REDIS_URL (real Redis instance)",
89+
]

src/mpp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,4 +399,5 @@ def success(
399399

400400
from . import _body_digest as BodyDigest # noqa: E402
401401
from . import _expires as Expires # noqa: E402
402+
from . import stores # noqa: E402
402403
from .store import MemoryStore, Store # noqa: E402

src/mpp/server/mpp.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from mpp.server.decorator import wrap_payment_handler
1313
from mpp.server.method import transform_request
1414
from mpp.server.verify import verify_or_challenge
15+
from mpp.store import Store
1516

1617
if TYPE_CHECKING:
1718
from mpp.server.method import Method
@@ -58,6 +59,7 @@ def __init__(
5859
realm: str,
5960
secret_key: str,
6061
defaults: dict[str, Any] | None = None,
62+
store: Store | None = None,
6163
) -> None:
6264
"""Initialize the payment handler.
6365
@@ -67,30 +69,49 @@ def __init__(
6769
secret_key: Server secret for HMAC-bound challenge IDs.
6870
Enables stateless challenge verification.
6971
defaults: Default request values merged with per-call request params.
72+
store: Optional key-value store for replay protection.
73+
When provided, automatically wired into intents that
74+
accept a ``store`` (e.g., ``ChargeIntent``).
7075
"""
7176
self.method = method
7277
self.realm = realm
7378
self.secret_key = secret_key
7479
self.defaults = defaults or {}
7580

81+
if store is not None:
82+
self._wire_store(store)
83+
84+
def _wire_store(self, store: Store) -> None:
85+
"""Inject *store* into intents that have a ``_store`` attribute set to None."""
86+
intents = getattr(self.method, "intents", None)
87+
if not isinstance(intents, dict):
88+
return
89+
for intent_obj in intents.values():
90+
if hasattr(intent_obj, "_store") and intent_obj._store is None:
91+
intent_obj._store = store
92+
7693
@classmethod
7794
def create(
7895
cls,
7996
method: Method,
8097
realm: str | None = None,
8198
secret_key: str | None = None,
99+
store: Store | None = None,
82100
) -> Mpp:
83101
"""Create an Mpp instance with smart defaults.
84102
85103
Args:
86104
method: Payment method (e.g., tempo(currency=..., recipient=...)).
87105
realm: Server realm. Auto-detected from environment if omitted.
88106
secret_key: HMAC secret. Required unless `MPP_SECRET_KEY` is set.
107+
store: Optional key-value store for replay protection.
108+
Automatically wired into intents that accept a store.
89109
"""
90110
return cls(
91111
method=method,
92112
realm=detect_realm() if realm is None else realm,
93113
secret_key=detect_secret_key() if secret_key is None else secret_key,
114+
store=store,
94115
)
95116

96117
async def charge(

src/mpp/stores/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Concrete store backends for replay protection.
2+
3+
Available backends:
4+
5+
- ``MemoryStore`` – in-memory ``dict``, for development/testing.
6+
- ``RedisStore`` – Redis/Valkey, for multi-instance production deployments.
7+
- ``SQLiteStore`` – local SQLite file, for single-instance production deployments.
8+
"""
9+
10+
from mpp.store import MemoryStore
11+
12+
__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"]
13+
14+
15+
def __getattr__(name: str): # type: ignore[reportReturnType]
16+
if name == "RedisStore":
17+
from mpp.stores.redis import RedisStore
18+
19+
return RedisStore
20+
if name == "SQLiteStore":
21+
from mpp.stores.sqlite import SQLiteStore
22+
23+
return SQLiteStore
24+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

src/mpp/stores/redis.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Redis-backed store for multi-instance deployments.
2+
3+
Uses ``redis-py`` (``redis.asyncio``) as the async driver. Install with::
4+
5+
pip install pympp[redis]
6+
7+
Example::
8+
9+
from redis.asyncio import from_url
10+
from mpp.stores import RedisStore
11+
12+
store = RedisStore(await from_url("redis://localhost:6379"))
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from typing import Any
18+
19+
20+
class RedisStore:
21+
"""Async key-value store backed by Redis.
22+
23+
Each key is prefixed with ``key_prefix`` (default ``"mpp:"``).
24+
Keys do not expire by default; set ``ttl_seconds`` to opt into expiry.
25+
26+
``put_if_absent`` maps to ``SET key value NX`` with an optional
27+
``EX ttl`` — a single atomic Redis command with no TOCTOU race.
28+
"""
29+
30+
def __init__(
31+
self,
32+
client: Any,
33+
*,
34+
key_prefix: str = "mpp:",
35+
ttl_seconds: int | None = None,
36+
) -> None:
37+
self._redis = client
38+
self._prefix = key_prefix
39+
self._ttl = ttl_seconds
40+
41+
def _key(self, key: str) -> str:
42+
return f"{self._prefix}{key}"
43+
44+
async def get(self, key: str) -> Any | None:
45+
return await self._redis.get(self._key(key))
46+
47+
async def put(self, key: str, value: Any) -> None:
48+
if self._ttl is None:
49+
await self._redis.set(self._key(key), value)
50+
return
51+
await self._redis.set(self._key(key), value, ex=self._ttl)
52+
53+
async def delete(self, key: str) -> None:
54+
await self._redis.delete(self._key(key))
55+
56+
async def put_if_absent(self, key: str, value: Any) -> bool:
57+
"""Atomic ``SETNX`` with an optional TTL.
58+
59+
Returns ``True`` when the key was new and the write succeeded,
60+
``False`` when the key already existed (duplicate).
61+
"""
62+
if self._ttl is None:
63+
result = await self._redis.set(self._key(key), value, nx=True)
64+
return result is not None
65+
result = await self._redis.set(self._key(key), value, nx=True, ex=self._ttl)
66+
return result is not None

src/mpp/stores/sqlite.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""SQLite-backed store for single-instance production deployments.
2+
3+
Uses ``aiosqlite`` for async access to Python's built-in ``sqlite3``.
4+
Install with::
5+
6+
pip install pympp[sqlite]
7+
8+
Example::
9+
10+
from mpp.stores import SQLiteStore
11+
12+
store = await SQLiteStore.create("mpp.db")
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import time
18+
from typing import Any
19+
20+
NO_TTL_EXPIRES_AT = 253402300799.0
21+
22+
23+
class SQLiteStore:
24+
"""Async key-value store backed by a local SQLite file.
25+
26+
Keys are stored in a ``kv`` table with optional TTL. Expired rows
27+
are pruned globally on writes so one-shot replay keys do not
28+
accumulate forever.
29+
30+
``put_if_absent`` uses ``INSERT OR IGNORE`` — a single atomic SQL
31+
statement with no TOCTOU race.
32+
"""
33+
34+
def __init__(
35+
self,
36+
db: Any,
37+
*,
38+
ttl_seconds: int | None = None,
39+
) -> None:
40+
self._db = db
41+
self._ttl = ttl_seconds
42+
43+
@classmethod
44+
async def create(
45+
cls,
46+
path: str = "mpp.db",
47+
*,
48+
ttl_seconds: int | None = None,
49+
) -> SQLiteStore:
50+
"""Open (or create) a SQLite database and initialize the schema.
51+
52+
Args:
53+
path: Filesystem path for the database file.
54+
Use ``":memory:"`` for an ephemeral in-memory database.
55+
ttl_seconds: Optional key TTL in seconds. Defaults to no expiry.
56+
"""
57+
import aiosqlite
58+
59+
db = await aiosqlite.connect(path)
60+
await db.execute(
61+
"CREATE TABLE IF NOT EXISTS kv ("
62+
" key TEXT PRIMARY KEY,"
63+
" value TEXT NOT NULL,"
64+
" expires_at REAL NOT NULL"
65+
")"
66+
)
67+
await db.commit()
68+
return cls(db, ttl_seconds=ttl_seconds)
69+
70+
async def close(self) -> None:
71+
"""Close the underlying database connection."""
72+
await self._db.close()
73+
74+
async def __aenter__(self) -> SQLiteStore:
75+
return self
76+
77+
async def __aexit__(self, *args: Any) -> None:
78+
await self.close()
79+
80+
def _expires_at(self) -> float:
81+
if self._ttl is None:
82+
return NO_TTL_EXPIRES_AT
83+
return time.time() + self._ttl
84+
85+
async def _prune_expired(self, now: float) -> None:
86+
await self._db.execute("DELETE FROM kv WHERE expires_at <= ?", (now,))
87+
88+
async def get(self, key: str) -> Any | None:
89+
now = time.time()
90+
cursor = await self._db.execute(
91+
"SELECT value FROM kv WHERE key = ? AND expires_at > ?",
92+
(key, now),
93+
)
94+
row = await cursor.fetchone()
95+
return row[0] if row else None
96+
97+
async def put(self, key: str, value: Any) -> None:
98+
await self._prune_expired(time.time())
99+
await self._db.execute(
100+
"INSERT INTO kv (key, value, expires_at) VALUES (?, ?, ?)"
101+
" ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
102+
" expires_at = excluded.expires_at",
103+
(key, value, self._expires_at()),
104+
)
105+
await self._db.commit()
106+
107+
async def delete(self, key: str) -> None:
108+
await self._db.execute("DELETE FROM kv WHERE key = ?", (key,))
109+
await self._db.commit()
110+
111+
async def put_if_absent(self, key: str, value: Any) -> bool:
112+
"""Atomic conditional insert.
113+
114+
Prunes expired rows first, then uses ``INSERT OR IGNORE`` so the
115+
write only succeeds when the key does not already exist.
116+
117+
Returns ``True`` if the key was new, ``False`` if it existed.
118+
"""
119+
now = time.time()
120+
await self._prune_expired(now)
121+
cursor = await self._db.execute(
122+
"INSERT OR IGNORE INTO kv (key, value, expires_at) VALUES (?, ?, ?)",
123+
(key, value, self._expires_at()),
124+
)
125+
await self._db.commit()
126+
return cursor.rowcount > 0

0 commit comments

Comments
 (0)