sweatstack 0.60.0__py3-none-any.whl → 0.62.0__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,238 @@
1
+ """Token store implementations for development use.
2
+
3
+ WARNING: These implementations are for LOCAL DEVELOPMENT ONLY.
4
+ Do not use in production. Implement your own TokenStore with proper
5
+ database infrastructure, encryption, and monitoring.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sqlite3
12
+ import threading
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from .models import StoredTokens, TokenStore
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class SQLiteTokenStore(TokenStore):
22
+ """SQLite-based TokenStore for local development.
23
+
24
+ WARNING: This implementation is for LOCAL DEVELOPMENT ONLY.
25
+ Do not use in production. For production, implement your own
26
+ TokenStore with proper database infrastructure, encryption,
27
+ and monitoring.
28
+
29
+ Features:
30
+ - File-based storage (no external database required)
31
+ - Thread-safe with connection-per-thread pattern
32
+ - Auto-creates table on first use
33
+
34
+ Args:
35
+ db_path: Path to SQLite database file. Defaults to "sweatstack_tokens.db"
36
+ in current directory.
37
+ """
38
+
39
+ def __init__(self, db_path: str | Path = "sweatstack_tokens.db"):
40
+ self.db_path = str(db_path)
41
+ self._local = threading.local()
42
+
43
+ logger.warning(
44
+ "SQLiteTokenStore is for LOCAL DEVELOPMENT ONLY. "
45
+ "Do not use in production. Implement a proper TokenStore "
46
+ "with your production database."
47
+ )
48
+
49
+ self._init_db()
50
+
51
+ def _get_connection(self) -> sqlite3.Connection:
52
+ """Get thread-local database connection."""
53
+ if not hasattr(self._local, "connection"):
54
+ self._local.connection = sqlite3.connect(
55
+ self.db_path,
56
+ check_same_thread=False,
57
+ )
58
+ self._local.connection.row_factory = sqlite3.Row
59
+ return self._local.connection
60
+
61
+ def _init_db(self) -> None:
62
+ """Create tokens table if it doesn't exist."""
63
+ conn = self._get_connection()
64
+ conn.execute("""
65
+ CREATE TABLE IF NOT EXISTS sweatstack_tokens (
66
+ user_id TEXT PRIMARY KEY,
67
+ access_token TEXT NOT NULL,
68
+ refresh_token TEXT NOT NULL,
69
+ expires_at TEXT NOT NULL
70
+ )
71
+ """)
72
+ conn.commit()
73
+
74
+ def save(self, tokens: StoredTokens) -> None:
75
+ """Save or update tokens for a user."""
76
+ conn = self._get_connection()
77
+ conn.execute(
78
+ """
79
+ INSERT INTO sweatstack_tokens (user_id, access_token, refresh_token, expires_at)
80
+ VALUES (?, ?, ?, ?)
81
+ ON CONFLICT(user_id) DO UPDATE SET
82
+ access_token = excluded.access_token,
83
+ refresh_token = excluded.refresh_token,
84
+ expires_at = excluded.expires_at
85
+ """,
86
+ (
87
+ tokens.user_id,
88
+ tokens.access_token,
89
+ tokens.refresh_token,
90
+ tokens.expires_at.isoformat(),
91
+ ),
92
+ )
93
+ conn.commit()
94
+
95
+ def load(self, user_id: str) -> StoredTokens | None:
96
+ """Load tokens for a user. Returns None if not found."""
97
+ conn = self._get_connection()
98
+ row = conn.execute(
99
+ "SELECT * FROM sweatstack_tokens WHERE user_id = ?",
100
+ (user_id,),
101
+ ).fetchone()
102
+
103
+ if row:
104
+ return StoredTokens(
105
+ user_id=row["user_id"],
106
+ access_token=row["access_token"],
107
+ refresh_token=row["refresh_token"],
108
+ expires_at=datetime.fromisoformat(row["expires_at"]),
109
+ )
110
+ return None
111
+
112
+ def delete(self, user_id: str) -> None:
113
+ """Delete tokens for a user. Idempotent."""
114
+ conn = self._get_connection()
115
+ conn.execute(
116
+ "DELETE FROM sweatstack_tokens WHERE user_id = ?",
117
+ (user_id,),
118
+ )
119
+ conn.commit()
120
+
121
+
122
+ class EncryptedSQLiteTokenStore(TokenStore):
123
+ """Encrypted SQLite TokenStore for local development.
124
+
125
+ WARNING: This implementation is for LOCAL DEVELOPMENT ONLY.
126
+ Do not use in production. This demonstrates the encryption pattern -
127
+ adapt it for your production database.
128
+
129
+ Encrypts access_token and refresh_token using Fernet (AES-128-CBC + HMAC).
130
+ user_id and expires_at are stored unencrypted for queries.
131
+
132
+ Args:
133
+ encryption_key: Fernet key for encryption. Generate with:
134
+ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
135
+ db_path: Path to SQLite database file.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ encryption_key: str | bytes,
141
+ db_path: str | Path = "sweatstack_tokens_encrypted.db",
142
+ ):
143
+ # Import here so cryptography is optional for basic usage
144
+ from cryptography.fernet import Fernet
145
+
146
+ self.db_path = str(db_path)
147
+ self._local = threading.local()
148
+
149
+ if isinstance(encryption_key, str):
150
+ encryption_key = encryption_key.encode()
151
+ self._fernet = Fernet(encryption_key)
152
+
153
+ logger.warning(
154
+ "EncryptedSQLiteTokenStore is for LOCAL DEVELOPMENT ONLY. "
155
+ "Do not use in production. This demonstrates the encryption "
156
+ "pattern - implement with your production database."
157
+ )
158
+
159
+ self._init_db()
160
+
161
+ def _get_connection(self) -> sqlite3.Connection:
162
+ """Get thread-local database connection."""
163
+ if not hasattr(self._local, "connection"):
164
+ self._local.connection = sqlite3.connect(
165
+ self.db_path,
166
+ check_same_thread=False,
167
+ )
168
+ self._local.connection.row_factory = sqlite3.Row
169
+ return self._local.connection
170
+
171
+ def _init_db(self) -> None:
172
+ """Create tokens table if it doesn't exist."""
173
+ conn = self._get_connection()
174
+ conn.execute("""
175
+ CREATE TABLE IF NOT EXISTS sweatstack_tokens (
176
+ user_id TEXT PRIMARY KEY,
177
+ access_token_encrypted TEXT NOT NULL,
178
+ refresh_token_encrypted TEXT NOT NULL,
179
+ expires_at TEXT NOT NULL
180
+ )
181
+ """)
182
+ conn.commit()
183
+
184
+ def _encrypt(self, value: str) -> str:
185
+ """Encrypt a string value."""
186
+ return self._fernet.encrypt(value.encode()).decode()
187
+
188
+ def _decrypt(self, value: str) -> str:
189
+ """Decrypt a string value."""
190
+ return self._fernet.decrypt(value.encode()).decode()
191
+
192
+ def save(self, tokens: StoredTokens) -> None:
193
+ """Save or update tokens for a user."""
194
+ conn = self._get_connection()
195
+ conn.execute(
196
+ """
197
+ INSERT INTO sweatstack_tokens
198
+ (user_id, access_token_encrypted, refresh_token_encrypted, expires_at)
199
+ VALUES (?, ?, ?, ?)
200
+ ON CONFLICT(user_id) DO UPDATE SET
201
+ access_token_encrypted = excluded.access_token_encrypted,
202
+ refresh_token_encrypted = excluded.refresh_token_encrypted,
203
+ expires_at = excluded.expires_at
204
+ """,
205
+ (
206
+ tokens.user_id,
207
+ self._encrypt(tokens.access_token),
208
+ self._encrypt(tokens.refresh_token),
209
+ tokens.expires_at.isoformat(),
210
+ ),
211
+ )
212
+ conn.commit()
213
+
214
+ def load(self, user_id: str) -> StoredTokens | None:
215
+ """Load tokens for a user. Returns None if not found."""
216
+ conn = self._get_connection()
217
+ row = conn.execute(
218
+ "SELECT * FROM sweatstack_tokens WHERE user_id = ?",
219
+ (user_id,),
220
+ ).fetchone()
221
+
222
+ if row:
223
+ return StoredTokens(
224
+ user_id=row["user_id"],
225
+ access_token=self._decrypt(row["access_token_encrypted"]),
226
+ refresh_token=self._decrypt(row["refresh_token_encrypted"]),
227
+ expires_at=datetime.fromisoformat(row["expires_at"]),
228
+ )
229
+ return None
230
+
231
+ def delete(self, user_id: str) -> None:
232
+ """Delete tokens for a user. Idempotent."""
233
+ conn = self._get_connection()
234
+ conn.execute(
235
+ "DELETE FROM sweatstack_tokens WHERE user_id = ?",
236
+ (user_id,),
237
+ )
238
+ conn.commit()
@@ -0,0 +1,201 @@
1
+ """Webhook handling for the FastAPI plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from typing import Annotated
12
+
13
+ from fastapi import Depends, HTTPException, Request
14
+
15
+ from .config import get_config
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Exceptions
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ class WebhookError(Exception):
24
+ """Base class for webhook-related errors."""
25
+
26
+ pass
27
+
28
+
29
+ class WebhookVerificationError(WebhookError):
30
+ """Raised when webhook signature verification fails."""
31
+
32
+ pass
33
+
34
+
35
+ class WebhookTokenStoreError(WebhookError):
36
+ """Raised when TokenStore is required but not configured."""
37
+
38
+ pass
39
+
40
+
41
+ class WebhookUserNotFoundError(WebhookError):
42
+ """Raised when no stored tokens exist for the webhook's user_id."""
43
+
44
+ pass
45
+
46
+
47
+ class WebhookTokenRefreshError(WebhookError):
48
+ """Raised when token refresh fails in webhook context."""
49
+
50
+ pass
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Webhook payload model
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class WebhookPayloadModel:
60
+ """Verified webhook payload data.
61
+
62
+ Attributes:
63
+ user_id: The SweatStack user ID this webhook relates to.
64
+ event_type: The type of event (e.g., "activity_created").
65
+ resource_id: The ID of the affected resource.
66
+ timestamp: When the event occurred.
67
+ """
68
+
69
+ user_id: str
70
+ event_type: str
71
+ resource_id: str
72
+ timestamp: datetime
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Signature verification
77
+ # ---------------------------------------------------------------------------
78
+
79
+ TIMESTAMP_TOLERANCE = 300 # 5 minutes
80
+
81
+
82
+ def verify_signature(
83
+ payload: bytes,
84
+ signature_header: str,
85
+ secret: str,
86
+ ) -> None:
87
+ """Verify webhook signature.
88
+
89
+ SweatStack uses HMAC-SHA256 signatures. The signature header format is:
90
+ t={timestamp},v1={signature}
91
+
92
+ The signed payload is: {timestamp}.{raw_json_body}
93
+
94
+ Args:
95
+ payload: The raw request body bytes.
96
+ signature_header: The X-Sweatstack-Signature header value.
97
+ secret: The webhook secret.
98
+
99
+ Raises:
100
+ WebhookVerificationError: If signature is invalid or timestamp too old.
101
+ """
102
+ try:
103
+ parts = dict(p.split("=", 1) for p in signature_header.split(","))
104
+ timestamp = int(parts["t"])
105
+ signature = parts["v1"]
106
+ except (KeyError, ValueError) as e:
107
+ raise WebhookVerificationError("Invalid signature header format") from e
108
+
109
+ # Check timestamp to prevent replay attacks
110
+ if abs(time.time() - timestamp) > TIMESTAMP_TOLERANCE:
111
+ raise WebhookVerificationError("Timestamp outside tolerance window")
112
+
113
+ # Compute expected signature
114
+ signed_payload = f"{timestamp}.".encode() + payload
115
+ expected = hmac.new(
116
+ secret.encode(),
117
+ signed_payload,
118
+ hashlib.sha256,
119
+ ).hexdigest()
120
+
121
+ # Constant-time comparison to prevent timing attacks
122
+ if not hmac.compare_digest(expected, signature):
123
+ raise WebhookVerificationError("Invalid signature")
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # FastAPI dependencies
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ async def _detect_webhook_context(request: Request) -> WebhookPayloadModel | None:
132
+ """Detect if this is a webhook request and return verified payload.
133
+
134
+ This dependency is cached per-request by FastAPI. It's used by both
135
+ WebhookPayload (which requires it) and AuthenticatedUser (which uses it
136
+ to detect webhook context for token loading).
137
+
138
+ Returns:
139
+ WebhookPayloadModel if this is a verified webhook request, None otherwise.
140
+
141
+ Raises:
142
+ WebhookVerificationError: If signature header is present but invalid.
143
+ """
144
+ signature = request.headers.get("X-Sweatstack-Signature")
145
+ if not signature:
146
+ return None # Not a webhook request - fast path
147
+
148
+ config = get_config()
149
+
150
+ if not config.webhook_secret:
151
+ raise WebhookVerificationError(
152
+ "Webhook received but webhook_secret not configured. "
153
+ "Add webhook_secret to configure() to enable webhook handling."
154
+ )
155
+
156
+ body = await request.body()
157
+ secret = (
158
+ config.webhook_secret.get_secret_value()
159
+ if hasattr(config.webhook_secret, "get_secret_value")
160
+ else config.webhook_secret
161
+ )
162
+ verify_signature(body, signature, secret)
163
+
164
+ try:
165
+ data = json.loads(body)
166
+ return WebhookPayloadModel(
167
+ user_id=data["user_id"],
168
+ event_type=data["event_type"],
169
+ resource_id=data["resource_id"],
170
+ timestamp=datetime.fromisoformat(data["timestamp"]),
171
+ )
172
+ except (json.JSONDecodeError, KeyError, ValueError) as e:
173
+ raise WebhookVerificationError(f"Invalid webhook payload: {e}") from e
174
+
175
+
176
+ async def _require_webhook_payload(
177
+ webhook_context: Annotated[WebhookPayloadModel | None, Depends(_detect_webhook_context)],
178
+ ) -> WebhookPayloadModel:
179
+ """Dependency that requires a verified webhook payload.
180
+
181
+ Raises:
182
+ HTTPException: If request is not a valid webhook.
183
+ """
184
+ if webhook_context is None:
185
+ raise HTTPException(status_code=400, detail="Missing or invalid webhook signature")
186
+ return webhook_context
187
+
188
+
189
+ # Public type alias for use in endpoint signatures
190
+ WebhookPayload = Annotated[WebhookPayloadModel, Depends(_require_webhook_payload)]
191
+ """Dependency that returns a verified webhook payload.
192
+
193
+ The signature is verified before your handler runs. If verification fails,
194
+ a 400 response is returned automatically.
195
+
196
+ Example:
197
+ @app.post("/webhooks/sweatstack")
198
+ def handle_webhook(payload: WebhookPayload):
199
+ print(f"Event: {payload.event_type} for user {payload.user_id}")
200
+ return {"status": "received"}
201
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sweatstack
3
- Version: 0.60.0
3
+ Version: 0.62.0
4
4
  Summary: The official Python client for SweatStack
5
5
  Author-email: Aart Goossens <aart@gssns.io>
6
6
  Requires-Python: >=3.9
@@ -11,12 +11,15 @@ sweatstack/streamlit.py,sha256=wnabWhife9eMAdkECPjRKkzE82KZoi_H8YzucZl_m9s,19604
11
11
  sweatstack/sweatshell.py,sha256=MYLNcWbOdceqKJ3S0Pe8dwHXEeYsGJNjQoYUXpMTftA,333
12
12
  sweatstack/utils.py,sha256=AwHRdC1ziOZ5o9RBIB21Uxm-DoClVRAJSVvgsmSmvps,1801
13
13
  sweatstack/Sweat Stack examples/Getting started.ipynb,sha256=k2hiSffWecoQ0VxjdpDcgFzBXDQiYEebhnAYlu8cgX8,6335204
14
- sweatstack/fastapi/__init__.py,sha256=4_a6oqapT8Pv0sdt6OmuTA_vo4qFSiyXjN5WMLnl0rs,971
15
- sweatstack/fastapi/config.py,sha256=9u_XXGpX2XdOJO8G2sL_Cx4L_hYUzktozS2MkIs7Fwk,6304
16
- sweatstack/fastapi/dependencies.py,sha256=K9nuSV5Vduu4hoN94B8rj6UKtUBu9uKtSwEmECDG9vM,5060
17
- sweatstack/fastapi/routes.py,sha256=ZyaowLYXlOM6a74Cog5uSzPKHNTBvOpIBruaJVzhKjE,5339
14
+ sweatstack/fastapi/__init__.py,sha256=BteLHro6KCVknIgNAp6OS5ZJRWmIXHS4iXOLOSyN2Mc,3216
15
+ sweatstack/fastapi/config.py,sha256=nNiZOeAXbipK4l2mxaPfaLwz2Ml-Eu7n-G46yGlvjA4,8764
16
+ sweatstack/fastapi/dependencies.py,sha256=8kfKqe-js1G7LLe2AR8xpChtoCKrDG3-F0VkZyXMlIo,12620
17
+ sweatstack/fastapi/models.py,sha256=vBImNywlKiw-10ZODRAHZSrWG3Xfla8uuXA0Y-p0Opc,5499
18
+ sweatstack/fastapi/routes.py,sha256=qNRSod_ZWziFptInplmwmrOe5ZwpJbL7kE5LHWJIxC8,13106
18
19
  sweatstack/fastapi/session.py,sha256=BtRPCmIEaToJPwFyZ0fqWGlmnDHuWKy8nri9dJrPXaA,2717
19
- sweatstack-0.60.0.dist-info/METADATA,sha256=XJq9khIsRFJDUVGLB9Vv_qwRutrIHlU4QwHRsLQKdeU,994
20
- sweatstack-0.60.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
21
- sweatstack-0.60.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
22
- sweatstack-0.60.0.dist-info/RECORD,,
20
+ sweatstack/fastapi/token_stores.py,sha256=-waq0CQLszIp-2uLwWgkMz8IbsmUC7xopvM2F12sbMo,8207
21
+ sweatstack/fastapi/webhooks.py,sha256=ShRQRkarJ3vuEkT1lkl1-_oQbPQTLNkgQ2E6Mm4UepU,6066
22
+ sweatstack-0.62.0.dist-info/METADATA,sha256=SqQNBCJZibHU6qQaQi1ij-OLHAWt082zfxPrXSFiJY4,994
23
+ sweatstack-0.62.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
24
+ sweatstack-0.62.0.dist-info/entry_points.txt,sha256=kCzOUQI3dqbTpEYqtgYDeiKFaqaA7BMlV6D24BMzCFU,208
25
+ sweatstack-0.62.0.dist-info/RECORD,,