basic-memory 0.14.2__py3-none-any.whl → 0.14.4__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.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +3 -1
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
- basic_memory/api/app.py +4 -1
- basic_memory/api/routers/management_router.py +3 -1
- basic_memory/api/routers/project_router.py +21 -13
- basic_memory/api/routers/resource_router.py +3 -3
- basic_memory/cli/app.py +3 -3
- basic_memory/cli/commands/__init__.py +1 -2
- basic_memory/cli/commands/db.py +5 -5
- basic_memory/cli/commands/import_chatgpt.py +3 -2
- basic_memory/cli/commands/import_claude_conversations.py +3 -1
- basic_memory/cli/commands/import_claude_projects.py +3 -1
- basic_memory/cli/commands/import_memory_json.py +5 -2
- basic_memory/cli/commands/mcp.py +3 -15
- basic_memory/cli/commands/project.py +46 -6
- basic_memory/cli/commands/status.py +4 -1
- basic_memory/cli/commands/sync.py +10 -2
- basic_memory/cli/main.py +0 -1
- basic_memory/config.py +61 -34
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/file_utils.py +65 -0
- basic_memory/importers/chatgpt_importer.py +20 -10
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/importers/utils.py +2 -2
- basic_memory/markdown/entity_parser.py +2 -2
- basic_memory/markdown/markdown_processor.py +2 -2
- basic_memory/markdown/plugins.py +42 -26
- basic_memory/markdown/utils.py +1 -1
- basic_memory/mcp/async_client.py +22 -2
- basic_memory/mcp/project_session.py +6 -4
- basic_memory/mcp/prompts/__init__.py +0 -2
- basic_memory/mcp/server.py +8 -71
- basic_memory/mcp/tools/build_context.py +12 -2
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/project_management.py +22 -7
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +17 -2
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/knowledge.py +13 -2
- basic_memory/models/project.py +3 -3
- basic_memory/repository/entity_repository.py +2 -2
- basic_memory/repository/project_repository.py +19 -1
- basic_memory/repository/search_repository.py +7 -3
- basic_memory/schemas/base.py +40 -10
- basic_memory/schemas/importer.py +1 -0
- basic_memory/schemas/memory.py +23 -11
- basic_memory/services/context_service.py +12 -2
- basic_memory/services/directory_service.py +7 -0
- basic_memory/services/entity_service.py +56 -10
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +93 -36
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +53 -4
- basic_memory/sync/watch_service.py +31 -8
- basic_memory/utils.py +234 -71
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
- basic_memory/cli/commands/auth.py +0 -136
- basic_memory/mcp/auth_provider.py +0 -270
- basic_memory/mcp/external_auth_provider.py +0 -321
- basic_memory/mcp/prompts/sync_status.py +0 -112
- basic_memory/mcp/supabase_auth_provider.py +0 -463
- basic_memory/services/migration_service.py +0 -168
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
"""Supabase OAuth provider for Basic Memory MCP server."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import secrets
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from datetime import datetime, timedelta, timezone
|
|
7
|
-
from typing import Optional, Dict, Any
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
import jwt
|
|
11
|
-
from loguru import logger
|
|
12
|
-
from mcp.server.auth.provider import (
|
|
13
|
-
OAuthAuthorizationServerProvider,
|
|
14
|
-
AuthorizationParams,
|
|
15
|
-
AuthorizationCode,
|
|
16
|
-
RefreshToken,
|
|
17
|
-
AccessToken,
|
|
18
|
-
TokenError,
|
|
19
|
-
AuthorizeError,
|
|
20
|
-
)
|
|
21
|
-
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class SupabaseAuthorizationCode(AuthorizationCode):
|
|
26
|
-
"""Authorization code with Supabase metadata."""
|
|
27
|
-
|
|
28
|
-
user_id: Optional[str] = None
|
|
29
|
-
email: Optional[str] = None
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@dataclass
|
|
33
|
-
class SupabaseRefreshToken(RefreshToken):
|
|
34
|
-
"""Refresh token with Supabase metadata."""
|
|
35
|
-
|
|
36
|
-
supabase_refresh_token: Optional[str] = None
|
|
37
|
-
user_id: Optional[str] = None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@dataclass
|
|
41
|
-
class SupabaseAccessToken(AccessToken):
|
|
42
|
-
"""Access token with Supabase metadata."""
|
|
43
|
-
|
|
44
|
-
supabase_access_token: Optional[str] = None
|
|
45
|
-
user_id: Optional[str] = None
|
|
46
|
-
email: Optional[str] = None
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class SupabaseOAuthProvider(
|
|
50
|
-
OAuthAuthorizationServerProvider[
|
|
51
|
-
SupabaseAuthorizationCode, SupabaseRefreshToken, SupabaseAccessToken
|
|
52
|
-
]
|
|
53
|
-
):
|
|
54
|
-
"""OAuth provider that integrates with Supabase Auth.
|
|
55
|
-
|
|
56
|
-
This provider uses Supabase as the authentication backend while
|
|
57
|
-
maintaining compatibility with MCP's OAuth requirements.
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
def __init__(
|
|
61
|
-
self,
|
|
62
|
-
supabase_url: str,
|
|
63
|
-
supabase_anon_key: str,
|
|
64
|
-
supabase_service_key: Optional[str] = None,
|
|
65
|
-
issuer_url: str = "http://localhost:8000",
|
|
66
|
-
):
|
|
67
|
-
self.supabase_url = supabase_url.rstrip("/")
|
|
68
|
-
self.supabase_anon_key = supabase_anon_key
|
|
69
|
-
self.supabase_service_key = supabase_service_key or supabase_anon_key
|
|
70
|
-
self.issuer_url = issuer_url
|
|
71
|
-
|
|
72
|
-
# HTTP client for Supabase API calls
|
|
73
|
-
self.http_client = httpx.AsyncClient()
|
|
74
|
-
|
|
75
|
-
# Temporary storage for auth flows (in production, use Supabase DB)
|
|
76
|
-
self.pending_auth_codes: Dict[str, SupabaseAuthorizationCode] = {}
|
|
77
|
-
self.mcp_to_supabase_tokens: Dict[str, Dict[str, Any]] = {}
|
|
78
|
-
|
|
79
|
-
async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]:
|
|
80
|
-
"""Get a client from Supabase.
|
|
81
|
-
|
|
82
|
-
In production, this would query a clients table in Supabase.
|
|
83
|
-
"""
|
|
84
|
-
# For now, we'll validate against a configured list of allowed clients
|
|
85
|
-
# In production, query Supabase DB for client info
|
|
86
|
-
allowed_clients = os.getenv("SUPABASE_ALLOWED_CLIENTS", "").split(",")
|
|
87
|
-
|
|
88
|
-
if client_id in allowed_clients:
|
|
89
|
-
return OAuthClientInformationFull(
|
|
90
|
-
client_id=client_id,
|
|
91
|
-
client_secret="", # Supabase handles secrets
|
|
92
|
-
redirect_uris=[], # Supabase handles redirect URIs
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
return None
|
|
96
|
-
|
|
97
|
-
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
98
|
-
"""Register a new OAuth client in Supabase.
|
|
99
|
-
|
|
100
|
-
In production, this would insert into a clients table.
|
|
101
|
-
"""
|
|
102
|
-
# For development, we just log the registration
|
|
103
|
-
logger.info(f"Would register client {client_info.client_id} in Supabase")
|
|
104
|
-
|
|
105
|
-
# In production:
|
|
106
|
-
# await self.supabase.table('oauth_clients').insert({
|
|
107
|
-
# 'client_id': client_info.client_id,
|
|
108
|
-
# 'client_secret': client_info.client_secret,
|
|
109
|
-
# 'metadata': client_info.client_metadata,
|
|
110
|
-
# }).execute()
|
|
111
|
-
|
|
112
|
-
async def authorize(
|
|
113
|
-
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
|
114
|
-
) -> str:
|
|
115
|
-
"""Create authorization URL redirecting to Supabase Auth.
|
|
116
|
-
|
|
117
|
-
This initiates the OAuth flow with Supabase as the identity provider.
|
|
118
|
-
"""
|
|
119
|
-
# Generate state for this auth request
|
|
120
|
-
state = secrets.token_urlsafe(32)
|
|
121
|
-
|
|
122
|
-
# Store the authorization request
|
|
123
|
-
self.pending_auth_codes[state] = SupabaseAuthorizationCode(
|
|
124
|
-
code=state,
|
|
125
|
-
scopes=params.scopes or [],
|
|
126
|
-
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=10)).timestamp(),
|
|
127
|
-
client_id=client.client_id,
|
|
128
|
-
code_challenge=params.code_challenge,
|
|
129
|
-
redirect_uri=params.redirect_uri,
|
|
130
|
-
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
# Build Supabase auth URL
|
|
134
|
-
auth_params = {
|
|
135
|
-
"redirect_to": f"{self.issuer_url}/auth/callback",
|
|
136
|
-
"scopes": " ".join(params.scopes or ["openid", "email"]),
|
|
137
|
-
"state": state,
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
# Use Supabase's OAuth endpoint
|
|
141
|
-
auth_url = f"{self.supabase_url}/auth/v1/authorize"
|
|
142
|
-
query_string = "&".join(f"{k}={v}" for k, v in auth_params.items())
|
|
143
|
-
|
|
144
|
-
return f"{auth_url}?{query_string}"
|
|
145
|
-
|
|
146
|
-
async def handle_supabase_callback(self, code: str, state: str) -> str:
|
|
147
|
-
"""Handle callback from Supabase after user authentication."""
|
|
148
|
-
# Get the original auth request
|
|
149
|
-
auth_request = self.pending_auth_codes.get(state)
|
|
150
|
-
if not auth_request:
|
|
151
|
-
raise AuthorizeError(
|
|
152
|
-
error="invalid_request",
|
|
153
|
-
error_description="Invalid state parameter",
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
# Exchange code with Supabase for tokens
|
|
157
|
-
token_response = await self.http_client.post(
|
|
158
|
-
f"{self.supabase_url}/auth/v1/token",
|
|
159
|
-
json={
|
|
160
|
-
"grant_type": "authorization_code",
|
|
161
|
-
"code": code,
|
|
162
|
-
"redirect_uri": f"{self.issuer_url}/auth/callback",
|
|
163
|
-
},
|
|
164
|
-
headers={
|
|
165
|
-
"apikey": self.supabase_anon_key,
|
|
166
|
-
"Authorization": f"Bearer {self.supabase_anon_key}",
|
|
167
|
-
},
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
if not token_response.is_success:
|
|
171
|
-
raise AuthorizeError(
|
|
172
|
-
error="server_error",
|
|
173
|
-
error_description="Failed to exchange code with Supabase",
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
supabase_tokens = token_response.json()
|
|
177
|
-
|
|
178
|
-
# Get user info from Supabase
|
|
179
|
-
user_response = await self.http_client.get(
|
|
180
|
-
f"{self.supabase_url}/auth/v1/user",
|
|
181
|
-
headers={
|
|
182
|
-
"apikey": self.supabase_anon_key,
|
|
183
|
-
"Authorization": f"Bearer {supabase_tokens['access_token']}",
|
|
184
|
-
},
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
user_data = user_response.json() if user_response.is_success else {}
|
|
188
|
-
|
|
189
|
-
# Generate MCP authorization code
|
|
190
|
-
mcp_code = secrets.token_urlsafe(32)
|
|
191
|
-
|
|
192
|
-
# Update auth request with user info
|
|
193
|
-
auth_request.code = mcp_code
|
|
194
|
-
auth_request.user_id = user_data.get("id")
|
|
195
|
-
auth_request.email = user_data.get("email")
|
|
196
|
-
|
|
197
|
-
# Store mapping
|
|
198
|
-
self.pending_auth_codes[mcp_code] = auth_request
|
|
199
|
-
self.mcp_to_supabase_tokens[mcp_code] = {
|
|
200
|
-
"supabase_tokens": supabase_tokens,
|
|
201
|
-
"user": user_data,
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
# Clean up old state
|
|
205
|
-
del self.pending_auth_codes[state]
|
|
206
|
-
|
|
207
|
-
# Redirect back to client
|
|
208
|
-
redirect_uri = str(auth_request.redirect_uri)
|
|
209
|
-
separator = "&" if "?" in redirect_uri else "?"
|
|
210
|
-
|
|
211
|
-
return f"{redirect_uri}{separator}code={mcp_code}&state={state}"
|
|
212
|
-
|
|
213
|
-
async def load_authorization_code(
|
|
214
|
-
self, client: OAuthClientInformationFull, authorization_code: str
|
|
215
|
-
) -> Optional[SupabaseAuthorizationCode]:
|
|
216
|
-
"""Load an authorization code."""
|
|
217
|
-
code = self.pending_auth_codes.get(authorization_code)
|
|
218
|
-
|
|
219
|
-
if code and code.client_id == client.client_id:
|
|
220
|
-
# Check expiration
|
|
221
|
-
if datetime.now(timezone.utc).timestamp() > code.expires_at:
|
|
222
|
-
del self.pending_auth_codes[authorization_code]
|
|
223
|
-
return None
|
|
224
|
-
return code
|
|
225
|
-
|
|
226
|
-
return None
|
|
227
|
-
|
|
228
|
-
async def exchange_authorization_code(
|
|
229
|
-
self, client: OAuthClientInformationFull, authorization_code: SupabaseAuthorizationCode
|
|
230
|
-
) -> OAuthToken:
|
|
231
|
-
"""Exchange authorization code for tokens."""
|
|
232
|
-
# Get stored Supabase tokens
|
|
233
|
-
token_data = self.mcp_to_supabase_tokens.get(authorization_code.code)
|
|
234
|
-
if not token_data:
|
|
235
|
-
raise TokenError(error="invalid_grant", error_description="Invalid authorization code")
|
|
236
|
-
|
|
237
|
-
supabase_tokens = token_data["supabase_tokens"]
|
|
238
|
-
user = token_data["user"]
|
|
239
|
-
|
|
240
|
-
# Generate MCP tokens that wrap Supabase tokens
|
|
241
|
-
access_token = self._generate_mcp_token(
|
|
242
|
-
client_id=client.client_id,
|
|
243
|
-
user_id=user.get("id", ""),
|
|
244
|
-
email=user.get("email", ""),
|
|
245
|
-
scopes=authorization_code.scopes,
|
|
246
|
-
supabase_access_token=supabase_tokens["access_token"],
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
refresh_token = secrets.token_urlsafe(32)
|
|
250
|
-
|
|
251
|
-
# Store the token mapping
|
|
252
|
-
self.mcp_to_supabase_tokens[access_token] = {
|
|
253
|
-
"client_id": client.client_id,
|
|
254
|
-
"user_id": user.get("id"),
|
|
255
|
-
"email": user.get("email"),
|
|
256
|
-
"supabase_access_token": supabase_tokens["access_token"],
|
|
257
|
-
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
|
258
|
-
"scopes": authorization_code.scopes,
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
# Store refresh token mapping
|
|
262
|
-
self.mcp_to_supabase_tokens[refresh_token] = {
|
|
263
|
-
"client_id": client.client_id,
|
|
264
|
-
"user_id": user.get("id"),
|
|
265
|
-
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
|
266
|
-
"scopes": authorization_code.scopes,
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
# Clean up authorization code
|
|
270
|
-
del self.pending_auth_codes[authorization_code.code]
|
|
271
|
-
del self.mcp_to_supabase_tokens[authorization_code.code]
|
|
272
|
-
|
|
273
|
-
return OAuthToken(
|
|
274
|
-
access_token=access_token,
|
|
275
|
-
token_type="bearer",
|
|
276
|
-
expires_in=supabase_tokens.get("expires_in", 3600),
|
|
277
|
-
refresh_token=refresh_token,
|
|
278
|
-
scope=" ".join(authorization_code.scopes) if authorization_code.scopes else None,
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
async def load_refresh_token(
|
|
282
|
-
self, client: OAuthClientInformationFull, refresh_token: str
|
|
283
|
-
) -> Optional[SupabaseRefreshToken]:
|
|
284
|
-
"""Load a refresh token."""
|
|
285
|
-
token_data = self.mcp_to_supabase_tokens.get(refresh_token)
|
|
286
|
-
|
|
287
|
-
if token_data and token_data["client_id"] == client.client_id:
|
|
288
|
-
return SupabaseRefreshToken(
|
|
289
|
-
token=refresh_token,
|
|
290
|
-
client_id=client.client_id,
|
|
291
|
-
scopes=token_data["scopes"],
|
|
292
|
-
supabase_refresh_token=token_data["supabase_refresh_token"],
|
|
293
|
-
user_id=token_data.get("user_id"),
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
return None
|
|
297
|
-
|
|
298
|
-
async def exchange_refresh_token(
|
|
299
|
-
self,
|
|
300
|
-
client: OAuthClientInformationFull,
|
|
301
|
-
refresh_token: SupabaseRefreshToken,
|
|
302
|
-
scopes: list[str],
|
|
303
|
-
) -> OAuthToken:
|
|
304
|
-
"""Exchange refresh token for new tokens using Supabase."""
|
|
305
|
-
# Refresh with Supabase
|
|
306
|
-
token_response = await self.http_client.post(
|
|
307
|
-
f"{self.supabase_url}/auth/v1/token",
|
|
308
|
-
json={
|
|
309
|
-
"grant_type": "refresh_token",
|
|
310
|
-
"refresh_token": refresh_token.supabase_refresh_token,
|
|
311
|
-
},
|
|
312
|
-
headers={
|
|
313
|
-
"apikey": self.supabase_anon_key,
|
|
314
|
-
"Authorization": f"Bearer {self.supabase_anon_key}",
|
|
315
|
-
},
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
if not token_response.is_success:
|
|
319
|
-
raise TokenError(
|
|
320
|
-
error="invalid_grant",
|
|
321
|
-
error_description="Failed to refresh with Supabase",
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
supabase_tokens = token_response.json()
|
|
325
|
-
|
|
326
|
-
# Get updated user info
|
|
327
|
-
user_response = await self.http_client.get(
|
|
328
|
-
f"{self.supabase_url}/auth/v1/user",
|
|
329
|
-
headers={
|
|
330
|
-
"apikey": self.supabase_anon_key,
|
|
331
|
-
"Authorization": f"Bearer {supabase_tokens['access_token']}",
|
|
332
|
-
},
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
user_data = user_response.json() if user_response.is_success else {}
|
|
336
|
-
|
|
337
|
-
# Generate new MCP tokens
|
|
338
|
-
new_access_token = self._generate_mcp_token(
|
|
339
|
-
client_id=client.client_id,
|
|
340
|
-
user_id=user_data.get("id", ""),
|
|
341
|
-
email=user_data.get("email", ""),
|
|
342
|
-
scopes=scopes or refresh_token.scopes,
|
|
343
|
-
supabase_access_token=supabase_tokens["access_token"],
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
new_refresh_token = secrets.token_urlsafe(32)
|
|
347
|
-
|
|
348
|
-
# Update token mappings
|
|
349
|
-
self.mcp_to_supabase_tokens[new_access_token] = {
|
|
350
|
-
"client_id": client.client_id,
|
|
351
|
-
"user_id": user_data.get("id"),
|
|
352
|
-
"email": user_data.get("email"),
|
|
353
|
-
"supabase_access_token": supabase_tokens["access_token"],
|
|
354
|
-
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
|
355
|
-
"scopes": scopes or refresh_token.scopes,
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
self.mcp_to_supabase_tokens[new_refresh_token] = {
|
|
359
|
-
"client_id": client.client_id,
|
|
360
|
-
"user_id": user_data.get("id"),
|
|
361
|
-
"supabase_refresh_token": supabase_tokens["refresh_token"],
|
|
362
|
-
"scopes": scopes or refresh_token.scopes,
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
# Clean up old tokens
|
|
366
|
-
del self.mcp_to_supabase_tokens[refresh_token.token]
|
|
367
|
-
|
|
368
|
-
return OAuthToken(
|
|
369
|
-
access_token=new_access_token,
|
|
370
|
-
token_type="bearer",
|
|
371
|
-
expires_in=supabase_tokens.get("expires_in", 3600),
|
|
372
|
-
refresh_token=new_refresh_token,
|
|
373
|
-
scope=" ".join(scopes or refresh_token.scopes),
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
async def load_access_token(self, token: str) -> Optional[SupabaseAccessToken]:
|
|
377
|
-
"""Load and validate an access token."""
|
|
378
|
-
# First check our mapping
|
|
379
|
-
token_data = self.mcp_to_supabase_tokens.get(token)
|
|
380
|
-
if token_data:
|
|
381
|
-
return SupabaseAccessToken(
|
|
382
|
-
token=token,
|
|
383
|
-
client_id=token_data["client_id"],
|
|
384
|
-
scopes=token_data["scopes"],
|
|
385
|
-
supabase_access_token=token_data.get("supabase_access_token"),
|
|
386
|
-
user_id=token_data.get("user_id"),
|
|
387
|
-
email=token_data.get("email"),
|
|
388
|
-
)
|
|
389
|
-
|
|
390
|
-
# Try to decode as JWT
|
|
391
|
-
try:
|
|
392
|
-
# Verify with Supabase's JWT secret
|
|
393
|
-
payload = jwt.decode(
|
|
394
|
-
token,
|
|
395
|
-
os.getenv("SUPABASE_JWT_SECRET", ""),
|
|
396
|
-
algorithms=["HS256"],
|
|
397
|
-
audience="authenticated",
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
return SupabaseAccessToken(
|
|
401
|
-
token=token,
|
|
402
|
-
client_id=payload.get("client_id", ""),
|
|
403
|
-
scopes=payload.get("scopes", []),
|
|
404
|
-
user_id=payload.get("sub"),
|
|
405
|
-
email=payload.get("email"),
|
|
406
|
-
)
|
|
407
|
-
except jwt.InvalidTokenError:
|
|
408
|
-
pass
|
|
409
|
-
|
|
410
|
-
# Validate with Supabase
|
|
411
|
-
user_response = await self.http_client.get(
|
|
412
|
-
f"{self.supabase_url}/auth/v1/user",
|
|
413
|
-
headers={
|
|
414
|
-
"apikey": self.supabase_anon_key,
|
|
415
|
-
"Authorization": f"Bearer {token}",
|
|
416
|
-
},
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
if user_response.is_success:
|
|
420
|
-
user_data = user_response.json()
|
|
421
|
-
return SupabaseAccessToken(
|
|
422
|
-
token=token,
|
|
423
|
-
client_id="", # Unknown client for direct Supabase tokens
|
|
424
|
-
scopes=[],
|
|
425
|
-
supabase_access_token=token,
|
|
426
|
-
user_id=user_data.get("id"),
|
|
427
|
-
email=user_data.get("email"),
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
return None
|
|
431
|
-
|
|
432
|
-
async def revoke_token(self, token: SupabaseAccessToken | SupabaseRefreshToken) -> None:
|
|
433
|
-
"""Revoke a token."""
|
|
434
|
-
# Remove from our mapping
|
|
435
|
-
self.mcp_to_supabase_tokens.pop(token.token, None)
|
|
436
|
-
|
|
437
|
-
# In production, also revoke in Supabase:
|
|
438
|
-
# await self.supabase.auth.admin.sign_out(token.user_id)
|
|
439
|
-
|
|
440
|
-
def _generate_mcp_token(
|
|
441
|
-
self,
|
|
442
|
-
client_id: str,
|
|
443
|
-
user_id: str,
|
|
444
|
-
email: str,
|
|
445
|
-
scopes: list[str],
|
|
446
|
-
supabase_access_token: str,
|
|
447
|
-
) -> str:
|
|
448
|
-
"""Generate an MCP token that wraps Supabase authentication."""
|
|
449
|
-
payload = {
|
|
450
|
-
"iss": self.issuer_url,
|
|
451
|
-
"sub": user_id,
|
|
452
|
-
"client_id": client_id,
|
|
453
|
-
"email": email,
|
|
454
|
-
"scopes": scopes,
|
|
455
|
-
"supabase_token": supabase_access_token[:10] + "...", # Reference only
|
|
456
|
-
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
|
457
|
-
"iat": datetime.now(timezone.utc),
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
# Use Supabase JWT secret if available
|
|
461
|
-
secret = os.getenv("SUPABASE_JWT_SECRET", secrets.token_urlsafe(32))
|
|
462
|
-
|
|
463
|
-
return jwt.encode(payload, secret, algorithm="HS256")
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
"""Migration service for handling background migrations and status tracking."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from enum import Enum
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Optional
|
|
8
|
-
|
|
9
|
-
from loguru import logger
|
|
10
|
-
|
|
11
|
-
from basic_memory.config import BasicMemoryConfig
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class MigrationStatus(Enum):
|
|
15
|
-
"""Status of migration operations."""
|
|
16
|
-
|
|
17
|
-
NOT_NEEDED = "not_needed"
|
|
18
|
-
PENDING = "pending"
|
|
19
|
-
IN_PROGRESS = "in_progress"
|
|
20
|
-
COMPLETED = "completed"
|
|
21
|
-
FAILED = "failed"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class MigrationState:
|
|
26
|
-
"""Current state of migration operations."""
|
|
27
|
-
|
|
28
|
-
status: MigrationStatus
|
|
29
|
-
message: str
|
|
30
|
-
progress: Optional[str] = None
|
|
31
|
-
error: Optional[str] = None
|
|
32
|
-
projects_migrated: int = 0
|
|
33
|
-
projects_total: int = 0
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class MigrationManager:
|
|
37
|
-
"""Manages background migration operations and status tracking."""
|
|
38
|
-
|
|
39
|
-
def __init__(self):
|
|
40
|
-
self._state = MigrationState(
|
|
41
|
-
status=MigrationStatus.NOT_NEEDED, message="No migration required"
|
|
42
|
-
)
|
|
43
|
-
self._migration_task: Optional[asyncio.Task] = None
|
|
44
|
-
|
|
45
|
-
@property
|
|
46
|
-
def state(self) -> MigrationState:
|
|
47
|
-
"""Get current migration state."""
|
|
48
|
-
return self._state
|
|
49
|
-
|
|
50
|
-
@property
|
|
51
|
-
def is_ready(self) -> bool:
|
|
52
|
-
"""Check if the system is ready for normal operations."""
|
|
53
|
-
return self._state.status in (MigrationStatus.NOT_NEEDED, MigrationStatus.COMPLETED)
|
|
54
|
-
|
|
55
|
-
@property
|
|
56
|
-
def status_message(self) -> str:
|
|
57
|
-
"""Get a user-friendly status message."""
|
|
58
|
-
if self._state.status == MigrationStatus.IN_PROGRESS:
|
|
59
|
-
progress = (
|
|
60
|
-
f" ({self._state.projects_migrated}/{self._state.projects_total})"
|
|
61
|
-
if self._state.projects_total > 0
|
|
62
|
-
else ""
|
|
63
|
-
)
|
|
64
|
-
return f"🔄 File sync in progress{progress}: {self._state.message}. Use sync_status() tool for details."
|
|
65
|
-
elif self._state.status == MigrationStatus.FAILED:
|
|
66
|
-
return f"❌ File sync failed: {self._state.error or 'Unknown error'}. Use sync_status() tool for details."
|
|
67
|
-
elif self._state.status == MigrationStatus.COMPLETED:
|
|
68
|
-
return "✅ File sync completed successfully"
|
|
69
|
-
else:
|
|
70
|
-
return "✅ System ready"
|
|
71
|
-
|
|
72
|
-
async def check_migration_needed(self, app_config: BasicMemoryConfig) -> bool:
|
|
73
|
-
"""Check if migration is needed without performing it."""
|
|
74
|
-
from basic_memory import db
|
|
75
|
-
from basic_memory.repository import ProjectRepository
|
|
76
|
-
|
|
77
|
-
try:
|
|
78
|
-
# Get database session
|
|
79
|
-
_, session_maker = await db.get_or_create_db(
|
|
80
|
-
db_path=app_config.database_path, db_type=db.DatabaseType.FILESYSTEM
|
|
81
|
-
)
|
|
82
|
-
project_repository = ProjectRepository(session_maker)
|
|
83
|
-
|
|
84
|
-
# Check for legacy projects
|
|
85
|
-
legacy_projects = []
|
|
86
|
-
for project_name, project_path in app_config.projects.items():
|
|
87
|
-
legacy_dir = Path(project_path) / ".basic-memory"
|
|
88
|
-
if legacy_dir.exists():
|
|
89
|
-
project = await project_repository.get_by_name(project_name)
|
|
90
|
-
if project:
|
|
91
|
-
legacy_projects.append(project)
|
|
92
|
-
|
|
93
|
-
if legacy_projects:
|
|
94
|
-
self._state = MigrationState(
|
|
95
|
-
status=MigrationStatus.PENDING,
|
|
96
|
-
message="Legacy projects detected",
|
|
97
|
-
projects_total=len(legacy_projects),
|
|
98
|
-
)
|
|
99
|
-
return True
|
|
100
|
-
else:
|
|
101
|
-
self._state = MigrationState(
|
|
102
|
-
status=MigrationStatus.NOT_NEEDED, message="No migration required"
|
|
103
|
-
)
|
|
104
|
-
return False
|
|
105
|
-
|
|
106
|
-
except Exception as e:
|
|
107
|
-
logger.error(f"Error checking migration status: {e}")
|
|
108
|
-
self._state = MigrationState(
|
|
109
|
-
status=MigrationStatus.FAILED, message="Migration check failed", error=str(e)
|
|
110
|
-
)
|
|
111
|
-
return False
|
|
112
|
-
|
|
113
|
-
async def start_background_migration(self, app_config: BasicMemoryConfig) -> None:
|
|
114
|
-
"""Start migration in background if needed."""
|
|
115
|
-
if not await self.check_migration_needed(app_config):
|
|
116
|
-
return
|
|
117
|
-
|
|
118
|
-
if self._migration_task and not self._migration_task.done():
|
|
119
|
-
logger.info("Migration already in progress")
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
logger.info("Starting background migration")
|
|
123
|
-
self._migration_task = asyncio.create_task(self._run_migration(app_config))
|
|
124
|
-
|
|
125
|
-
async def _run_migration(self, app_config: BasicMemoryConfig) -> None:
|
|
126
|
-
"""Run the actual migration process."""
|
|
127
|
-
try:
|
|
128
|
-
self._state.status = MigrationStatus.IN_PROGRESS
|
|
129
|
-
self._state.message = "Migrating legacy projects"
|
|
130
|
-
|
|
131
|
-
# Import here to avoid circular imports
|
|
132
|
-
from basic_memory.services.initialization import migrate_legacy_projects
|
|
133
|
-
|
|
134
|
-
# Run the migration
|
|
135
|
-
await migrate_legacy_projects(app_config)
|
|
136
|
-
|
|
137
|
-
self._state = MigrationState(
|
|
138
|
-
status=MigrationStatus.COMPLETED, message="Migration completed successfully"
|
|
139
|
-
)
|
|
140
|
-
logger.info("Background migration completed successfully")
|
|
141
|
-
|
|
142
|
-
except Exception as e:
|
|
143
|
-
logger.error(f"Background migration failed: {e}")
|
|
144
|
-
self._state = MigrationState(
|
|
145
|
-
status=MigrationStatus.FAILED, message="Migration failed", error=str(e)
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
async def wait_for_completion(self, timeout: Optional[float] = None) -> bool:
|
|
149
|
-
"""Wait for migration to complete."""
|
|
150
|
-
if self.is_ready:
|
|
151
|
-
return True
|
|
152
|
-
|
|
153
|
-
if not self._migration_task:
|
|
154
|
-
return False
|
|
155
|
-
|
|
156
|
-
try:
|
|
157
|
-
await asyncio.wait_for(self._migration_task, timeout=timeout)
|
|
158
|
-
return self.is_ready
|
|
159
|
-
except asyncio.TimeoutError:
|
|
160
|
-
return False
|
|
161
|
-
|
|
162
|
-
def mark_completed(self, message: str = "Migration completed") -> None:
|
|
163
|
-
"""Mark migration as completed externally."""
|
|
164
|
-
self._state = MigrationState(status=MigrationStatus.COMPLETED, message=message)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
# Global migration manager instance
|
|
168
|
-
migration_manager = MigrationManager()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|