beadhub 0.1.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.
Files changed (54) hide show
  1. beadhub/__init__.py +12 -0
  2. beadhub/api.py +260 -0
  3. beadhub/auth.py +101 -0
  4. beadhub/aweb_context.py +65 -0
  5. beadhub/aweb_introspection.py +70 -0
  6. beadhub/beads_sync.py +514 -0
  7. beadhub/cli.py +330 -0
  8. beadhub/config.py +65 -0
  9. beadhub/db.py +129 -0
  10. beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
  11. beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
  12. beadhub/defaults/invariants/03-communication-chat.md +60 -0
  13. beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
  14. beadhub/defaults/invariants/05-collaborate.md +12 -0
  15. beadhub/defaults/roles/backend.md +55 -0
  16. beadhub/defaults/roles/coordinator.md +44 -0
  17. beadhub/defaults/roles/frontend.md +77 -0
  18. beadhub/defaults/roles/implementer.md +73 -0
  19. beadhub/defaults/roles/reviewer.md +56 -0
  20. beadhub/defaults/roles/startup-expert.md +93 -0
  21. beadhub/defaults.py +262 -0
  22. beadhub/events.py +704 -0
  23. beadhub/internal_auth.py +121 -0
  24. beadhub/jsonl.py +68 -0
  25. beadhub/logging.py +62 -0
  26. beadhub/migrations/beads/001_initial.sql +70 -0
  27. beadhub/migrations/beads/002_search_indexes.sql +20 -0
  28. beadhub/migrations/server/001_initial.sql +279 -0
  29. beadhub/names.py +33 -0
  30. beadhub/notifications.py +275 -0
  31. beadhub/pagination.py +125 -0
  32. beadhub/presence.py +495 -0
  33. beadhub/rate_limit.py +152 -0
  34. beadhub/redis_client.py +11 -0
  35. beadhub/roles.py +35 -0
  36. beadhub/routes/__init__.py +1 -0
  37. beadhub/routes/agents.py +303 -0
  38. beadhub/routes/bdh.py +655 -0
  39. beadhub/routes/beads.py +778 -0
  40. beadhub/routes/claims.py +141 -0
  41. beadhub/routes/escalations.py +471 -0
  42. beadhub/routes/init.py +348 -0
  43. beadhub/routes/mcp.py +338 -0
  44. beadhub/routes/policies.py +833 -0
  45. beadhub/routes/repos.py +538 -0
  46. beadhub/routes/status.py +568 -0
  47. beadhub/routes/subscriptions.py +362 -0
  48. beadhub/routes/workspaces.py +1642 -0
  49. beadhub/workspace_config.py +202 -0
  50. beadhub-0.1.0.dist-info/METADATA +254 -0
  51. beadhub-0.1.0.dist-info/RECORD +54 -0
  52. beadhub-0.1.0.dist-info/WHEEL +4 -0
  53. beadhub-0.1.0.dist-info/entry_points.txt +2 -0
  54. beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
