paycascade-sdk 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
2
+ """PayCascade customer-side SDK: cache and access middleware."""
3
+
4
+ from paycascade_sdk.auth_intercept import (
5
+ DEFAULT_EXEMPT_ROUTES,
6
+ DEFAULT_MAX_STALENESS_SECONDS,
7
+ DEFAULT_READ_METHODS,
8
+ AccessDecision,
9
+ CachedSubscriptionState,
10
+ EscalationLevel,
11
+ FailPolicy,
12
+ InMemoryStateCache,
13
+ PayCascadeClient,
14
+ StateCache,
15
+ StatePoller,
16
+ calculate_jittered_interval,
17
+ enforce_cached_state,
18
+ initialize_state_cache,
19
+ is_route_exempt,
20
+ refresh_subscription_state,
21
+ )
22
+
23
+ __all__ = [
24
+ "DEFAULT_EXEMPT_ROUTES",
25
+ "DEFAULT_MAX_STALENESS_SECONDS",
26
+ "DEFAULT_READ_METHODS",
27
+ "AccessDecision",
28
+ "CachedSubscriptionState",
29
+ "EscalationLevel",
30
+ "FailPolicy",
31
+ "InMemoryStateCache",
32
+ "PayCascadeClient",
33
+ "StateCache",
34
+ "StatePoller",
35
+ "calculate_jittered_interval",
36
+ "enforce_cached_state",
37
+ "initialize_state_cache",
38
+ "is_route_exempt",
39
+ "refresh_subscription_state",
40
+ ]
@@ -0,0 +1,387 @@
1
+ # Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
2
+ """Customer-side SDK cache and access middleware for PayCascade.
3
+
4
+ Provides background polling of the PayCascade hosted GET /api/v1/state endpoint,
5
+ in-memory caching with jitter, and request-level access enforcement policies.
6
+ This module is part of the standalone `paycascade-sdk` package and must never
7
+ import from `app/` or require server-side dependencies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from enum import Enum
15
+ import random
16
+ import threading
17
+ import time
18
+ from typing import Any, Callable, Sequence
19
+ from uuid import UUID
20
+ import httpx
21
+
22
+
23
+ class EscalationLevel(str, Enum):
24
+ HEALTHY = "HEALTHY"
25
+ SOFT_WARNING = "SOFT_WARNING"
26
+ SCOPE_RESTRICTED = "SCOPE_RESTRICTED"
27
+ HARD_LOCKED = "HARD_LOCKED"
28
+ GRACE_PERIOD_EXPIRED = "GRACE_PERIOD_EXPIRED"
29
+
30
+
31
+ class FailPolicy(str, Enum):
32
+ FAIL_OPEN = "FAIL_OPEN"
33
+ FAIL_CLOSED = "FAIL_CLOSED"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CachedSubscriptionState:
38
+ subscription_id: str
39
+ level: EscalationLevel
40
+ failed_at: datetime | None
41
+ retry_count: int
42
+ evaluated_at: datetime
43
+ cached_at: datetime
44
+
45
+ def is_stale(self, max_staleness_seconds: float, now: datetime | None = None) -> bool:
46
+ current_time = now or datetime.now(timezone.utc)
47
+ cached_time = self.cached_at
48
+ if cached_time.tzinfo is None:
49
+ cached_time = cached_time.replace(tzinfo=timezone.utc)
50
+ if current_time.tzinfo is None:
51
+ current_time = current_time.replace(tzinfo=timezone.utc)
52
+ age = (current_time - cached_time).total_seconds()
53
+ return age > max_staleness_seconds
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AccessDecision:
58
+ allowed: bool
59
+ level: EscalationLevel | None
60
+ reason: str
61
+ warning: bool = False
62
+ is_stale: bool = False
63
+ is_exempt_route: bool = False
64
+
65
+
66
+ DEFAULT_EXEMPT_ROUTES: tuple[str, ...] = (
67
+ "/billing",
68
+ "/stripe/portal-session",
69
+ "/logout",
70
+ )
71
+
72
+ DEFAULT_READ_METHODS: tuple[str, ...] = ("GET", "HEAD", "OPTIONS")
73
+ DEFAULT_MAX_STALENESS_SECONDS: float = 86400.0 # 24 hours
74
+
75
+
76
+ class StateCache:
77
+ """Interface / base class for customer subscription state caching."""
78
+
79
+ def get(self, subscription_id: str) -> CachedSubscriptionState | None:
80
+ raise NotImplementedError
81
+
82
+ def set(self, state: CachedSubscriptionState) -> None:
83
+ raise NotImplementedError
84
+
85
+
86
+ class InMemoryStateCache(StateCache):
87
+ """Thread-safe in-memory cache for subscription states."""
88
+
89
+ def __init__(self) -> None:
90
+ self._lock = threading.RLock()
91
+ self._cache: dict[str, CachedSubscriptionState] = {}
92
+
93
+ def get(self, subscription_id: str) -> CachedSubscriptionState | None:
94
+ with self._lock:
95
+ return self._cache.get(str(subscription_id))
96
+
97
+ def set(self, state: CachedSubscriptionState) -> None:
98
+ with self._lock:
99
+ self._cache[str(state.subscription_id)] = state
100
+
101
+ def clear(self) -> None:
102
+ with self._lock:
103
+ self._cache.clear()
104
+
105
+
106
+ def parse_iso_datetime(value: str | None) -> datetime | None:
107
+ if value is None:
108
+ return None
109
+ # Normalize Z to +00:00 for fromisoformat compatibility
110
+ clean_value = value.replace("Z", "+00:00")
111
+ dt = datetime.fromisoformat(clean_value)
112
+ if dt.tzinfo is None:
113
+ dt = dt.replace(tzinfo=timezone.utc)
114
+ return dt.astimezone(timezone.utc)
115
+
116
+
117
+ class PayCascadeClient:
118
+ """Client for interacting with the PayCascade hosted API contract."""
119
+
120
+ def __init__(
121
+ self,
122
+ base_url: str,
123
+ api_key: str,
124
+ http_client: httpx.Client | None = None,
125
+ timeout: float = 10.0,
126
+ ) -> None:
127
+ self.base_url = base_url.rstrip("/")
128
+ self.api_key = api_key
129
+ self.timeout = timeout
130
+ self._client = http_client or httpx.Client(timeout=timeout)
131
+ self._owns_client = http_client is None
132
+
133
+ def close(self) -> None:
134
+ if self._owns_client:
135
+ self._client.close()
136
+
137
+ def fetch_state(self, subscription_id: str | UUID) -> CachedSubscriptionState:
138
+ url = f"{self.base_url}/api/v1/state"
139
+ headers = {
140
+ "Authorization": f"Bearer {self.api_key}",
141
+ "Accept": "application/json",
142
+ }
143
+ params = {"subscription_id": str(subscription_id)}
144
+
145
+ response = self._client.get(url, headers=headers, params=params)
146
+ response.raise_for_status()
147
+ data = response.json()
148
+
149
+ now = datetime.now(timezone.utc)
150
+ return CachedSubscriptionState(
151
+ subscription_id=str(data["subscription_id"]),
152
+ level=EscalationLevel(data["level"]),
153
+ failed_at=parse_iso_datetime(data.get("failed_at")),
154
+ retry_count=int(data["retry_count"]),
155
+ evaluated_at=parse_iso_datetime(data["evaluated_at"]) or now,
156
+ cached_at=now,
157
+ )
158
+
159
+
160
+ def calculate_jittered_interval(interval_seconds: float, jitter_ratio: float = 0.2) -> float:
161
+ """Compute a randomized interval with +/- jitter_ratio spread."""
162
+ if jitter_ratio <= 0.0:
163
+ return interval_seconds
164
+ jitter_bound = interval_seconds * jitter_ratio
165
+ return random.uniform(interval_seconds - jitter_bound, interval_seconds + jitter_bound)
166
+
167
+
168
+ class StatePoller:
169
+ """Background poller that periodically refreshes subscription state into a cache."""
170
+
171
+ def __init__(
172
+ self,
173
+ client: PayCascadeClient,
174
+ cache: StateCache,
175
+ subscription_ids: Sequence[str | UUID] | Callable[[], Sequence[str | UUID]],
176
+ interval_seconds: float = 300.0,
177
+ jitter_ratio: float = 0.2,
178
+ on_error: Callable[[str, Exception], None] | None = None,
179
+ ) -> None:
180
+ self.client = client
181
+ self.cache = cache
182
+ self.subscription_ids = subscription_ids
183
+ self.interval_seconds = interval_seconds
184
+ self.jitter_ratio = jitter_ratio
185
+ self.on_error = on_error
186
+ self._stop_event = threading.Event()
187
+ self._thread: threading.Thread | None = None
188
+
189
+ def _get_subscription_ids(self) -> Sequence[str | UUID]:
190
+ if callable(self.subscription_ids):
191
+ return self.subscription_ids()
192
+ return self.subscription_ids
193
+
194
+ def refresh_all(self) -> dict[str, CachedSubscriptionState]:
195
+ refreshed: dict[str, CachedSubscriptionState] = {}
196
+ for sub_id in self._get_subscription_ids():
197
+ sub_id_str = str(sub_id)
198
+ try:
199
+ state = self.client.fetch_state(sub_id_str)
200
+ self.cache.set(state)
201
+ refreshed[sub_id_str] = state
202
+ except Exception as exc:
203
+ if self.on_error:
204
+ self.on_error(sub_id_str, exc)
205
+ return refreshed
206
+
207
+ def run_loop(self) -> None:
208
+ while not self._stop_event.is_set():
209
+ self.refresh_all()
210
+ delay = calculate_jittered_interval(self.interval_seconds, self.jitter_ratio)
211
+ if self._stop_event.wait(timeout=max(0.01, delay)):
212
+ break
213
+
214
+ def start(self) -> None:
215
+ if self._thread is not None and self._thread.is_alive():
216
+ return
217
+ self._stop_event.clear()
218
+ self._thread = threading.Thread(target=self.run_loop, daemon=True, name="PayCascadeStatePoller")
219
+ self._thread.start()
220
+
221
+ def stop(self, timeout: float = 5.0) -> None:
222
+ self._stop_event.set()
223
+ if self._thread is not None and self._thread.is_alive():
224
+ self._thread.join(timeout=timeout)
225
+
226
+
227
+ def is_route_exempt(path: str, exempt_routes: Sequence[str] = DEFAULT_EXEMPT_ROUTES) -> bool:
228
+ """Check if request path matches or is a subpath of an exempt recovery route."""
229
+ normalized_path = "/" + path.strip("/") if not path.startswith("/") else path
230
+ for exempt in exempt_routes:
231
+ normalized_exempt = "/" + exempt.strip("/") if not exempt.startswith("/") else exempt
232
+ if normalized_path == normalized_exempt or normalized_path.startswith(f"{normalized_exempt}/"):
233
+ return True
234
+ return False
235
+
236
+
237
+ def enforce_cached_state(
238
+ subscription_id: str | UUID,
239
+ cache: StateCache,
240
+ path: str,
241
+ method: str = "GET",
242
+ exempt_routes: Sequence[str] = DEFAULT_EXEMPT_ROUTES,
243
+ fail_policy: FailPolicy = FailPolicy.FAIL_OPEN,
244
+ max_staleness_seconds: float = DEFAULT_MAX_STALENESS_SECONDS,
245
+ read_methods: Sequence[str] = DEFAULT_READ_METHODS,
246
+ now: datetime | None = None,
247
+ ) -> AccessDecision:
248
+ """Enforce access control policies based strictly on cached state."""
249
+ sub_id_str = str(subscription_id)
250
+
251
+ # 1. Exempt recovery routes always bypass enforcement
252
+ if is_route_exempt(path, exempt_routes):
253
+ cached = cache.get(sub_id_str)
254
+ level = cached.level if cached else None
255
+ return AccessDecision(
256
+ allowed=True,
257
+ level=level,
258
+ reason="Route is exempt from payment access controls",
259
+ warning=False,
260
+ is_stale=False,
261
+ is_exempt_route=True,
262
+ )
263
+
264
+ cached = cache.get(sub_id_str)
265
+
266
+ # 2. Cache miss handling
267
+ if cached is None:
268
+ if fail_policy == FailPolicy.FAIL_OPEN:
269
+ return AccessDecision(
270
+ allowed=True,
271
+ level=None,
272
+ reason="Cache miss: fail-open policy allowed request",
273
+ warning=False,
274
+ is_stale=False,
275
+ is_exempt_route=False,
276
+ )
277
+ return AccessDecision(
278
+ allowed=False,
279
+ level=None,
280
+ reason="Cache miss: fail-closed policy blocked request",
281
+ warning=False,
282
+ is_stale=False,
283
+ is_exempt_route=False,
284
+ )
285
+
286
+ # 3. Staleness evaluation
287
+ is_stale = cached.is_stale(max_staleness_seconds=max_staleness_seconds, now=now)
288
+ if is_stale:
289
+ if fail_policy == FailPolicy.FAIL_OPEN:
290
+ return AccessDecision(
291
+ allowed=True,
292
+ level=cached.level,
293
+ reason=f"Cached state is stale (> {max_staleness_seconds}s): fail-open policy allowed request",
294
+ warning=True,
295
+ is_stale=True,
296
+ is_exempt_route=False,
297
+ )
298
+ return AccessDecision(
299
+ allowed=False,
300
+ level=cached.level,
301
+ reason=f"Cached state is stale (> {max_staleness_seconds}s): fail-closed policy blocked request",
302
+ warning=False,
303
+ is_stale=True,
304
+ is_exempt_route=False,
305
+ )
306
+
307
+ # 4. Active escalation level enforcement
308
+ normalized_method = method.upper()
309
+ level = cached.level
310
+
311
+ if level == EscalationLevel.HEALTHY:
312
+ return AccessDecision(
313
+ allowed=True,
314
+ level=level,
315
+ reason="Subscription is healthy",
316
+ warning=False,
317
+ is_stale=False,
318
+ )
319
+
320
+ if level == EscalationLevel.SOFT_WARNING:
321
+ return AccessDecision(
322
+ allowed=True,
323
+ level=level,
324
+ reason="Payment delinquent (soft warning): access allowed with warning indicator",
325
+ warning=True,
326
+ is_stale=False,
327
+ )
328
+
329
+ if level == EscalationLevel.SCOPE_RESTRICTED:
330
+ if normalized_method in (m.upper() for m in read_methods):
331
+ return AccessDecision(
332
+ allowed=True,
333
+ level=level,
334
+ reason="Payment delinquent (scope restricted): read access allowed",
335
+ warning=True,
336
+ is_stale=False,
337
+ )
338
+ return AccessDecision(
339
+ allowed=False,
340
+ level=level,
341
+ reason="Payment delinquent (scope restricted): mutating write operations are blocked",
342
+ warning=True,
343
+ is_stale=False,
344
+ )
345
+
346
+ if level == EscalationLevel.HARD_LOCKED:
347
+ return AccessDecision(
348
+ allowed=False,
349
+ level=level,
350
+ reason="Payment delinquent (hard locked): protected application access is blocked",
351
+ warning=True,
352
+ is_stale=False,
353
+ )
354
+
355
+ if level == EscalationLevel.GRACE_PERIOD_EXPIRED:
356
+ return AccessDecision(
357
+ allowed=False,
358
+ level=level,
359
+ reason="Grace period expired: protected application access is blocked",
360
+ warning=False,
361
+ is_stale=False,
362
+ )
363
+
364
+ # Fallback safety guard
365
+ return AccessDecision(
366
+ allowed=False,
367
+ level=level,
368
+ reason=f"Unknown escalation level: {level}",
369
+ warning=False,
370
+ is_stale=False,
371
+ )
372
+
373
+
374
+ def initialize_state_cache() -> StateCache:
375
+ """Helper factory to create a default in-memory state cache."""
376
+ return InMemoryStateCache()
377
+
378
+
379
+ def refresh_subscription_state(
380
+ client: PayCascadeClient,
381
+ cache: StateCache,
382
+ subscription_id: str | UUID,
383
+ ) -> CachedSubscriptionState:
384
+ """Synchronously refresh and cache subscription state for a single subscription."""
385
+ state = client.fetch_state(subscription_id)
386
+ cache.set(state)
387
+ return state
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: paycascade-sdk
3
+ Version: 0.1.1
4
+ Summary: Customer-side cache and access middleware for the PayCascade hosted billing-state API.
5
+ Author: BitBerry Labs
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ License-File: LICENSE.md
18
+ Requires-Dist: httpx>=0.27.0
19
+ Dynamic: license-file
@@ -0,0 +1,7 @@
1
+ paycascade_sdk/__init__.py,sha256=CAnqlEGsY_SnthyKWljXV-bhFNR-Wm0xTGacy0Q91CY,914
2
+ paycascade_sdk/auth_intercept.py,sha256=Rkp0z9ut8OECBuw3pkh9PWJhlmE4EXf7DVUZPf-d380,11204
3
+ paycascade_sdk-0.1.1.dist-info/licenses/LICENSE.md,sha256=PO_mgqJjeQn1eDM0cRNP-uIqxZDrZhM1y8G_ORR6BNI,1372
4
+ paycascade_sdk-0.1.1.dist-info/METADATA,sha256=XA41YZ7AqULkXHLCCzNMDaIop6Oz86-VVAJ5hOsXFvc,759
5
+ paycascade_sdk-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ paycascade_sdk-0.1.1.dist-info/top_level.txt,sha256=XWU_tQqPJogAFHCyJ-Bjfjyr7WpVJ1sp_qrT4IkwQKg,15
7
+ paycascade_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BitBerry Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ SCOPE: This license applies only to the PayCascade customer SDK files in this
24
+ directory and the corresponding SDK package. It does not license the PayCascade
25
+ server or any other files in the repository, and it grants no rights to
26
+ BitBerry Labs trademarks. The root LICENSE.md governs all other files.
@@ -0,0 +1 @@
1
+ paycascade_sdk