dominusnode 1.0.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.
dominusnode/teams.py ADDED
@@ -0,0 +1,339 @@
1
+ """Teams resource -- team management with members, invites, wallet, and API keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+ from urllib.parse import quote
7
+
8
+ from .http_client import AsyncHttpClient, SyncHttpClient
9
+ from .wallet import _validate_amount_cents
10
+ from .types import (
11
+ Team,
12
+ TeamMember,
13
+ TeamInvite,
14
+ TeamTransaction,
15
+ TeamKey,
16
+ TeamKeyCreateResponse,
17
+ TeamDeleteResponse,
18
+ )
19
+
20
+
21
+ def _parse_team(data: Dict[str, Any]) -> Team:
22
+ return Team(
23
+ id=data["id"],
24
+ name=data["name"],
25
+ owner_id=data["ownerId"],
26
+ max_members=data.get("maxMembers", 0),
27
+ status=data.get("status", "active"),
28
+ balance_cents=data.get("balanceCents", 0),
29
+ created_at=data.get("createdAt", ""),
30
+ )
31
+
32
+
33
+ def _parse_member(data: Dict[str, Any]) -> TeamMember:
34
+ return TeamMember(
35
+ id=data.get("id", ""),
36
+ team_id=data.get("teamId", ""),
37
+ user_id=data["userId"],
38
+ email=data["email"],
39
+ role=data["role"],
40
+ joined_at=data.get("joinedAt", ""),
41
+ )
42
+
43
+
44
+ def _parse_invite(data: Dict[str, Any]) -> TeamInvite:
45
+ return TeamInvite(
46
+ id=data["id"],
47
+ team_id=data["teamId"],
48
+ email=data["email"],
49
+ role=data["role"],
50
+ token=data.get("token", ""),
51
+ expires_at=data.get("expiresAt", ""),
52
+ created_at=data.get("createdAt", ""),
53
+ )
54
+
55
+
56
+ def _parse_transaction(data: Dict[str, Any]) -> TeamTransaction:
57
+ return TeamTransaction(
58
+ id=data["id"],
59
+ team_id=data["teamId"],
60
+ type=data["type"],
61
+ amount_cents=data["amountCents"],
62
+ description=data["description"],
63
+ created_at=data.get("createdAt", ""),
64
+ )
65
+
66
+
67
+ def _parse_key(data: Dict[str, Any]) -> TeamKey:
68
+ return TeamKey(
69
+ id=data["id"],
70
+ key_prefix=data["keyPrefix"],
71
+ label=data["label"],
72
+ created_at=data.get("createdAt", ""),
73
+ )
74
+
75
+
76
+ class TeamsResource:
77
+ """Synchronous team operations."""
78
+
79
+ def __init__(self, http: SyncHttpClient) -> None:
80
+ self._http = http
81
+
82
+ def create(self, name: str, max_members: Optional[int] = None) -> Team:
83
+ """Create a new team."""
84
+ body: Dict[str, Any] = {"name": name}
85
+ if max_members is not None:
86
+ body["maxMembers"] = max_members
87
+ data = self._http.post("/api/teams", json=body)
88
+ return _parse_team(data)
89
+
90
+ def list(self) -> List[Team]:
91
+ """List all teams the user belongs to."""
92
+ data = self._http.get("/api/teams")
93
+ return [_parse_team(t) for t in data]
94
+
95
+ def get(self, team_id: str) -> Team:
96
+ """Get a single team by ID."""
97
+ data = self._http.get(f"/api/teams/{quote(team_id, safe='')}")
98
+ return _parse_team(data)
99
+
100
+ def update(self, team_id: str, name: Optional[str] = None, max_members: Optional[int] = None) -> Team:
101
+ """Update a team's settings."""
102
+ body: Dict[str, Any] = {}
103
+ if name is not None:
104
+ body["name"] = name
105
+ if max_members is not None:
106
+ body["maxMembers"] = max_members
107
+ data = self._http.patch(f"/api/teams/{quote(team_id, safe='')}", json=body)
108
+ return _parse_team(data)
109
+
110
+ def delete(self, team_id: str) -> TeamDeleteResponse:
111
+ """Delete a team and refund remaining wallet balance."""
112
+ data = self._http.delete(f"/api/teams/{quote(team_id, safe='')}")
113
+ return TeamDeleteResponse(refunded_cents=data["refundedCents"])
114
+
115
+ def fund_wallet(self, team_id: str, amount_cents: int) -> TeamTransaction:
116
+ """Fund the team wallet from the user's main wallet."""
117
+ _validate_amount_cents(amount_cents, "amount_cents")
118
+ data = self._http.post(
119
+ f"/api/teams/{quote(team_id, safe='')}/wallet/fund",
120
+ json={"amountCents": amount_cents},
121
+ )
122
+ return _parse_transaction(data["transaction"])
123
+
124
+ def get_transactions(self, team_id: str, limit: int = 50, offset: int = 0) -> List[TeamTransaction]:
125
+ """Get wallet transaction history for a team."""
126
+ data = self._http.get(
127
+ f"/api/teams/{quote(team_id, safe='')}/wallet/transactions?limit={limit}&offset={offset}"
128
+ )
129
+ return [_parse_transaction(tx) for tx in data["transactions"]]
130
+
131
+ def list_members(self, team_id: str) -> List[TeamMember]:
132
+ """List all members of a team."""
133
+ data = self._http.get(f"/api/teams/{quote(team_id, safe='')}/members")
134
+ return [_parse_member(m) for m in data["members"]]
135
+
136
+ def add_member(self, team_id: str, email: str, role: Optional[str] = None) -> TeamMember:
137
+ """Add a member to a team."""
138
+ body: Dict[str, Any] = {"email": email}
139
+ if role is not None:
140
+ body["role"] = role
141
+ data = self._http.post(f"/api/teams/{quote(team_id, safe='')}/members", json=body)
142
+ return _parse_member(data)
143
+
144
+ def update_member_role(self, team_id: str, user_id: str, role: str) -> TeamMember:
145
+ """Update a member's role within a team."""
146
+ data = self._http.patch(
147
+ f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}",
148
+ json={"role": role},
149
+ )
150
+ return _parse_member(data)
151
+
152
+ def remove_member(self, team_id: str, user_id: str) -> None:
153
+ """Remove a member from a team."""
154
+ self._http.delete(
155
+ f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}"
156
+ )
157
+
158
+ def create_invite(self, team_id: str, email: str, role: Optional[str] = None) -> TeamInvite:
159
+ """Create an invite for a team."""
160
+ body: Dict[str, Any] = {"email": email}
161
+ if role is not None:
162
+ body["role"] = role
163
+ data = self._http.post(f"/api/teams/{quote(team_id, safe='')}/invites", json=body)
164
+ return _parse_invite(data)
165
+
166
+ def list_invites(self, team_id: str) -> List[TeamInvite]:
167
+ """List pending invites for a team."""
168
+ data = self._http.get(f"/api/teams/{quote(team_id, safe='')}/invites")
169
+ return [_parse_invite(i) for i in data["invites"]]
170
+
171
+ def cancel_invite(self, team_id: str, invite_id: str) -> None:
172
+ """Cancel a pending invite."""
173
+ self._http.delete(
174
+ f"/api/teams/{quote(team_id, safe='')}/invites/{quote(invite_id, safe='')}"
175
+ )
176
+
177
+ def accept_invite(self, token: str) -> TeamMember:
178
+ """Accept a team invite using the invite token."""
179
+ data = self._http.post(
180
+ f"/api/teams/invites/{quote(token, safe='')}/accept", json={}
181
+ )
182
+ return _parse_member(data)
183
+
184
+ def create_key(self, team_id: str, label: str) -> TeamKeyCreateResponse:
185
+ """Create a new API key for the team."""
186
+ data = self._http.post(
187
+ f"/api/teams/{quote(team_id, safe='')}/keys",
188
+ json={"label": label},
189
+ )
190
+ return TeamKeyCreateResponse(
191
+ id=data["id"],
192
+ key=data["key"],
193
+ key_prefix=data["keyPrefix"],
194
+ label=data["label"],
195
+ )
196
+
197
+ def list_keys(self, team_id: str) -> List[TeamKey]:
198
+ """List API keys for a team."""
199
+ data = self._http.get(f"/api/teams/{quote(team_id, safe='')}/keys")
200
+ return [_parse_key(k) for k in data["keys"]]
201
+
202
+ def revoke_key(self, team_id: str, key_id: str) -> None:
203
+ """Revoke a team API key by its ID."""
204
+ self._http.delete(
205
+ f"/api/teams/{quote(team_id, safe='')}/keys/{quote(key_id, safe='')}"
206
+ )
207
+
208
+
209
+ class AsyncTeamsResource:
210
+ """Asynchronous team operations."""
211
+
212
+ def __init__(self, http: AsyncHttpClient) -> None:
213
+ self._http = http
214
+
215
+ async def create(self, name: str, max_members: Optional[int] = None) -> Team:
216
+ """Create a new team."""
217
+ body: Dict[str, Any] = {"name": name}
218
+ if max_members is not None:
219
+ body["maxMembers"] = max_members
220
+ data = await self._http.post("/api/teams", json=body)
221
+ return _parse_team(data)
222
+
223
+ async def list(self) -> List[Team]:
224
+ """List all teams the user belongs to."""
225
+ data = await self._http.get("/api/teams")
226
+ return [_parse_team(t) for t in data]
227
+
228
+ async def get(self, team_id: str) -> Team:
229
+ """Get a single team by ID."""
230
+ data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}")
231
+ return _parse_team(data)
232
+
233
+ async def update(self, team_id: str, name: Optional[str] = None, max_members: Optional[int] = None) -> Team:
234
+ """Update a team's settings."""
235
+ body: Dict[str, Any] = {}
236
+ if name is not None:
237
+ body["name"] = name
238
+ if max_members is not None:
239
+ body["maxMembers"] = max_members
240
+ data = await self._http.patch(f"/api/teams/{quote(team_id, safe='')}", json=body)
241
+ return _parse_team(data)
242
+
243
+ async def delete(self, team_id: str) -> TeamDeleteResponse:
244
+ """Delete a team and refund remaining wallet balance."""
245
+ data = await self._http.delete(f"/api/teams/{quote(team_id, safe='')}")
246
+ return TeamDeleteResponse(refunded_cents=data["refundedCents"])
247
+
248
+ async def fund_wallet(self, team_id: str, amount_cents: int) -> TeamTransaction:
249
+ """Fund the team wallet from the user's main wallet."""
250
+ _validate_amount_cents(amount_cents, "amount_cents")
251
+ data = await self._http.post(
252
+ f"/api/teams/{quote(team_id, safe='')}/wallet/fund",
253
+ json={"amountCents": amount_cents},
254
+ )
255
+ return _parse_transaction(data["transaction"])
256
+
257
+ async def get_transactions(self, team_id: str, limit: int = 50, offset: int = 0) -> List[TeamTransaction]:
258
+ """Get wallet transaction history for a team."""
259
+ data = await self._http.get(
260
+ f"/api/teams/{quote(team_id, safe='')}/wallet/transactions?limit={limit}&offset={offset}"
261
+ )
262
+ return [_parse_transaction(tx) for tx in data["transactions"]]
263
+
264
+ async def list_members(self, team_id: str) -> List[TeamMember]:
265
+ """List all members of a team."""
266
+ data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}/members")
267
+ return [_parse_member(m) for m in data["members"]]
268
+
269
+ async def add_member(self, team_id: str, email: str, role: Optional[str] = None) -> TeamMember:
270
+ """Add a member to a team."""
271
+ body: Dict[str, Any] = {"email": email}
272
+ if role is not None:
273
+ body["role"] = role
274
+ data = await self._http.post(f"/api/teams/{quote(team_id, safe='')}/members", json=body)
275
+ return _parse_member(data)
276
+
277
+ async def update_member_role(self, team_id: str, user_id: str, role: str) -> TeamMember:
278
+ """Update a member's role within a team."""
279
+ data = await self._http.patch(
280
+ f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}",
281
+ json={"role": role},
282
+ )
283
+ return _parse_member(data)
284
+
285
+ async def remove_member(self, team_id: str, user_id: str) -> None:
286
+ """Remove a member from a team."""
287
+ await self._http.delete(
288
+ f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}"
289
+ )
290
+
291
+ async def create_invite(self, team_id: str, email: str, role: Optional[str] = None) -> TeamInvite:
292
+ """Create an invite for a team."""
293
+ body: Dict[str, Any] = {"email": email}
294
+ if role is not None:
295
+ body["role"] = role
296
+ data = await self._http.post(f"/api/teams/{quote(team_id, safe='')}/invites", json=body)
297
+ return _parse_invite(data)
298
+
299
+ async def list_invites(self, team_id: str) -> List[TeamInvite]:
300
+ """List pending invites for a team."""
301
+ data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}/invites")
302
+ return [_parse_invite(i) for i in data["invites"]]
303
+
304
+ async def cancel_invite(self, team_id: str, invite_id: str) -> None:
305
+ """Cancel a pending invite."""
306
+ await self._http.delete(
307
+ f"/api/teams/{quote(team_id, safe='')}/invites/{quote(invite_id, safe='')}"
308
+ )
309
+
310
+ async def accept_invite(self, token: str) -> TeamMember:
311
+ """Accept a team invite using the invite token."""
312
+ data = await self._http.post(
313
+ f"/api/teams/invites/{quote(token, safe='')}/accept", json={}
314
+ )
315
+ return _parse_member(data)
316
+
317
+ async def create_key(self, team_id: str, label: str) -> TeamKeyCreateResponse:
318
+ """Create a new API key for the team."""
319
+ data = await self._http.post(
320
+ f"/api/teams/{quote(team_id, safe='')}/keys",
321
+ json={"label": label},
322
+ )
323
+ return TeamKeyCreateResponse(
324
+ id=data["id"],
325
+ key=data["key"],
326
+ key_prefix=data["keyPrefix"],
327
+ label=data["label"],
328
+ )
329
+
330
+ async def list_keys(self, team_id: str) -> List[TeamKey]:
331
+ """List API keys for a team."""
332
+ data = await self._http.get(f"/api/teams/{quote(team_id, safe='')}/keys")
333
+ return [_parse_key(k) for k in data["keys"]]
334
+
335
+ async def revoke_key(self, team_id: str, key_id: str) -> None:
336
+ """Revoke a team API key by its ID."""
337
+ await self._http.delete(
338
+ f"/api/teams/{quote(team_id, safe='')}/keys/{quote(key_id, safe='')}"
339
+ )
@@ -0,0 +1,235 @@
1
+ """JWT token management with auto-refresh.
2
+
3
+ Tokens are stored in memory only -- never written to disk.
4
+ JWT expiry is decoded from the base64 payload without cryptographic
5
+ verification (the server validates the signature).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import base64
12
+ import json
13
+ import threading
14
+ import time
15
+ from typing import TYPE_CHECKING, Callable, Optional
16
+
17
+ from .constants import TOKEN_REFRESH_BUFFER_SECONDS
18
+
19
+ if TYPE_CHECKING:
20
+ from typing import Awaitable
21
+
22
+
23
+ class TokenManager:
24
+ """Sync token manager with threading.Lock for singleton refresh."""
25
+
26
+ def __init__(self) -> None:
27
+ self._access_token: Optional[str] = None
28
+ self._refresh_token: Optional[str] = None
29
+ self._refresh_lock = threading.Lock()
30
+ self._refresh_fn: Optional[Callable[[str], tuple[str, str]]] = None
31
+
32
+ def set_tokens(self, access_token: str, refresh_token: str) -> None:
33
+ """Store a new token pair."""
34
+ self._access_token = access_token
35
+ self._refresh_token = refresh_token
36
+
37
+ def set_refresh_fn(self, fn: Callable[[str], tuple[str, str]]) -> None:
38
+ """Register the sync function that performs token refresh.
39
+
40
+ The function receives the current refresh token and must return
41
+ a (new_access_token, new_refresh_token) tuple.
42
+ """
43
+ self._refresh_fn = fn
44
+
45
+ def clear(self) -> None:
46
+ """Remove stored tokens."""
47
+ self._access_token = None
48
+ self._refresh_token = None
49
+
50
+ @property
51
+ def access_token(self) -> Optional[str]:
52
+ return self._access_token
53
+
54
+ @property
55
+ def refresh_token(self) -> Optional[str]:
56
+ return self._refresh_token
57
+
58
+ @property
59
+ def has_tokens(self) -> bool:
60
+ return self._access_token is not None
61
+
62
+ @property
63
+ def has_refresh_token(self) -> bool:
64
+ return self._refresh_token is not None
65
+
66
+ def get_valid_token(self) -> Optional[str]:
67
+ """Return a valid access token, refreshing if needed.
68
+
69
+ Returns None if no tokens are available or refresh fails.
70
+ """
71
+ if self._access_token is None:
72
+ return None
73
+
74
+ if not is_expired(self._access_token):
75
+ return self._access_token
76
+
77
+ # Token expired or about to expire -- try refresh
78
+ return self._do_refresh()
79
+
80
+ def force_refresh(self) -> Optional[str]:
81
+ """Force a token refresh regardless of expiry (e.g., after 401).
82
+
83
+ Raises RuntimeError if refresh fails instead of returning None,
84
+ preventing "Bearer None" from being sent as an auth header.
85
+ """
86
+ return self._do_refresh(force=True)
87
+
88
+ def _do_refresh(self, force: bool = False) -> Optional[str]:
89
+ """Perform a singleton refresh under lock."""
90
+ if self._refresh_token is None or self._refresh_fn is None:
91
+ if force:
92
+ raise RuntimeError("No valid token and cannot refresh")
93
+ return None
94
+
95
+ with self._refresh_lock:
96
+ # Double-check: another thread may have refreshed while we waited
97
+ # Skip double-check on force_refresh — the whole point is to NOT reuse current token
98
+ if not force and self._access_token and not is_expired(self._access_token):
99
+ return self._access_token
100
+
101
+ try:
102
+ new_access, new_refresh = self._refresh_fn(self._refresh_token)
103
+ self._access_token = new_access
104
+ self._refresh_token = new_refresh
105
+ return self._access_token
106
+ except Exception:
107
+ self.clear()
108
+ if force:
109
+ raise RuntimeError("Token refresh failed")
110
+ return None
111
+
112
+
113
+ class AsyncTokenManager:
114
+ """Async token manager with asyncio.Lock for singleton refresh.
115
+
116
+ The lock is created lazily on first use to avoid binding to an event loop
117
+ at __init__ time, which would fail if the TokenManager is constructed
118
+ outside of an async context (e.g., in frameworks that manage their own
119
+ event loops like FastAPI/uvicorn or Django ASGI).
120
+ """
121
+
122
+ def __init__(self) -> None:
123
+ self._access_token: Optional[str] = None
124
+ self._refresh_token: Optional[str] = None
125
+ self._refresh_lock: Optional[asyncio.Lock] = None
126
+ self._refresh_fn: Optional[Callable[[str], Awaitable[tuple[str, str]]]] = None
127
+
128
+ def _get_lock(self) -> asyncio.Lock:
129
+ """Lazily create the asyncio.Lock on first use within an async context."""
130
+ if self._refresh_lock is None:
131
+ self._refresh_lock = asyncio.Lock()
132
+ return self._refresh_lock
133
+
134
+ def set_tokens(self, access_token: str, refresh_token: str) -> None:
135
+ """Store a new token pair."""
136
+ self._access_token = access_token
137
+ self._refresh_token = refresh_token
138
+
139
+ def set_refresh_fn(self, fn: Callable[[str], Awaitable[tuple[str, str]]]) -> None:
140
+ """Register the async function that performs token refresh."""
141
+ self._refresh_fn = fn
142
+
143
+ def clear(self) -> None:
144
+ """Remove stored tokens."""
145
+ self._access_token = None
146
+ self._refresh_token = None
147
+
148
+ @property
149
+ def access_token(self) -> Optional[str]:
150
+ return self._access_token
151
+
152
+ @property
153
+ def refresh_token(self) -> Optional[str]:
154
+ return self._refresh_token
155
+
156
+ @property
157
+ def has_tokens(self) -> bool:
158
+ return self._access_token is not None
159
+
160
+ @property
161
+ def has_refresh_token(self) -> bool:
162
+ return self._refresh_token is not None
163
+
164
+ async def get_valid_token(self) -> Optional[str]:
165
+ """Return a valid access token, refreshing if needed."""
166
+ if self._access_token is None:
167
+ return None
168
+
169
+ if not is_expired(self._access_token):
170
+ return self._access_token
171
+
172
+ return await self._do_refresh()
173
+
174
+ async def force_refresh(self) -> Optional[str]:
175
+ """Force a token refresh regardless of expiry (e.g., after 401).
176
+
177
+ Raises RuntimeError if refresh fails instead of returning None.
178
+ """
179
+ return await self._do_refresh(force=True)
180
+
181
+ async def _do_refresh(self, force: bool = False) -> Optional[str]:
182
+ """Perform a singleton refresh under async lock."""
183
+ if self._refresh_token is None or self._refresh_fn is None:
184
+ if force:
185
+ raise RuntimeError("No valid token and cannot refresh")
186
+ return None
187
+
188
+ async with self._get_lock():
189
+ # Skip double-check on force_refresh
190
+ if not force and self._access_token and not is_expired(self._access_token):
191
+ return self._access_token
192
+
193
+ try:
194
+ new_access, new_refresh = await self._refresh_fn(self._refresh_token)
195
+ self._access_token = new_access
196
+ self._refresh_token = new_refresh
197
+ return self._access_token
198
+ except Exception:
199
+ self.clear()
200
+ if force:
201
+ raise RuntimeError("Token refresh failed")
202
+ return None
203
+
204
+
205
+ def is_expired(token: str) -> bool:
206
+ """Check if a JWT is expired or will expire within the buffer window.
207
+
208
+ Decodes the base64 payload to read the ``exp`` claim. No signature
209
+ verification is performed -- that is the server's responsibility.
210
+
211
+ Returns True if the token is expired / about to expire, or if the
212
+ token cannot be decoded.
213
+ """
214
+ try:
215
+ parts = token.split(".")
216
+ if len(parts) != 3:
217
+ return True
218
+
219
+ # Base64url decode the payload (second segment)
220
+ payload_b64 = parts[1]
221
+ # Add padding
222
+ padding = 4 - len(payload_b64) % 4
223
+ if padding != 4:
224
+ payload_b64 += "=" * padding
225
+
226
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
227
+ payload = json.loads(payload_bytes)
228
+
229
+ exp = payload.get("exp")
230
+ if exp is None:
231
+ return True
232
+
233
+ return time.time() >= (exp - TOKEN_REFRESH_BUFFER_SECONDS)
234
+ except Exception:
235
+ return True