paycascade-sdk 0.1.1__tar.gz
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.
- paycascade_sdk-0.1.1/LICENSE.md +26 -0
- paycascade_sdk-0.1.1/MANIFEST.in +3 -0
- paycascade_sdk-0.1.1/PKG-INFO +19 -0
- paycascade_sdk-0.1.1/README.md +113 -0
- paycascade_sdk-0.1.1/paycascade_sdk/__init__.py +40 -0
- paycascade_sdk-0.1.1/paycascade_sdk/auth_intercept.py +387 -0
- paycascade_sdk-0.1.1/paycascade_sdk.egg-info/PKG-INFO +19 -0
- paycascade_sdk-0.1.1/paycascade_sdk.egg-info/SOURCES.txt +11 -0
- paycascade_sdk-0.1.1/paycascade_sdk.egg-info/dependency_links.txt +1 -0
- paycascade_sdk-0.1.1/paycascade_sdk.egg-info/requires.txt +1 -0
- paycascade_sdk-0.1.1/paycascade_sdk.egg-info/top_level.txt +1 -0
- paycascade_sdk-0.1.1/pyproject.toml +40 -0
- paycascade_sdk-0.1.1/setup.cfg +4 -0
|
@@ -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,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,113 @@
|
|
|
1
|
+
# PayCascade Python SDK
|
|
2
|
+
|
|
3
|
+
The SDK is a customer-side helper for polling the PayCascade state API and
|
|
4
|
+
making access decisions from a local cache. It is publicly installable from
|
|
5
|
+
PyPI under the MIT License and is also included in the self-hosted Dev Kit.
|
|
6
|
+
The SDK is not required to run the Dev Kit server.
|
|
7
|
+
|
|
8
|
+
Install the public package from the default PyPI index:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
python -m pip install paycascade-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Anyone can install, use, modify, and redistribute the SDK under the MIT
|
|
15
|
+
License. Package downloads are not gated by a hosted subscription. Calling
|
|
16
|
+
PayCascade's hosted API still requires a valid API key and applicable hosted
|
|
17
|
+
service entitlement. In a source checkout or Dev Kit snapshot, install locally
|
|
18
|
+
with:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
python -m pip install ./sdk
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The SDK is distributed under the [MIT License](LICENSE.md); that license
|
|
25
|
+
covers only the SDK and grants no rights to the server or BitBerry Labs
|
|
26
|
+
trademarks. The SDK requires Python 3.11 or newer and `httpx`. It polls the PayCascade
|
|
27
|
+
state API and offers client-side access-decision helpers; it does not receive
|
|
28
|
+
or process Stripe webhooks. Stripe sends customer billing webhooks to the
|
|
29
|
+
PayCascade service. The SDK does not enforce policy in your application
|
|
30
|
+
automatically: your application must call the decision helper and apply the
|
|
31
|
+
result to its own routes and user experience.
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
Keep the API key on your application server; never ship it to a browser or
|
|
36
|
+
mobile client. Replace the example values with the PayCascade API base URL,
|
|
37
|
+
tenant API key, and PayCascade subscription ID that your application has
|
|
38
|
+
registered.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
from uuid import UUID
|
|
44
|
+
|
|
45
|
+
from paycascade_sdk import (
|
|
46
|
+
FailPolicy,
|
|
47
|
+
InMemoryStateCache,
|
|
48
|
+
PayCascadeClient,
|
|
49
|
+
StatePoller,
|
|
50
|
+
enforce_cached_state,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
subscription_id = UUID(os.environ["PAYCASCADE_SUBSCRIPTION_ID"])
|
|
55
|
+
client = PayCascadeClient(
|
|
56
|
+
base_url=os.environ["PAYCASCADE_API_URL"],
|
|
57
|
+
api_key=os.environ["PAYCASCADE_API_KEY"],
|
|
58
|
+
)
|
|
59
|
+
cache = InMemoryStateCache()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def report_poll_error(subscription_id: str, error: Exception) -> None:
|
|
63
|
+
logger.warning("PayCascade state refresh failed for %s: %s", subscription_id, error)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
poller = StatePoller(
|
|
67
|
+
client=client,
|
|
68
|
+
cache=cache,
|
|
69
|
+
subscription_ids=[subscription_id],
|
|
70
|
+
interval_seconds=300,
|
|
71
|
+
on_error=report_poll_error,
|
|
72
|
+
)
|
|
73
|
+
poller.start()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# In your request handler, use the authenticated user's mapped subscription.
|
|
77
|
+
decision = enforce_cached_state(
|
|
78
|
+
subscription_id=subscription_id,
|
|
79
|
+
cache=cache,
|
|
80
|
+
path="/reports",
|
|
81
|
+
method="GET",
|
|
82
|
+
fail_policy=FailPolicy.FAIL_OPEN,
|
|
83
|
+
)
|
|
84
|
+
if not decision.allowed:
|
|
85
|
+
# Return your application's own payment/recovery response.
|
|
86
|
+
...
|
|
87
|
+
finally:
|
|
88
|
+
poller.stop()
|
|
89
|
+
client.close()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`StatePoller.start()` refreshes immediately and then polls at the configured
|
|
93
|
+
interval with jitter. Provide `on_error` so refresh failures are observable.
|
|
94
|
+
For multi-process applications, each process has its own in-memory cache;
|
|
95
|
+
polling frequency and API load should be planned accordingly.
|
|
96
|
+
|
|
97
|
+
## Access-policy behavior
|
|
98
|
+
|
|
99
|
+
- The default `FAIL_OPEN` policy allows access when state is missing or stale.
|
|
100
|
+
`FAIL_CLOSED` blocks access in those cases. Choose deliberately for your
|
|
101
|
+
product and communicate failure behavior to users.
|
|
102
|
+
- Healthy and soft-warning states allow access; the warning state can be used
|
|
103
|
+
to show a recovery notice. Restricted state permits configured read methods
|
|
104
|
+
and blocks mutations. Hard-locked and grace-period-expired states deny
|
|
105
|
+
protected access.
|
|
106
|
+
- Recovery routes such as `/billing`, `/stripe/portal-session`, and `/logout`
|
|
107
|
+
are exempt by default. Customize exemptions to preserve a usable payment
|
|
108
|
+
recovery path.
|
|
109
|
+
- Protect against forged or mismatched subscription IDs by looking up the
|
|
110
|
+
PayCascade subscription ID from your own authenticated account records.
|
|
111
|
+
- The cache is process-local and not a security boundary. Your application is
|
|
112
|
+
responsible for authorization, mapping users to subscriptions, and presenting
|
|
113
|
+
access decisions.
|
|
@@ -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,11 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
paycascade_sdk/__init__.py
|
|
6
|
+
paycascade_sdk/auth_intercept.py
|
|
7
|
+
paycascade_sdk.egg-info/PKG-INFO
|
|
8
|
+
paycascade_sdk.egg-info/SOURCES.txt
|
|
9
|
+
paycascade_sdk.egg-info/dependency_links.txt
|
|
10
|
+
paycascade_sdk.egg-info/requires.txt
|
|
11
|
+
paycascade_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paycascade_sdk
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["setuptools>=68"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "paycascade-sdk"
|
|
8
|
+
version = "0.1.1"
|
|
9
|
+
description = "Customer-side cache and access middleware for the PayCascade hosted billing-state API."
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "BitBerry Labs" }]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE.md"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Deliberately excludes fastapi, sqlalchemy, celery, stripe, cryptography, and
|
|
30
|
+
# every other hosted-service dependency: this package never imports app/ and
|
|
31
|
+
# must be installable in a customer's app without pulling in server internals.
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["paycascade_sdk*"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
# Own config so `pytest` run from sdk/ never picks up the hosted service's
|
|
38
|
+
# root pytest.ini and stays scoped to this package's isolated dependency set.
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = "-q"
|