devtorch-core 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,182 @@
1
+ """
2
+ Sprint 4 – Invariant engine (I1 Information Persistence, I3 Semantic Grounding).
3
+
4
+ InvariantEngine registers check functions and evaluates them against a context,
5
+ returning actionable InvariantFailure results. ConceptStore persists concept
6
+ definitions with hashes for I3 semantic handshake.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Callable, Dict, List, Optional
16
+
17
+ CONCEPTS_DIR_NAME = "concepts"
18
+
19
+
20
+ @dataclass
21
+ class InvariantFailure:
22
+ """A single invariant violation with an actionable fix hint."""
23
+ invariant_id: str
24
+ message: str
25
+ actionable_fix: str
26
+
27
+
28
+ @dataclass
29
+ class InvariantContext:
30
+ """Context passed to invariant check functions (e.g. for merge or lock-release)."""
31
+ operation: str # "merge", "lock_release", "finalize"
32
+ branch_tips: Optional[Dict[str, str]] = None # branch -> commit_id
33
+ concepts_used: Optional[List[str]] = None
34
+
35
+
36
+ def _content_hash(text: str) -> str:
37
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
38
+
39
+
40
+ class ConceptStore:
41
+ """
42
+ Persist concept definitions under .GCC/concepts/<name>.json with content hash.
43
+ I3 semantic grounding requires handshake: operations touching a concept
44
+ must have a matching definition hash.
45
+ """
46
+ def __init__(self, gcc_dir: Path) -> None:
47
+ self.concepts_dir = gcc_dir / CONCEPTS_DIR_NAME
48
+
49
+ def ensure_dir(self) -> None:
50
+ self.concepts_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ def add(self, name: str, definition: str) -> str:
53
+ """Store concept definition; return its content hash."""
54
+ self.ensure_dir()
55
+ h = _content_hash(definition)
56
+ path = self.concepts_dir / f"{name}.json"
57
+ path.write_text(
58
+ json.dumps({"name": name, "definition": definition, "hash": h}, indent=2) + "\n",
59
+ encoding="utf-8",
60
+ )
61
+ return h
62
+
63
+ def get(self, name: str) -> Optional[dict]:
64
+ """Return {"name", "definition", "hash"} or None."""
65
+ path = self.concepts_dir / f"{name}.json"
66
+ if not path.exists():
67
+ return None
68
+ return json.loads(path.read_text(encoding="utf-8"))
69
+
70
+ def hash_for(self, name: str) -> Optional[str]:
71
+ """Return stored content hash for concept or None."""
72
+ obj = self.get(name)
73
+ return obj.get("hash") if obj else None
74
+
75
+ def list_concepts(self) -> List[str]:
76
+ """Return all concept names."""
77
+ self.ensure_dir()
78
+ return [p.stem for p in self.concepts_dir.glob("*.json")]
79
+
80
+
81
+ class InvariantEngine:
82
+ """
83
+ Register and evaluate invariants. Each invariant is a function
84
+ (repo, context) -> list[InvariantFailure].
85
+ """
86
+ def __init__(self) -> None:
87
+ self._checkers: List[tuple[str, Callable]] = []
88
+
89
+ def register(self, invariant_id: str, check_fn: Callable) -> None:
90
+ self._checkers.append((invariant_id, check_fn))
91
+
92
+ def evaluate(self, repo, context: InvariantContext) -> List[InvariantFailure]:
93
+ failures: List[InvariantFailure] = []
94
+ for inv_id, check_fn in self._checkers:
95
+ try:
96
+ result = check_fn(repo, context)
97
+ if result:
98
+ failures.extend(result)
99
+ except Exception as e:
100
+ failures.append(
101
+ InvariantFailure(
102
+ inv_id,
103
+ str(e),
104
+ "Fix the underlying error and re-run the operation.",
105
+ )
106
+ )
107
+ return failures
108
+
109
+
110
+ # --- I1: Information Persistence -------------------------------------------------
111
+ # Decision-finalizing operations must be backed by committed history: the branch
112
+ # tip must exist as a commit object and the decision must be reflected in the event log.
113
+
114
+ def check_i1_commit_backed(repo, context: InvariantContext) -> List[InvariantFailure]:
115
+ """
116
+ I1: Every branch tip in context must exist in commits/ and have a COMMIT event.
117
+ """
118
+ if not context.branch_tips:
119
+ return []
120
+ failures: List[InvariantFailure] = []
121
+ commits_dir = repo.gcc_dir / "commits"
122
+ event_log = repo.gcc_dir / "events.log.jsonl"
123
+ commit_ids_in_log = set()
124
+ if event_log.exists():
125
+ for line in event_log.open("r", encoding="utf-8"):
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+ try:
130
+ obj = json.loads(line)
131
+ if obj.get("event_type") == "COMMIT":
132
+ cid = (obj.get("payload") or {}).get("commit_id")
133
+ if cid:
134
+ commit_ids_in_log.add(cid)
135
+ except json.JSONDecodeError:
136
+ continue
137
+ for branch, tip in context.branch_tips.items():
138
+ if not tip:
139
+ continue
140
+ commit_path = commits_dir / f"{tip}.json"
141
+ if not commit_path.exists():
142
+ failures.append(
143
+ InvariantFailure(
144
+ "I1",
145
+ f"Branch '{branch}' tip {tip} has no commit object in .GCC/commits/.",
146
+ "Ensure the branch has at least one devtorch commit before merge or lock-release.",
147
+ )
148
+ )
149
+ elif tip not in commit_ids_in_log:
150
+ failures.append(
151
+ InvariantFailure(
152
+ "I1",
153
+ f"Commit {tip} is not recorded in the event log (no COMMIT event).",
154
+ "Do not modify .GCC/commits/ or refs manually; use devtorch commit.",
155
+ )
156
+ )
157
+ return failures
158
+
159
+
160
+ # --- I3: Semantic Grounding ------------------------------------------------------
161
+ # Operations that touch defined concepts require a handshake: the concept must exist
162
+ # in ConceptStore with a matching hash.
163
+
164
+ def make_i3_semantic_handshake(concept_store: ConceptStore) -> Callable:
165
+ """Return a checker that requires all concepts_used to be defined and hash-matched."""
166
+
167
+ def check_i3(repo, context: InvariantContext) -> List[InvariantFailure]:
168
+ if not context.concepts_used:
169
+ return []
170
+ failures: List[InvariantFailure] = []
171
+ for name in context.concepts_used:
172
+ if concept_store.get(name) is None:
173
+ failures.append(
174
+ InvariantFailure(
175
+ "I3",
176
+ f"Concept '{name}' has no definition in .GCC/concepts/.",
177
+ f"Run: devtorch concept add {name} '<definition>'",
178
+ )
179
+ )
180
+ return failures
181
+
182
+ return check_i3
@@ -0,0 +1,10 @@
1
+ """
2
+ DevTorch MCP server package.
3
+
4
+ Exposes DevTorch primitives as Model Context Protocol tools that
5
+ Claude Code can call natively.
6
+ """
7
+
8
+ from .server import main, find_gcc_repo
9
+
10
+ __all__ = ["main", "find_gcc_repo"]
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import threading
7
+ import time
8
+ from typing import Any, Optional
9
+
10
+ from starlette.middleware.base import BaseHTTPMiddleware
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse
13
+
14
+ EXEMPT_PATHS = frozenset({"/health", "/readyz"})
15
+
16
+
17
+ class APIKeyMiddleware(BaseHTTPMiddleware):
18
+ """
19
+ Validates X-DevTorch-Key header against SHA-256 hashes stored in R2
20
+ at {org_id}/.devtorch/api_keys.json.
21
+
22
+ Injects request.state.org_id and request.state.allowed_repos on success.
23
+ Org is taken from URL path: /{org_id}/{repo_id}/...
24
+ """
25
+
26
+ API_KEYS_KEY = ".devtorch/api_keys.json"
27
+
28
+ def __init__(self, app: Any, backend: Any) -> None:
29
+ super().__init__(app)
30
+ self._backend = backend
31
+
32
+ async def dispatch(self, request: Request, call_next: Any) -> Any:
33
+ if request.url.path in EXEMPT_PATHS:
34
+ return await call_next(request)
35
+ raw_key = request.headers.get("X-DevTorch-Key")
36
+ if raw_key is None:
37
+ return JSONResponse({"detail": "Missing X-DevTorch-Key"}, status_code=401)
38
+
39
+ key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
40
+
41
+ try:
42
+ data = json.loads(self._backend.read_bytes(self.API_KEYS_KEY))
43
+ except (FileNotFoundError, json.JSONDecodeError):
44
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
45
+
46
+ entry = next(
47
+ (v for k, v in data.items() if hmac.compare_digest(k, key_hash)),
48
+ None,
49
+ )
50
+ if entry is None:
51
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
52
+
53
+ token_org = entry.get("org_id", "")
54
+ allowed_repos = entry.get("allowed_repos") # None means all repos
55
+
56
+ # Validate org from URL path
57
+ path_parts = request.url.path.strip("/").split("/")
58
+ if request.url.path not in EXEMPT_PATHS:
59
+ if len(path_parts) < 2:
60
+ return JSONResponse({"detail": "Forbidden"}, status_code=403)
61
+ url_org, url_repo = path_parts[0], path_parts[1]
62
+ if url_org != token_org:
63
+ return JSONResponse({"detail": "Forbidden"}, status_code=403)
64
+ if allowed_repos is not None and url_repo not in allowed_repos:
65
+ return JSONResponse({"detail": "Forbidden"}, status_code=403)
66
+
67
+ request.state.org_id = token_org
68
+ request.state.allowed_repos = allowed_repos
69
+ return await call_next(request)
70
+
71
+
72
+ class OAuthMiddleware(BaseHTTPMiddleware):
73
+ """
74
+ Validates Authorization: Bearer <jwt> against JWKS endpoints.
75
+ Supports multiple providers via DEVTORCH_JWKS_URLS (comma-separated).
76
+ Caches JWKS in memory with a 5-minute TTL.
77
+ Trusted providers include GitHub Actions OIDC (https://token.actions.githubusercontent.com).
78
+ """
79
+
80
+ CACHE_TTL = 300 # 5 minutes
81
+
82
+ def __init__(self, app: Any, jwks_urls: list[str]) -> None:
83
+ super().__init__(app)
84
+ self._jwks_urls = jwks_urls
85
+ self._jwks_cache: dict[str, Any] = {} # kid → public key
86
+ self._cache_lock = threading.Lock()
87
+ self._cache_expiry: float = 0.0
88
+
89
+ async def dispatch(self, request: Request, call_next: Any) -> Any:
90
+ if request.url.path in EXEMPT_PATHS:
91
+ return await call_next(request)
92
+
93
+ auth = request.headers.get("Authorization", "")
94
+ if not auth.startswith("Bearer "):
95
+ return JSONResponse({"detail": "Missing Bearer token"}, status_code=401)
96
+
97
+ token = auth.removeprefix("Bearer ").strip()
98
+
99
+ try:
100
+ import jwt as _jwt
101
+ from jwt.exceptions import PyJWTError
102
+ except ImportError:
103
+ return JSONResponse({"detail": "Server misconfiguration: PyJWT not installed"}, status_code=500)
104
+
105
+ try:
106
+ header = _jwt.get_unverified_header(token)
107
+ kid = header.get("kid")
108
+ key = self._get_key(kid)
109
+ if key is None:
110
+ return JSONResponse({"detail": "Unknown key id"}, status_code=401)
111
+ payload = _jwt.decode(token, key, algorithms=["RS256", "ES256"])
112
+ except PyJWTError:
113
+ return JSONResponse({"detail": "Invalid or expired token"}, status_code=401)
114
+
115
+ org_id = payload.get("org") or payload.get("https://devtorch.ai/org")
116
+ if not org_id:
117
+ return JSONResponse({"detail": "Missing org claim"}, status_code=401)
118
+
119
+ path_parts = request.url.path.strip("/").split("/")
120
+ if len(path_parts) >= 1 and path_parts[0] != org_id:
121
+ return JSONResponse({"detail": "Forbidden"}, status_code=403)
122
+
123
+ request.state.org_id = org_id
124
+ request.state.user_id = payload.get("sub")
125
+ return await call_next(request)
126
+
127
+ def _get_key(self, kid: Optional[str]) -> Optional[Any]:
128
+ with self._cache_lock:
129
+ expiry = self._cache_expiry
130
+ current_cache = dict(self._jwks_cache)
131
+
132
+ # Refresh outside the lock — may be done by multiple threads simultaneously
133
+ # (idempotent: worst case is a redundant fetch)
134
+ if time.monotonic() > expiry:
135
+ new_cache = self._fetch_jwks()
136
+ if new_cache:
137
+ with self._cache_lock:
138
+ merged = dict(self._jwks_cache)
139
+ merged.update(new_cache)
140
+ self._jwks_cache = merged
141
+ self._cache_expiry = time.monotonic() + self.CACHE_TTL
142
+ current_cache = dict(self._jwks_cache)
143
+ else:
144
+ # Back off 30s before next attempt to avoid thundering herd
145
+ with self._cache_lock:
146
+ self._cache_expiry = time.monotonic() + 30
147
+
148
+ return current_cache.get(kid) if kid else None
149
+
150
+ def _fetch_jwks(self) -> dict[str, Any]:
151
+ """Fetch all JWKS URLs and return a dict of kid → public_key. Returns empty dict on total failure."""
152
+ import urllib.request
153
+ import json as _json
154
+
155
+ new_cache: dict[str, Any] = {}
156
+ for url in self._jwks_urls:
157
+ try:
158
+ with urllib.request.urlopen(url, timeout=5) as resp:
159
+ data = _json.loads(resp.read())
160
+ for jwk in data.get("keys", []):
161
+ try:
162
+ key = self._jwk_to_public_key(jwk)
163
+ new_cache[jwk["kid"]] = key
164
+ except Exception:
165
+ pass
166
+ except Exception:
167
+ pass # keep going, try next URL
168
+ return new_cache
169
+
170
+ @staticmethod
171
+ def _jwk_to_public_key(jwk: dict) -> Any:
172
+ from jwt.algorithms import RSAAlgorithm, ECAlgorithm
173
+ import json as _json
174
+ alg = jwk.get("kty", "RSA")
175
+ if alg == "RSA":
176
+ return RSAAlgorithm.from_jwk(_json.dumps(jwk))
177
+ return ECAlgorithm.from_jwk(_json.dumps(jwk))