beadhub/presence.py ADDED
@@ -0,0 +1,495 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Dict, List, Optional
5
+ from urllib.parse import quote
6
+
7
+ from redis.asyncio import Redis
8
+
9
+ from .roles import ROLE_MAX_LENGTH, is_valid_role, normalize_role
10
+
11
+ DEFAULT_PRESENCE_TTL_SECONDS = 1800 # 30 minutes
12
+
13
+
14
+ def _safe_key_component(value: str) -> str:
15
+ """URL-encode a value for safe use in Redis keys.
16
+
17
+ Prevents key collision attacks where values containing colons could
18
+ create ambiguous key boundaries. For example, without encoding:
19
+ project_id="abc", alias="xyz:def" -> "idx:alias:abc:xyz:def"
20
+ project_id="abc:xyz", alias="def" -> "idx:alias:abc:xyz:def" (COLLISION!)
21
+
22
+ With encoding:
23
+ project_id="abc", alias="xyz:def" -> "idx:alias:abc:xyz%3Adef"
24
+ project_id="abc:xyz", alias="def" -> "idx:alias:abc%3Axyz:def" (DISTINCT)
25
+ """
26
+ return quote(value, safe="")
27
+
28
+
29
+ def _presence_key(workspace_id: str) -> str:
30
+ """Presence key: one agent per workspace."""
31
+ return f"presence:{workspace_id}"
32
+
33
+
34
+ def _project_workspaces_index_key(project_id: str) -> str:
35
+ """Secondary index: workspace_ids by project_id."""
36
+ return f"idx:project_workspaces:{project_id}"
37
+
38
+
39
+ def _repo_workspaces_index_key(repo_id: str) -> str:
40
+ """Secondary index: workspace_ids by repo_id."""
41
+ return f"idx:repo_workspaces:{repo_id}"
42
+
43
+
44
+ def _branch_workspaces_index_key(repo_id: str, branch: str) -> str:
45
+ """Secondary index: workspace_ids by repo_id and branch."""
46
+ return f"idx:branch_workspaces:{repo_id}:{_safe_key_component(branch)}"
47
+
48
+
49
+ def _project_slug_workspaces_index_key(project_slug: str) -> str:
50
+ """Secondary index: workspace_ids by project_slug."""
51
+ return f"idx:project_slug_workspaces:{_safe_key_component(project_slug)}"
52
+
53
+
54
+ def _all_workspaces_index_key() -> str:
55
+ """Global index: all workspace_ids with active presence."""
56
+ return "idx:all_workspaces"
57
+
58
+
59
+ def _alias_index_key(project_id: str, alias: str) -> str:
60
+ """Secondary index: workspace_id by (project_id, alias).
61
+
62
+ Enables O(1) alias collision checking instead of SCAN.
63
+ Key maps to a single workspace_id (aliases are unique per project).
64
+ """
65
+ return f"idx:alias:{project_id}:{_safe_key_component(alias)}"
66
+
67
+
68
+ async def update_agent_presence(
69
+ redis: Redis,
70
+ workspace_id: str,
71
+ alias: str,
72
+ program: Optional[str],
73
+ model: Optional[str],
74
+ human_name: Optional[str] = None,
75
+ project_id: Optional[str] = None,
76
+ project_slug: Optional[str] = None,
77
+ repo_id: Optional[str] = None,
78
+ member_email: str = "",
79
+ status: str = "active",
80
+ current_branch: Optional[str] = None,
81
+ role: Optional[str] = None,
82
+ ttl_seconds: int = DEFAULT_PRESENCE_TTL_SECONDS,
83
+ ) -> str:
84
+ """
85
+ Upsert agent presence in Redis and return the ISO timestamp used.
86
+
87
+ Args:
88
+ workspace_id: UUID identifying the workspace.
89
+ alias: Human-friendly workspace identifier for addressing.
90
+ human_name: Name of the human who owns this workspace.
91
+ project_id: UUID of the project (for secondary index).
92
+ project_slug: Human-readable project slug.
93
+ repo_id: UUID of the repo (for secondary index).
94
+ current_branch: Optional branch name.
95
+ role: Brief description of workspace purpose (max 50 chars).
96
+ ttl_seconds: How long until presence expires if not refreshed. Default 5 minutes.
97
+ """
98
+ key = _presence_key(workspace_id)
99
+ now = datetime.now(timezone.utc).isoformat()
100
+
101
+ fields = {
102
+ "workspace_id": workspace_id,
103
+ "alias": alias,
104
+ "human_name": human_name or "",
105
+ "project_id": project_id or "",
106
+ "project_slug": project_slug or "",
107
+ "repo_id": repo_id or "",
108
+ "member_email": member_email,
109
+ "program": program or "",
110
+ "model": model or "",
111
+ "status": status,
112
+ "current_branch": current_branch or "",
113
+ "last_seen": now,
114
+ }
115
+ if role is not None and len(role) <= ROLE_MAX_LENGTH and is_valid_role(role):
116
+ fields["role"] = normalize_role(role)
117
+
118
+ await redis.hset(key, mapping=fields)
119
+ await redis.expire(key, ttl_seconds)
120
+
121
+ # Update secondary indexes
122
+ # Index TTL is 2x presence TTL to ensure index entries outlive presence keys,
123
+ # allowing lazy cleanup to detect stale entries via EXISTS checks.
124
+ # Note: workspace → project is immutable (see architecture docs), so project_id
125
+ # and project_slug don't change for a given workspace. Branch and repo indexes
126
+ # may have transient staleness (up to TTL*2) when workspaces switch branches.
127
+
128
+ # Global all_workspaces index (always maintained)
129
+ all_idx_key = _all_workspaces_index_key()
130
+ await redis.sadd(all_idx_key, workspace_id)
131
+ await redis.expire(all_idx_key, ttl_seconds * 2)
132
+
133
+ if project_id:
134
+ idx_key = _project_workspaces_index_key(project_id)
135
+ await redis.sadd(idx_key, workspace_id)
136
+ await redis.expire(idx_key, ttl_seconds * 2)
137
+
138
+ # Alias index for O(1) collision checking (1:1 mapping, not a set)
139
+ alias_idx_key = _alias_index_key(project_id, alias)
140
+ await redis.set(alias_idx_key, workspace_id, ex=ttl_seconds * 2)
141
+
142
+ if project_slug:
143
+ idx_key = _project_slug_workspaces_index_key(project_slug)
144
+ await redis.sadd(idx_key, workspace_id)
145
+ await redis.expire(idx_key, ttl_seconds * 2)
146
+
147
+ if repo_id:
148
+ idx_key = _repo_workspaces_index_key(repo_id)
149
+ await redis.sadd(idx_key, workspace_id)
150
+ await redis.expire(idx_key, ttl_seconds * 2)
151
+
152
+ if current_branch:
153
+ idx_key = _branch_workspaces_index_key(repo_id, current_branch)
154
+ await redis.sadd(idx_key, workspace_id)
155
+ await redis.expire(idx_key, ttl_seconds * 2)
156
+
157
+ return now
158
+
159
+
160
+ async def get_agent_presence(
161
+ redis: Redis,
162
+ workspace_id: str,
163
+ ) -> Optional[Dict[str, str]]:
164
+ """
165
+ Fetch an agent's presence record from Redis.
166
+
167
+ One agent per workspace architecture: workspace_id is the only key.
168
+
169
+ Args:
170
+ workspace_id: UUID identifying the workspace.
171
+ """
172
+ key = _presence_key(workspace_id)
173
+ data: Dict[str, str] = await redis.hgetall(key)
174
+ if not data:
175
+ return None
176
+ return data
177
+
178
+
179
+ async def list_agent_presences(
180
+ redis: Redis,
181
+ workspace_id: Optional[str] = None,
182
+ ) -> List[Dict[str, str]]:
183
+ """
184
+ List agent presence records.
185
+
186
+ Uses the all_workspaces secondary index for O(M) lookup where M is the
187
+ number of active workspaces, instead of O(N) SCAN over all Redis keys.
188
+
189
+ Args:
190
+ workspace_id: If provided, return the presence for this workspace only.
191
+ Otherwise, list all presences.
192
+ """
193
+ if workspace_id:
194
+ # One agent per workspace - direct lookup
195
+ key = _presence_key(workspace_id)
196
+ data: Dict[str, str] = await redis.hgetall(key)
197
+ return [data] if data else []
198
+
199
+ # List all presences using secondary index (avoids SCAN)
200
+ idx_key = _all_workspaces_index_key()
201
+ workspace_ids = await _filter_valid_workspace_ids(redis, idx_key)
202
+
203
+ if not workspace_ids:
204
+ return []
205
+
206
+ # Batch fetch all presence hashes with pipeline (N round-trips → 1)
207
+ pipe = redis.pipeline()
208
+ for ws_id in workspace_ids:
209
+ pipe.hgetall(_presence_key(ws_id))
210
+ presence_data = await pipe.execute()
211
+
212
+ results: List[Dict[str, str]] = []
213
+ for data in presence_data:
214
+ if data:
215
+ results.append(data)
216
+
217
+ return results
218
+
219
+
220
+ async def list_agent_presences_by_workspace_ids(
221
+ redis: Redis,
222
+ workspace_ids: List[str],
223
+ ) -> List[Dict[str, str]]:
224
+ """
225
+ Fetch presence records for specific workspace IDs.
226
+
227
+ This avoids scanning global indexes when callers already know which
228
+ workspaces they need to enrich with presence.
229
+ """
230
+ if not workspace_ids:
231
+ return []
232
+
233
+ pipe = redis.pipeline()
234
+ for ws_id in workspace_ids:
235
+ pipe.hgetall(_presence_key(ws_id))
236
+ presence_data = await pipe.execute()
237
+
238
+ results: List[Dict[str, str]] = []
239
+ for data in presence_data:
240
+ if data:
241
+ results.append(data)
242
+
243
+ return results
244
+
245
+
246
+ async def _filter_valid_workspace_ids(
247
+ redis: Redis,
248
+ idx_key: str,
249
+ ) -> List[str]:
250
+ """
251
+ Filter workspace_ids from a secondary index, removing stale entries.
252
+
253
+ Uses Redis pipeline to batch EXISTS checks (N+1 round-trips → 2:
254
+ one SMEMBERS + one pipeline for all EXISTS checks).
255
+
256
+ Stale entries (presence expired but index entry remains) are lazily
257
+ removed from the index. There's a theoretical race where a workspace
258
+ could be removed from the index just as it refreshes presence, but
259
+ the entry gets re-added on next presence update.
260
+
261
+ Args:
262
+ redis: Redis client.
263
+ idx_key: Key for the secondary index (Set).
264
+
265
+ Returns:
266
+ List of workspace_ids with active presence.
267
+ """
268
+ members = await redis.smembers(idx_key)
269
+ if not members:
270
+ return []
271
+
272
+ # Normalize to strings
273
+ workspace_ids = [
274
+ ws_id.decode("utf-8") if isinstance(ws_id, bytes) else ws_id for ws_id in members
275
+ ]
276
+
277
+ # Batch EXISTS checks with pipeline (N round-trips → 1)
278
+ pipe = redis.pipeline()
279
+ for ws_id in workspace_ids:
280
+ pipe.exists(_presence_key(ws_id))
281
+ exists_results = await pipe.execute()
282
+
283
+ # Separate valid and stale workspace_ids
284
+ valid_workspace_ids: List[str] = []
285
+ stale_workspace_ids: List[str] = []
286
+ for ws_id, exists in zip(workspace_ids, exists_results):
287
+ if exists:
288
+ valid_workspace_ids.append(ws_id)
289
+ else:
290
+ stale_workspace_ids.append(ws_id)
291
+
292
+ # Lazy cleanup: remove stale entries from index
293
+ if stale_workspace_ids:
294
+ cleanup_pipe = redis.pipeline()
295
+ for ws_id in stale_workspace_ids:
296
+ cleanup_pipe.srem(idx_key, ws_id)
297
+ await cleanup_pipe.execute()
298
+
299
+ return valid_workspace_ids
300
+
301
+
302
+ async def get_workspace_ids_by_project_slug(
303
+ redis: Redis,
304
+ project_slug: str,
305
+ ) -> List[str]:
306
+ """
307
+ Get all workspace_ids that belong to a project by slug.
308
+
309
+ Uses secondary index for O(M) lookup where M is workspaces in project.
310
+ Stale entries (presence expired but index entry remains) are filtered
311
+ out and lazily removed from the index.
312
+
313
+ Args:
314
+ project_slug: Human-readable project slug.
315
+
316
+ Returns:
317
+ List of workspace_ids with matching project_slug.
318
+ """
319
+ idx_key = _project_slug_workspaces_index_key(project_slug)
320
+ return await _filter_valid_workspace_ids(redis, idx_key)
321
+
322
+
323
+ async def get_workspace_ids_by_project_id(
324
+ redis: Redis,
325
+ project_id: str,
326
+ ) -> List[str]:
327
+ """
328
+ Get all workspace_ids that belong to a project by ID.
329
+
330
+ Uses secondary index for O(1) lookup. Stale entries (presence expired but
331
+ index entry remains) are filtered out and lazily removed from the index.
332
+
333
+ Args:
334
+ project_id: Project UUID.
335
+
336
+ Returns:
337
+ List of workspace_ids in the project.
338
+ """
339
+ idx_key = _project_workspaces_index_key(project_id)
340
+ return await _filter_valid_workspace_ids(redis, idx_key)
341
+
342
+
343
+ async def get_workspace_ids_by_repo_id(
344
+ redis: Redis,
345
+ repo_id: str,
346
+ ) -> List[str]:
347
+ """
348
+ Get all workspace_ids that belong to a repo by ID.
349
+
350
+ Uses secondary index for O(1) lookup. Stale entries (presence expired but
351
+ index entry remains) are filtered out and lazily removed from the index.
352
+
353
+ Args:
354
+ repo_id: Repo UUID.
355
+
356
+ Returns:
357
+ List of workspace_ids in the repo.
358
+ """
359
+ idx_key = _repo_workspaces_index_key(repo_id)
360
+ return await _filter_valid_workspace_ids(redis, idx_key)
361
+
362
+
363
+ async def get_workspace_ids_by_branch(
364
+ redis: Redis,
365
+ repo_id: str,
366
+ branch: str,
367
+ ) -> List[str]:
368
+ """
369
+ Get all workspace_ids working on a specific branch of a repo.
370
+
371
+ Uses secondary index for O(1) lookup. Stale entries (presence expired but
372
+ index entry remains) are filtered out and lazily removed from the index.
373
+
374
+ Args:
375
+ repo_id: Repo UUID.
376
+ branch: Branch name.
377
+
378
+ Returns:
379
+ List of workspace_ids on the branch.
380
+ """
381
+ idx_key = _branch_workspaces_index_key(repo_id, branch)
382
+ return await _filter_valid_workspace_ids(redis, idx_key)
383
+
384
+
385
+ async def get_all_workspace_ids(
386
+ redis: Redis,
387
+ ) -> List[str]:
388
+ """
389
+ Get all workspace_ids with active presence.
390
+
391
+ Uses the global all_workspaces index. Stale entries (presence expired but
392
+ index entry remains) are filtered out and lazily removed from the index.
393
+
394
+ Returns:
395
+ List of all active workspace_ids.
396
+ """
397
+ idx_key = _all_workspaces_index_key()
398
+ return await _filter_valid_workspace_ids(redis, idx_key)
399
+
400
+
401
+ async def get_workspace_id_by_alias(
402
+ redis: Redis,
403
+ project_id: str,
404
+ alias: str,
405
+ ) -> Optional[str]:
406
+ """
407
+ Get the workspace_id using a specific alias within a project.
408
+
409
+ Uses the alias secondary index for O(1) lookup. Returns the workspace_id
410
+ if the alias is in use and the workspace has active presence, None otherwise.
411
+
412
+ Note: This is for presence-based collision checking only. The database
413
+ (workspaces table) is the authoritative source for alias ownership.
414
+
415
+ Args:
416
+ project_id: Project UUID.
417
+ alias: The alias to look up.
418
+
419
+ Returns:
420
+ workspace_id if alias is in use with active presence, None otherwise.
421
+ """
422
+ idx_key = _alias_index_key(project_id, alias)
423
+ workspace_id = await redis.get(idx_key)
424
+
425
+ if not workspace_id:
426
+ return None
427
+
428
+ # Normalize to string (may be bytes depending on Redis client config)
429
+ ws_id = workspace_id.decode("utf-8") if isinstance(workspace_id, bytes) else workspace_id
430
+
431
+ # Verify presence is still active (index may outlive presence due to TTL difference)
432
+ presence_key = _presence_key(ws_id)
433
+ if not await redis.exists(presence_key):
434
+ # Stale index entry - lazy cleanup
435
+ await redis.delete(idx_key)
436
+ return None
437
+
438
+ return ws_id
439
+
440
+
441
+ async def get_workspace_project_slug(
442
+ redis: Redis,
443
+ workspace_id: str,
444
+ ) -> Optional[str]:
445
+ """Get the project_slug for a workspace from its presence data.
446
+
447
+ Args:
448
+ redis: Redis client.
449
+ workspace_id: Workspace UUID.
450
+
451
+ Returns:
452
+ project_slug if available, None otherwise.
453
+ """
454
+ data = await redis.hget(_presence_key(workspace_id), "project_slug")
455
+ if not data:
456
+ return None
457
+ slug = data.decode("utf-8") if isinstance(data, bytes) else data
458
+ return slug if slug else None
459
+
460
+
461
+ async def clear_workspace_presence(
462
+ redis: Redis,
463
+ workspace_ids: List[str],
464
+ ) -> int:
465
+ """
466
+ Clear presence for a list of workspaces.
467
+
468
+ Deletes presence keys and removes from all secondary indexes.
469
+ Used when soft-deleting repos or projects.
470
+
471
+ Args:
472
+ workspace_ids: List of workspace UUIDs to clear.
473
+
474
+ Returns:
475
+ Number of presence keys deleted.
476
+ """
477
+ if not workspace_ids:
478
+ return 0
479
+
480
+ # Delete presence keys
481
+ pipe = redis.pipeline()
482
+ for ws_id in workspace_ids:
483
+ pipe.delete(_presence_key(ws_id))
484
+ results = await pipe.execute()
485
+ deleted_count = sum(1 for r in results if r)
486
+
487
+ # Remove from all secondary indexes (lazy cleanup handles misses)
488
+ # We remove from all possible indexes to ensure cleanup
489
+ all_idx_key = _all_workspaces_index_key()
490
+ pipe = redis.pipeline()
491
+ for ws_id in workspace_ids:
492
+ pipe.srem(all_idx_key, ws_id)
493
+ await pipe.execute()
494
+
495
+ return deleted_count
beadhub/rate_limit.py ADDED
@@ -0,0 +1,152 @@
1
+ """Rate limiting for BeadHub endpoints.
2
+
3
+ Provides Redis-based rate limiting with fixed window counters.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ from typing import Optional
11
+
12
+ from fastapi import HTTPException, Request
13
+ from redis.asyncio import Redis
14
+ from redis.exceptions import RedisError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Rate limit configuration for /v1/init endpoint
19
+ # Can be overridden via environment for testing
20
+ INIT_RATE_LIMIT = int(os.getenv("BEADHUB_INIT_RATE_LIMIT", "10")) # requests per window
21
+ INIT_RATE_WINDOW = int(os.getenv("BEADHUB_INIT_RATE_WINDOW", "60")) # seconds
22
+
23
+ # Lua script for atomic increment with expiration
24
+ # This prevents race condition between INCR and EXPIRE
25
+ RATE_LIMIT_SCRIPT = """
26
+ local current = redis.call('INCR', KEYS[1])
27
+ if current == 1 then
28
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
29
+ end
30
+ return current
31
+ """
32
+
33
+
34
+ def get_client_ip(request: Request) -> str:
35
+ """Extract client IP address from request.
36
+
37
+ Uses request.client.host which is set by the ASGI server based on
38
+ the actual TCP connection. This is secure against X-Forwarded-For
39
+ spoofing attacks.
40
+
41
+ For deployments behind a reverse proxy, configure the proxy to set
42
+ the client IP correctly, or use FastAPI's TrustedHostMiddleware.
43
+ Do NOT trust X-Forwarded-For without proper proxy configuration.
44
+
45
+ Args:
46
+ request: FastAPI request object
47
+
48
+ Returns:
49
+ Client IP address string
50
+ """
51
+ # request.client can be None in some test scenarios
52
+ if request.client is None:
53
+ return "unknown"
54
+ return request.client.host
55
+
56
+
57
+ async def check_rate_limit(
58
+ request: Request,
59
+ redis: Redis,
60
+ key_prefix: str,
61
+ limit: int,
62
+ window_seconds: int,
63
+ ) -> Optional[int]:
64
+ """Check if request is within rate limit.
65
+
66
+ Uses a Lua script for atomic increment with expiration. The window
67
+ is fixed from the first request (not sliding).
68
+
69
+ Note: Fixed window allows burst at window boundaries (up to 2x limit
70
+ in a short period). This is acceptable for /v1/init.
71
+
72
+ Args:
73
+ request: FastAPI request object
74
+ redis: Async Redis client
75
+ key_prefix: Prefix for the rate limit key (e.g., "ratelimit:init")
76
+ limit: Maximum requests per window
77
+ window_seconds: Window duration in seconds
78
+
79
+ Returns:
80
+ None if within limit, otherwise seconds until limit resets
81
+
82
+ Raises:
83
+ RedisError: If Redis is unavailable (caller should handle)
84
+ """
85
+ client_ip = get_client_ip(request)
86
+ key = f"{key_prefix}:{client_ip}"
87
+
88
+ # Atomic increment with expiration using Lua script
89
+ # This prevents race condition where INCR succeeds but EXPIRE fails
90
+ current = await redis.eval(RATE_LIMIT_SCRIPT, 1, key, window_seconds)
91
+
92
+ if current > limit:
93
+ # Over limit - get TTL for Retry-After header
94
+ ttl = await redis.ttl(key)
95
+ if ttl < 0:
96
+ # Key has no expiry (shouldn't happen with Lua script)
97
+ # Delete and allow retry on next request
98
+ await redis.delete(key)
99
+ ttl = window_seconds
100
+ logger.error(
101
+ "Rate limit key without TTL detected and deleted: ip=%s key=%s",
102
+ client_ip,
103
+ key,
104
+ )
105
+ logger.warning(
106
+ "Rate limit exceeded: ip=%s key=%s count=%d limit=%d",
107
+ client_ip,
108
+ key,
109
+ current,
110
+ limit,
111
+ )
112
+ return ttl
113
+
114
+ return None
115
+
116
+
117
+ async def enforce_init_rate_limit(request: Request, redis: Redis) -> None:
118
+ """Enforce rate limit for /v1/init endpoint.
119
+
120
+ Fails closed: If Redis is unavailable, returns 503 Service Unavailable
121
+ rather than allowing unlimited requests.
122
+
123
+ Args:
124
+ request: FastAPI request object
125
+ redis: Async Redis client
126
+
127
+ Raises:
128
+ HTTPException: 429 Too Many Requests if rate limit exceeded
129
+ HTTPException: 503 Service Unavailable if Redis is unavailable
130
+ """
131
+ try:
132
+ retry_after = await check_rate_limit(
133
+ request=request,
134
+ redis=redis,
135
+ key_prefix="ratelimit:init",
136
+ limit=INIT_RATE_LIMIT,
137
+ window_seconds=INIT_RATE_WINDOW,
138
+ )
139
+ except RedisError as e:
140
+ # Fail closed: deny request if we can't check rate limit
141
+ logger.error("Redis error during rate limit check: %s", e)
142
+ raise HTTPException(
143
+ status_code=503,
144
+ detail="Service temporarily unavailable. Please retry.",
145
+ )
146
+
147
+ if retry_after is not None:
148
+ raise HTTPException(
149
+ status_code=429,
150
+ detail="Rate limit exceeded. Too many initialization requests.",
151
+ headers={"Retry-After": str(retry_after)},
152
+ )
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+ from redis.asyncio import Redis
5
+
6
+
7
+ def get_redis(request: Request) -> Redis:
8
+ """
9
+ FastAPI dependency that returns the shared async Redis client from app state.
10
+ """
11
+ return request.app.state.redis
beadhub/roles.py ADDED
@@ -0,0 +1,35 @@
1
+ """Role validation and normalization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ ROLE_MAX_LENGTH = 50
8
+ ROLE_MAX_WORDS = 2
9
+ ROLE_WORD_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
10
+ ROLE_ERROR_MESSAGE = (
11
+ "Invalid role: use 1-2 words (letters/numbers) with hyphens/underscores allowed; max 50 chars"
12
+ )
13
+
14
+
15
+ def normalize_role(role: str) -> str:
16
+ """Normalize role string by trimming, collapsing spaces, and lowercasing."""
17
+ return " ".join(role.strip().split()).lower()
18
+
19
+
20
+ def is_valid_role(role: str) -> bool:
21
+ """Check if role is 1-2 words with allowed characters."""
22
+ if not role or not isinstance(role, str):
23
+ return False
24
+ normalized = normalize_role(role)
25
+ if not normalized or len(normalized) > ROLE_MAX_LENGTH:
26
+ return False
27
+ words = normalized.split(" ")
28
+ if len(words) > ROLE_MAX_WORDS:
29
+ return False
30
+ return all(ROLE_WORD_PATTERN.match(word) for word in words)
31
+
32
+
33
+ def role_to_alias_prefix(role: str) -> str:
34
+ """Convert role to an alias-friendly prefix."""
35
+ return normalize_role(role).replace(" ", "-")
@@ -0,0 +1 @@
1
+