minder-cli 0.2.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 (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
minder/auth/service.py ADDED
@@ -0,0 +1,541 @@
1
+ """
2
+ Auth Service — user registration, API key management, JWT issuance/validation.
3
+
4
+ Error codes follow the project standard:
5
+ AUTH_USER_EXISTS — duplicate registration
6
+ AUTH_USER_NOT_FOUND — user lookup miss
7
+ AUTH_USER_INACTIVE — deactivated account
8
+ AUTH_INVALID_KEY — API key mismatch
9
+ AUTH_TOKEN_EXPIRED — JWT past exp claim
10
+ AUTH_TOKEN_INVALID — malformed or tampered JWT
11
+ """
12
+
13
+ import secrets
14
+ import uuid
15
+ import json
16
+ from datetime import UTC, datetime, timedelta
17
+ from enum import Enum
18
+ from typing import Any, Tuple
19
+
20
+ import jwt
21
+ from passlib.context import CryptContext # type: ignore[import-untyped]
22
+ from passlib.exc import UnknownHashError # type: ignore[import-untyped]
23
+
24
+ from minder.auth.principal import AdminUserPrincipal, ClientPrincipal, Principal
25
+ from minder.config import MinderConfig
26
+ from minder.models.user import User
27
+ from minder.store.interfaces import ICacheProvider, IOperationalStore
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Role hierarchy
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ class UserRole(str, Enum):
35
+ ADMIN = "admin"
36
+ MEMBER = "member"
37
+ READONLY = "readonly"
38
+
39
+
40
+ ROLE_HIERARCHY: dict[UserRole, int] = {
41
+ UserRole.ADMIN: 3,
42
+ UserRole.MEMBER: 2,
43
+ UserRole.READONLY: 1,
44
+ }
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Domain exception
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class AuthError(Exception):
52
+ """Raised for all auth-layer failures. Carries a structured error code."""
53
+
54
+ def __init__(self, code: str, message: str) -> None:
55
+ self.code = code
56
+ self.message = message
57
+ super().__init__(message)
58
+
59
+ def __repr__(self) -> str:
60
+ return f"AuthError(code={self.code!r}, message={self.message!r})"
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Service
65
+ # ---------------------------------------------------------------------------
66
+
67
+ _pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
68
+
69
+
70
+ class AuthService:
71
+ """Stateless auth service. Injected with store + config at construction."""
72
+
73
+ def __init__(
74
+ self,
75
+ store: IOperationalStore,
76
+ config: MinderConfig,
77
+ cache: ICacheProvider | None = None,
78
+ ) -> None:
79
+ self._store = store
80
+ self._config = config
81
+ self._cache = cache
82
+ self._client_session_cache: dict[str, str] = {}
83
+
84
+ # ------------------------------------------------------------------
85
+ # Internal helpers
86
+ # ------------------------------------------------------------------
87
+
88
+ def _generate_api_key(self) -> str:
89
+ """Return a cryptographically random API key with the configured prefix."""
90
+ token = secrets.token_urlsafe(32)
91
+ return f"{self._config.auth.api_key_prefix}{token}"
92
+
93
+ def _generate_client_api_key(self) -> str:
94
+ token = secrets.token_urlsafe(32)
95
+ return f"{self._config.auth.client_api_key_prefix}{token}"
96
+
97
+ @staticmethod
98
+ def _hash_secret(secret: str) -> str:
99
+ return _pwd_context.hash(secret)
100
+
101
+ @staticmethod
102
+ def _verify_secret(secret: str, hashed: str) -> bool:
103
+ return _pwd_context.verify(secret, hashed)
104
+
105
+ async def _session_store_set(self, key: str, value: dict[str, Any], ttl_seconds: int) -> None:
106
+ encoded = json.dumps(value, default=str)
107
+ if self._cache is not None:
108
+ await self._cache.set(key, encoded, ttl=ttl_seconds)
109
+ return
110
+ self._client_session_cache[key] = encoded
111
+
112
+ async def _session_store_get(self, key: str) -> dict[str, Any] | None:
113
+ raw: str | None
114
+ if self._cache is not None:
115
+ raw = await self._cache.get(key)
116
+ else:
117
+ raw = self._client_session_cache.get(key)
118
+ if raw is None:
119
+ return None
120
+ return json.loads(raw)
121
+
122
+ # ------------------------------------------------------------------
123
+ # Registration
124
+ # ------------------------------------------------------------------
125
+
126
+ async def register_user(
127
+ self,
128
+ email: str,
129
+ username: str,
130
+ display_name: str,
131
+ role: UserRole | str = UserRole.MEMBER,
132
+ password: str | None = None,
133
+ ) -> Tuple[User, str]:
134
+ """
135
+ Create a new user account.
136
+
137
+ Returns:
138
+ (user, plaintext_api_key) — caller must surface the key once; it
139
+ is never stored in plaintext.
140
+
141
+ If ``password`` is supplied it is hashed and stored separately so the
142
+ user can authenticate with username + password *in addition to* the API
143
+ key.
144
+
145
+ Raises:
146
+ AuthError(AUTH_USER_EXISTS) if email or username is already taken.
147
+ """
148
+ if await self._store.get_user_by_email(email):
149
+ raise AuthError("AUTH_USER_EXISTS", f"Email '{email}' is already registered")
150
+
151
+ if await self._store.get_user_by_username(username):
152
+ raise AuthError("AUTH_USER_EXISTS", f"Username '{username}' is already taken")
153
+
154
+ role_str = role.value if isinstance(role, UserRole) else str(role)
155
+
156
+ api_key = self._generate_api_key()
157
+ password_hash = self._hash_secret(password) if password else None
158
+ user = await self._store.create_user(
159
+ id=uuid.uuid4(),
160
+ email=email,
161
+ username=username,
162
+ display_name=display_name,
163
+ api_key_hash=self._hash_secret(api_key),
164
+ password_hash=password_hash,
165
+ role=role_str,
166
+ is_active=True,
167
+ settings={},
168
+ )
169
+ return user, api_key
170
+
171
+ async def has_admin_users(self) -> bool:
172
+ """Check if any admin users exist in the system."""
173
+ return await self._store.has_admin_users()
174
+
175
+ # ------------------------------------------------------------------
176
+ # Authentication
177
+ # ------------------------------------------------------------------
178
+
179
+ async def authenticate_api_key(self, api_key: str) -> User:
180
+ users = await self._store.list_users(active_only=False)
181
+ import logging
182
+ logger = logging.getLogger("minder.auth")
183
+ logger.debug(f"Checking API key against {len(users)} users")
184
+ for user in users:
185
+ try:
186
+ matches = self._verify_secret(api_key, user.api_key_hash)
187
+ except UnknownHashError:
188
+ continue
189
+ if matches:
190
+ if not user.is_active:
191
+ raise AuthError("AUTH_USER_INACTIVE", "User account is inactive")
192
+ await self._store.update_user(user.id, last_login=datetime.now(UTC))
193
+ return user
194
+ raise AuthError("AUTH_INVALID_KEY", "Invalid API key")
195
+
196
+ async def authenticate_username_password(self, username: str, password: str) -> User:
197
+ """Authenticate with username + password.
198
+
199
+ Raises:
200
+ AuthError(AUTH_USER_NOT_FOUND) — username not found
201
+ AuthError(AUTH_PASSWORD_NOT_SET) — user has no password configured
202
+ AuthError(AUTH_INVALID_KEY) — password mismatch
203
+ AuthError(AUTH_USER_INACTIVE) — account deactivated
204
+ """
205
+ user = await self._store.get_user_by_username(username)
206
+ if user is None:
207
+ # Return a generic message to avoid username enumeration
208
+ raise AuthError("AUTH_INVALID_KEY", "Invalid username or password")
209
+ if not user.is_active:
210
+ raise AuthError("AUTH_USER_INACTIVE", "User account is inactive")
211
+ password_hash = getattr(user, "password_hash", None)
212
+ if not password_hash:
213
+ raise AuthError(
214
+ "AUTH_PASSWORD_NOT_SET",
215
+ "Password login is not configured for this account. Use your API key instead.",
216
+ )
217
+ try:
218
+ if not self._verify_secret(password, password_hash):
219
+ raise AuthError("AUTH_INVALID_KEY", "Invalid username or password")
220
+ except UnknownHashError:
221
+ raise AuthError("AUTH_INVALID_KEY", "Invalid username or password")
222
+ await self._store.update_user(user.id, last_login=datetime.now(UTC))
223
+ return user
224
+
225
+ async def set_password(self, user_id: uuid.UUID, password: str) -> None:
226
+ """Set or update the login password for an existing user."""
227
+ await self._store.update_user(user_id, password_hash=self._hash_secret(password))
228
+
229
+ async def register_client(
230
+ self,
231
+ *,
232
+ name: str,
233
+ slug: str,
234
+ created_by_user_id: uuid.UUID,
235
+ description: str = "",
236
+ owner_team: str | None = None,
237
+ transport_modes: list[str] | None = None,
238
+ tool_scopes: list[str] | None = None,
239
+ repo_scopes: list[str] | None = None,
240
+ workflow_scopes: list[str] | None = None,
241
+ rate_limit_policy: dict[str, Any] | None = None,
242
+ ) -> tuple[Any, str]:
243
+ if await self._store.get_client_by_slug(slug):
244
+ raise AuthError("AUTH_CLIENT_EXISTS", f"Client slug '{slug}' is already registered")
245
+
246
+ creator = await self._store.get_user_by_id(created_by_user_id)
247
+ if creator is None:
248
+ raise AuthError("AUTH_USER_NOT_FOUND", "Creator user not found")
249
+
250
+ client = await self._store.create_client(
251
+ id=uuid.uuid4(),
252
+ name=name,
253
+ slug=slug,
254
+ description=description,
255
+ status="active",
256
+ created_by_user_id=created_by_user_id,
257
+ owner_team=owner_team,
258
+ transport_modes=transport_modes or ["sse", "stdio"],
259
+ tool_scopes=tool_scopes or [],
260
+ repo_scopes=repo_scopes or [],
261
+ workflow_scopes=workflow_scopes or [],
262
+ rate_limit_policy=rate_limit_policy or {},
263
+ )
264
+ client_api_key = self._generate_client_api_key()
265
+ await self._store.create_client_api_key(
266
+ id=uuid.uuid4(),
267
+ client_id=client.id,
268
+ key_prefix=client_api_key[:12],
269
+ secret_hash=self._hash_secret(client_api_key),
270
+ status="active",
271
+ created_by_user_id=created_by_user_id,
272
+ )
273
+ await self._store.create_audit_log(
274
+ id=uuid.uuid4(),
275
+ actor_type="admin_user",
276
+ actor_id=str(created_by_user_id),
277
+ event_type="client.created",
278
+ resource_type="client",
279
+ resource_id=str(client.id),
280
+ outcome="success",
281
+ audit_metadata={"slug": slug},
282
+ )
283
+ return client, client_api_key
284
+
285
+ async def create_client_api_key(
286
+ self,
287
+ *,
288
+ client_id: uuid.UUID,
289
+ created_by_user_id: uuid.UUID,
290
+ ) -> str:
291
+ client = await self._store.get_client_by_id(client_id)
292
+ if client is None:
293
+ raise AuthError("AUTH_CLIENT_NOT_FOUND", "Client not found")
294
+ creator = await self._store.get_user_by_id(created_by_user_id)
295
+ if creator is None:
296
+ raise AuthError("AUTH_USER_NOT_FOUND", "Creator user not found")
297
+
298
+ client_api_key = self._generate_client_api_key()
299
+ await self._store.create_client_api_key(
300
+ id=uuid.uuid4(),
301
+ client_id=client_id,
302
+ key_prefix=client_api_key[:12],
303
+ secret_hash=self._hash_secret(client_api_key),
304
+ status="active",
305
+ created_by_user_id=created_by_user_id,
306
+ )
307
+ await self._store.create_audit_log(
308
+ id=uuid.uuid4(),
309
+ actor_type="admin_user",
310
+ actor_id=str(created_by_user_id),
311
+ event_type="client.key_created",
312
+ resource_type="client",
313
+ resource_id=str(client_id),
314
+ outcome="success",
315
+ audit_metadata={"client_slug": getattr(client, "slug", None)},
316
+ )
317
+ return client_api_key
318
+
319
+ async def revoke_client_api_keys(
320
+ self,
321
+ client_id: uuid.UUID,
322
+ *,
323
+ actor_user_id: uuid.UUID | None = None,
324
+ ) -> None:
325
+ now = datetime.now(UTC)
326
+ client = await self._store.get_client_by_id(client_id)
327
+ for key in await self._store.list_client_api_keys(client_id):
328
+ await self._store.update_client_api_key(
329
+ key.id,
330
+ status="revoked",
331
+ revoked_at=now,
332
+ )
333
+ if actor_user_id is not None:
334
+ await self._store.create_audit_log(
335
+ id=uuid.uuid4(),
336
+ actor_type="admin_user",
337
+ actor_id=str(actor_user_id),
338
+ event_type="client.key_revoked",
339
+ resource_type="client",
340
+ resource_id=str(client_id),
341
+ outcome="success",
342
+ audit_metadata={"client_slug": getattr(client, "slug", None)},
343
+ )
344
+
345
+ async def authenticate_client_api_key(self, client_api_key: str) -> Any:
346
+ if not client_api_key.startswith(self._config.auth.client_api_key_prefix):
347
+ raise AuthError("AUTH_INVALID_CLIENT_KEY", "Invalid client API key")
348
+
349
+ clients = await self._store.list_clients()
350
+ for client in clients:
351
+ if getattr(client, "status", "active") != "active":
352
+ continue
353
+ for key in await self._store.list_client_api_keys(client.id):
354
+ if getattr(key, "status", "active") != "active":
355
+ continue
356
+ try:
357
+ matches = self._verify_secret(client_api_key, key.secret_hash)
358
+ except UnknownHashError:
359
+ continue
360
+ if matches:
361
+ await self._store.update_client_api_key(
362
+ key.id,
363
+ last_used_at=datetime.now(UTC),
364
+ )
365
+ return client
366
+ raise AuthError("AUTH_INVALID_CLIENT_KEY", "Invalid client API key")
367
+
368
+ async def exchange_client_api_key(
369
+ self,
370
+ client_api_key: str,
371
+ *,
372
+ requested_scopes: list[str] | None = None,
373
+ metadata: dict[str, Any] | None = None,
374
+ ) -> dict[str, Any]:
375
+ client = await self.authenticate_client_api_key(client_api_key)
376
+ allowed_scopes = list(getattr(client, "tool_scopes", []))
377
+ requested = requested_scopes or allowed_scopes
378
+ effective_scopes = [scope for scope in requested if scope in allowed_scopes]
379
+ if not effective_scopes:
380
+ effective_scopes = allowed_scopes
381
+
382
+ now = datetime.now(UTC)
383
+ expires_at = now + timedelta(minutes=self._config.auth.client_token_expiry_minutes)
384
+ token_id = str(uuid.uuid4())
385
+ session = await self._store.create_client_session(
386
+ id=uuid.uuid4(),
387
+ client_id=client.id,
388
+ access_token_id=token_id,
389
+ status="active",
390
+ scopes=effective_scopes,
391
+ issued_at=now,
392
+ expires_at=expires_at,
393
+ last_seen_at=now,
394
+ session_metadata=metadata or {},
395
+ )
396
+ await self._session_store_set(
397
+ f"client_session:{token_id}",
398
+ {
399
+ "client_id": str(client.id),
400
+ "client_slug": client.slug,
401
+ "scopes": effective_scopes,
402
+ "repo_scope": list(getattr(client, "repo_scopes", [])),
403
+ "session_id": str(session.id),
404
+ },
405
+ ttl_seconds=self._config.auth.client_token_expiry_minutes * 60,
406
+ )
407
+ payload = {
408
+ "sub": str(client.id),
409
+ "ptype": "client",
410
+ "slug": client.slug,
411
+ "scopes": effective_scopes,
412
+ "repo_scope": list(getattr(client, "repo_scopes", [])),
413
+ "jti": token_id,
414
+ "iat": now,
415
+ "exp": expires_at,
416
+ }
417
+ token = jwt.encode(payload, self._config.auth.jwt_secret, algorithm="HS256")
418
+ await self._store.create_audit_log(
419
+ id=uuid.uuid4(),
420
+ actor_type="client",
421
+ actor_id=str(client.id),
422
+ event_type="client.token_exchanged",
423
+ resource_type="client_session",
424
+ resource_id=str(session.id),
425
+ outcome="success",
426
+ audit_metadata={"scopes": effective_scopes},
427
+ )
428
+ return {
429
+ "access_token": token,
430
+ "expires_in": self._config.auth.client_token_expiry_minutes * 60,
431
+ "token_type": "Bearer",
432
+ "client_id": str(client.id),
433
+ "client_slug": client.slug,
434
+ }
435
+
436
+ # ------------------------------------------------------------------
437
+ # JWT
438
+ # ------------------------------------------------------------------
439
+
440
+ def issue_jwt(self, user: User) -> str:
441
+ """Issue a signed JWT for the user."""
442
+ now = datetime.now(UTC)
443
+ payload = {
444
+ "sub": str(user.id),
445
+ "email": user.email,
446
+ "username": user.username,
447
+ "role": user.role,
448
+ "iat": now,
449
+ "exp": now + timedelta(hours=self._config.auth.jwt_expiry_hours),
450
+ }
451
+ return jwt.encode(payload, self._config.auth.jwt_secret, algorithm="HS256")
452
+
453
+ def validate_jwt(self, token: str) -> dict:
454
+ """
455
+ Decode and validate a JWT.
456
+
457
+ Returns the decoded payload dict.
458
+
459
+ Raises:
460
+ AuthError(AUTH_TOKEN_EXPIRED) if past exp.
461
+ AuthError(AUTH_TOKEN_INVALID) on any other JWT error.
462
+ """
463
+ try:
464
+ return jwt.decode(
465
+ token,
466
+ self._config.auth.jwt_secret,
467
+ algorithms=["HS256"],
468
+ )
469
+ except jwt.ExpiredSignatureError:
470
+ raise AuthError("AUTH_TOKEN_EXPIRED", "JWT token has expired")
471
+ except jwt.InvalidTokenError as exc:
472
+ raise AuthError("AUTH_TOKEN_INVALID", f"Invalid JWT: {exc}")
473
+
474
+ async def get_user_from_jwt(self, token: str) -> User:
475
+ """Validate JWT and return the corresponding active user."""
476
+ payload = self.validate_jwt(token)
477
+ user_id = uuid.UUID(payload["sub"])
478
+ user = await self._store.get_user_by_id(user_id)
479
+ if not user:
480
+ raise AuthError("AUTH_USER_NOT_FOUND", "User referenced by token not found")
481
+ if not user.is_active:
482
+ raise AuthError("AUTH_USER_INACTIVE", "User account is inactive")
483
+ return user
484
+
485
+ async def get_principal_from_token(self, token: str) -> Principal:
486
+ payload = self.validate_jwt(token)
487
+ if payload.get("ptype") == "client":
488
+ token_id = payload.get("jti")
489
+ if not token_id:
490
+ raise AuthError("AUTH_TOKEN_INVALID", "Client token missing jti")
491
+ cached = await self._session_store_get(f"client_session:{token_id}")
492
+ if cached is None:
493
+ raise AuthError("AUTH_TOKEN_INVALID", "Client session not found or expired")
494
+ client = await self._store.get_client_by_id(uuid.UUID(payload["sub"]))
495
+ if client is None or getattr(client, "status", "active") != "active":
496
+ raise AuthError("AUTH_CLIENT_NOT_FOUND", "Client referenced by token not found")
497
+ session = await self._store.get_client_session_by_token_id(token_id)
498
+ if session is not None:
499
+ await self._store.update_client_session(session.id, last_seen_at=datetime.now(UTC))
500
+ return ClientPrincipal(
501
+ client_id=client.id,
502
+ client_slug=client.slug,
503
+ scopes=list(cached.get("scopes", [])),
504
+ repo_scope=list(cached.get("repo_scope", [])),
505
+ metadata={"session_id": cached.get("session_id")},
506
+ )
507
+ user = await self.get_user_from_jwt(token)
508
+ return AdminUserPrincipal(user)
509
+
510
+ async def get_principal_from_client_key(
511
+ self,
512
+ client_api_key: str,
513
+ *,
514
+ requested_scopes: list[str] | None = None,
515
+ ) -> Principal:
516
+ exchange = await self.exchange_client_api_key(
517
+ client_api_key,
518
+ requested_scopes=requested_scopes,
519
+ )
520
+ return await self.get_principal_from_token(exchange["access_token"])
521
+
522
+ # ------------------------------------------------------------------
523
+ # Key rotation
524
+ # ------------------------------------------------------------------
525
+
526
+ async def rotate_api_key(self, user_id: uuid.UUID) -> str:
527
+ """
528
+ Generate and store a new API key for the user.
529
+
530
+ Returns the new plaintext key (display once).
531
+
532
+ Raises:
533
+ AuthError(AUTH_USER_NOT_FOUND) if user doesn't exist.
534
+ """
535
+ user = await self._store.get_user_by_id(user_id)
536
+ if not user:
537
+ raise AuthError("AUTH_USER_NOT_FOUND", f"User {user_id} not found")
538
+
539
+ new_key = self._generate_api_key()
540
+ await self._store.update_user(user_id, api_key_hash=self._hash_secret(new_key))
541
+ return new_key
@@ -0,0 +1,9 @@
1
+ from minder.bootstrap.providers import build_cache, build_store, build_vector_store
2
+ from minder.bootstrap.transport import build_transport
3
+
4
+ __all__ = [
5
+ "build_cache",
6
+ "build_store",
7
+ "build_transport",
8
+ "build_vector_store",
9
+ ]
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from minder.cache.providers import RedisCacheProvider
6
+ from minder.config import MinderConfig
7
+ from minder.store.interfaces import ICacheProvider, IGraphRepository, IOperationalStore, IVectorStore
8
+ from minder.store.vector import VectorStore
9
+
10
+
11
+ def _sqlite_db_url(raw_path: str) -> str:
12
+ db_path = Path(raw_path).expanduser()
13
+ db_path.parent.mkdir(parents=True, exist_ok=True)
14
+ return f"sqlite+aiosqlite:///{db_path}"
15
+
16
+
17
+ def build_store(config: MinderConfig) -> IOperationalStore:
18
+ provider = config.relational_store.provider
19
+
20
+ if provider == "mongodb":
21
+ from minder.store.mongodb.client import MongoClient
22
+ from minder.store.mongodb.operational_store import MongoOperationalStore
23
+
24
+ client = MongoClient(
25
+ uri=config.mongodb.uri,
26
+ database=config.mongodb.database,
27
+ min_pool_size=config.mongodb.min_pool_size,
28
+ max_pool_size=config.mongodb.max_pool_size,
29
+ )
30
+ return MongoOperationalStore(client) # type: ignore[return-value]
31
+
32
+ if provider in ("sqlite", "postgresql"):
33
+ from minder.store.relational import RelationalStore
34
+
35
+ if provider == "sqlite":
36
+ db_url = _sqlite_db_url(config.relational_store.db_path)
37
+ else:
38
+ db_url = config.relational_store.uri
39
+
40
+ return RelationalStore(db_url) # type: ignore[return-value]
41
+
42
+ raise ValueError(
43
+ f"Unsupported relational_store.provider '{provider}'. "
44
+ "Supported: 'mongodb', 'sqlite', 'postgresql'."
45
+ )
46
+
47
+
48
+ def build_cache(config: MinderConfig) -> ICacheProvider:
49
+ provider = config.cache.provider
50
+
51
+ if provider == "redis":
52
+ return RedisCacheProvider(
53
+ uri=config.redis.uri,
54
+ prefix=config.redis.prefix,
55
+ default_ttl=config.redis.cache_ttl,
56
+ )
57
+
58
+ raise ValueError(
59
+ f"Unsupported cache.provider '{provider}'. "
60
+ "Only 'redis' is supported. Set [cache] provider = \"redis\" in minder.toml."
61
+ )
62
+
63
+
64
+ def build_vector_store(config: MinderConfig, store: IOperationalStore) -> IVectorStore:
65
+ if config.vector_store.provider == "milvus":
66
+ from minder.store.milvus.client import MilvusClient
67
+ from minder.store.milvus.vector_store import MilvusVectorStore
68
+
69
+ client = MilvusClient(uri=config.vector_store.uri)
70
+ return MilvusVectorStore(
71
+ client,
72
+ store,
73
+ prefix=config.vector_store.collection_prefix,
74
+ dimensions=config.embedding.dimensions,
75
+ )
76
+
77
+ return VectorStore(store, store)
78
+
79
+
80
+ def build_graph_store(config: MinderConfig) -> IGraphRepository | None:
81
+ if not config.graph_store.enabled:
82
+ return None
83
+
84
+ provider = config.graph_store.provider
85
+ if provider == "auto":
86
+ provider = config.relational_store.provider
87
+ if provider == "mongodb":
88
+ provider = "sqlite"
89
+
90
+ if provider in ("sqlite", "postgresql"):
91
+ from minder.store.graph import KnowledgeGraphStore
92
+
93
+ if provider == "sqlite":
94
+ if config.graph_store.provider == "auto" and config.relational_store.provider == "sqlite":
95
+ db_url = _sqlite_db_url(config.relational_store.db_path)
96
+ else:
97
+ db_url = _sqlite_db_url(config.graph_store.db_path)
98
+ else:
99
+ if config.graph_store.provider == "auto" and config.relational_store.provider == "postgresql":
100
+ db_url = config.relational_store.uri
101
+ else:
102
+ db_url = config.graph_store.uri
103
+
104
+ return KnowledgeGraphStore(db_url)
105
+
106
+ raise ValueError(
107
+ f"Unsupported graph_store.provider '{provider}'. "
108
+ "Supported: 'auto', 'sqlite', 'postgresql'."
109
+ )