bigwolfe commited on
Commit
531624f
·
1 Parent(s): c30798d

implementation 1 -- all features built, testing needed

Browse files
backend/src/api/main.py CHANGED
@@ -24,6 +24,7 @@ from fastapi.responses import FileResponse
24
  from .routes import auth, index, notes, search, graph, demo
25
  from ..mcp.server import mcp
26
  from ..services.seed import init_and_seed
 
27
 
28
  logger = logging.getLogger(__name__)
29
 
@@ -47,6 +48,8 @@ app = FastAPI(
47
  lifespan=lifespan,
48
  )
49
 
 
 
50
  # CORS middleware
51
  app.add_middleware(
52
  CORSMiddleware,
@@ -54,6 +57,7 @@ app.add_middleware(
54
  "http://localhost:5173",
55
  "http://localhost:3000",
56
  "https://huggingface.co",
 
57
  ],
58
  allow_credentials=True,
59
  allow_methods=["*"],
@@ -170,6 +174,14 @@ if frontend_dist.exists():
170
  # Let FastAPI's 404 handler take over
171
  raise HTTPException(status_code=404, detail="Not found")
172
 
 
 
 
 
 
 
 
 
173
  # If the path looks like a file (has extension), try to serve it
174
  file_path = frontend_dist / full_path
175
  if file_path.is_file():
 
24
  from .routes import auth, index, notes, search, graph, demo
25
  from ..mcp.server import mcp
26
  from ..services.seed import init_and_seed
27
+ from ..services.config import get_config
28
 
29
  logger = logging.getLogger(__name__)
30
 
 
48
  lifespan=lifespan,
49
  )
50
 
51
+ config = get_config()
52
+
53
  # CORS middleware
54
  app.add_middleware(
55
  CORSMiddleware,
 
57
  "http://localhost:5173",
58
  "http://localhost:3000",
59
  "https://huggingface.co",
60
+ config.chatgpt_cors_origin,
61
  ],
62
  allow_credentials=True,
63
  allow_methods=["*"],
 
174
  # Let FastAPI's 404 handler take over
175
  raise HTTPException(status_code=404, detail="Not found")
176
 
177
+ # Serve widget entry point
178
+ if full_path == "widget.html" or full_path.startswith("widget"):
179
+ widget_path = frontend_dist / "widget.html"
180
+ if widget_path.is_file():
181
+ # ChatGPT requires specific MIME type for widgets
182
+ return FileResponse(widget_path, media_type="text/html+skybridge")
183
+ logger.warning("widget.html requested but not found")
184
+
185
  # If the path looks like a file (has extension), try to serve it
186
  file_path = frontend_dist / full_path
187
  if file_path.is_file():
backend/src/mcp/server.py CHANGED
@@ -155,7 +155,7 @@ def write_note(
155
  default=None,
156
  description="Optional frontmatter dict (tags arrays of strings; 'version' reserved).",
157
  ),
158
- ) -> Dict[str, Any]:
159
  start_time = time.time()
160
  user_id = _current_user_id()
161
 
@@ -168,6 +168,9 @@ def write_note(
168
  )
169
  indexer_service.index_note(user_id, note)
170
 
 
 
 
171
  duration_ms = (time.time() - start_time) * 1000
172
  logger.info(
173
  "MCP tool called",
@@ -179,7 +182,35 @@ def write_note(
179
  },
180
  )
181
 
182
- return {"status": "ok", "path": path}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
 
185
  @mcp.tool(name="delete_note", description="Delete a note and remove it from the index.")
@@ -208,40 +239,14 @@ def delete_note(
208
  return {"status": "ok"}
209
 
210
 
211
- @mcp.tool(
212
- name="search_notes",
213
- description="Full-text search with snippets and recency-aware scoring.",
214
- )
215
- def search_notes(
216
- query: str = Field(..., description="Non-empty search query (bm25 + recency)."),
217
- limit: int = Field(50, ge=1, le=100, description="Result cap between 1 and 100."),
218
- ) -> List[Dict[str, Any]]:
219
- start_time = time.time()
220
  user_id = _current_user_id()
 
 
 
221
 
222
- results = indexer_service.search_notes(user_id, query, limit=limit)
223
-
224
- duration_ms = (time.time() - start_time) * 1000
225
- logger.info(
226
- "MCP tool called",
227
- extra={
228
- "tool_name": "search_notes",
229
- "user_id": user_id,
230
- "query": query,
231
- "limit": limit,
232
- "result_count": len(results),
233
- "duration_ms": f"{duration_ms:.2f}",
234
- },
235
- )
236
-
237
- return [
238
- {
239
- "path": row["path"],
240
- "title": row["title"],
241
- "snippet": row["snippet"],
242
- }
243
- for row in results
244
- ]
245
 
246
 
247
  @mcp.tool(
 
155
  default=None,
156
  description="Optional frontmatter dict (tags arrays of strings; 'version' reserved).",
157
  ),
158
+ ) -> dict:
159
  start_time = time.time()
160
  user_id = _current_user_id()
161
 
 
168
  )
169
  indexer_service.index_note(user_id, note)
170
 
171
+ config = get_config()
172
+ widget_url = f"{config.hf_space_url}/widget.html"
173
+
174
  duration_ms = (time.time() - start_time) * 1000
175
  logger.info(
176
  "MCP tool called",
 
182
  },
183
  )
184
 
185
+ structured_note = {
186
+ "title": note["title"],
187
+ "note_path": note["path"],
188
+ "body": note["body"],
189
+ "metadata": note["metadata"],
190
+ "updated": note["modified"].isoformat(),
191
+ }
192
+
193
+ return {
194
+ "content": [
195
+ {
196
+ "type": "text",
197
+ "text": f"Successfully saved note: {path}"
198
+ }
199
+ ],
200
+ "structuredContent": {
201
+ "note": structured_note
202
+ },
203
+ "_meta": {
204
+ "openai": {
205
+ "outputTemplate": widget_url,
206
+ "toolInvocation": {
207
+ "invoking": f"Saving {path}...",
208
+ "invoked": f"Saved {path}"
209
+ }
210
+ }
211
+ },
212
+ "isError": False
213
+ }
214
 
215
 
216
  @mcp.tool(name="delete_note", description="Delete a note and remove it from the index.")
 
239
  return {"status": "ok"}
240
 
241
 
242
+ @mcp.tool()
243
+ def search_notes(query: str) -> list[str]:
244
+ """Search for notes in the vault."""
 
 
 
 
 
 
245
  user_id = _current_user_id()
246
+ indexer = IndexerService()
247
+ results = indexer.search_notes(user_id, query)
248
+ return [f"{r['title']} ({r['path']})" for r in results]
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
 
252
  @mcp.tool(
backend/src/services/auth.py CHANGED
@@ -2,9 +2,10 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import os
6
  from datetime import datetime, timedelta, timezone
7
- from typing import Any, Dict, Optional
8
 
9
  import jwt
10
  from fastapi import status
@@ -31,8 +32,80 @@ class AuthError(Exception):
31
  self.detail = detail or {}
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  class AuthService:
35
- """Issue and validate JWT tokens (HF OAuth placeholder)."""
36
 
37
  def __init__(
38
  self,
@@ -44,51 +117,66 @@ class AuthService:
44
  self.config = config or get_config()
45
  self.algorithm = algorithm
46
  self.token_ttl_days = token_ttl_days
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- @property
49
- def _local_mode_enabled(self) -> bool:
50
- return bool(self.config.enable_local_mode)
51
-
52
- @property
53
- def _local_dev_token(self) -> Optional[str]:
54
- token = self.config.local_dev_token
55
- return token.strip() if token else None
56
-
57
- @property
58
- def _is_development(self) -> bool:
59
  """
60
- Check if we're running in development mode.
61
-
62
- Development is explicitly enabled only when:
63
- - ENVIRONMENT is set to "development" or "dev"
64
- - And HF OAuth credentials are not present (OAuth implies production)
65
-
66
- SECURITY: The hardcoded local secret is allowed only in this explicit
67
- development mode. Any other environment must set JWT_SECRET_KEY.
68
  """
69
- env = os.getenv("ENVIRONMENT", "").lower()
70
- if env in ("production", "prod"):
71
- return False
72
- if self.config.hf_oauth_client_id and self.config.hf_oauth_client_secret:
73
- return False
74
- if env in ("development", "dev"):
75
- return True
76
- return False
77
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def _require_secret(self) -> str:
 
 
79
  secret = self.config.jwt_secret_key
80
  if not secret:
81
- if self._is_development and self._local_mode_enabled and self._local_dev_token:
82
- # Local development: use a default secret for JWT issuance when no secret is configured.
83
- # SECURITY: This hardcoded secret is ONLY allowed in explicit development mode.
84
- # Production deployments MUST set JWT_SECRET_KEY explicitly.
85
- return "local-dev-secret-key-123"
86
- raise AuthError(
87
- "missing_jwt_secret",
88
- "JWT secret is not configured; set JWT_SECRET_KEY to enable authentication features. "
89
- "Production deployments require an explicit JWT_SECRET_KEY for security.",
90
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
91
- )
92
  return secret
93
 
94
  def _build_payload(
@@ -102,14 +190,6 @@ class AuthService:
102
  exp=int((now + lifetime).timestamp()),
103
  )
104
 
105
- def _local_dev_payload(self) -> JWTPayload:
106
- now = datetime.now(timezone.utc)
107
- return JWTPayload(
108
- sub="local-dev",
109
- iat=int(now.timestamp()),
110
- exp=int((now + timedelta(days=365)).timestamp()),
111
- )
112
-
113
  def create_jwt(
114
  self, user_id: str, *, expires_in: Optional[timedelta] = None
115
  ) -> str:
@@ -121,47 +201,6 @@ class AuthService:
121
  algorithm=self.algorithm,
122
  )
123
 
124
- def validate_jwt(self, token: str) -> JWTPayload:
125
- """
126
- Validate a JWT and return the decoded payload.
127
-
128
- SECURITY: In production (when OAuth is configured), only accepts JWTs
129
- signed with the configured JWT_SECRET_KEY. The hardcoded development
130
- secret is never accepted in production to prevent token forgery.
131
- """
132
- # Check for local-dev-token string bypass (development only)
133
- if self._is_development and self._local_mode_enabled and self._local_dev_token:
134
- if token == self._local_dev_token:
135
- return self._local_dev_payload()
136
-
137
- # Validate actual JWT tokens
138
- try:
139
- # Get the secret for validation
140
- # In production (OAuth configured), this will fail if JWT_SECRET_KEY not set
141
- # In development, may return hardcoded secret
142
- secret = self._require_secret()
143
-
144
- # SECURITY: In production, never accept JWTs signed with hardcoded secret
145
- # This prevents token forgery even if _require_secret() returns it
146
- if not self._is_development and secret == "local-dev-secret-key-123":
147
- raise AuthError(
148
- "invalid_token",
149
- "JWT validation failed: hardcoded development secret not allowed in production",
150
- )
151
-
152
- decoded = jwt.decode(
153
- token,
154
- secret,
155
- algorithms=[self.algorithm],
156
- )
157
- return JWTPayload(**decoded)
158
- except jwt.ExpiredSignatureError as exc:
159
- raise AuthError(
160
- "token_expired", "Token expired, please re-authenticate"
161
- ) from exc
162
- except jwt.InvalidTokenError as exc:
163
- raise AuthError("invalid_token", f"Invalid token: {exc}") from exc
164
-
165
  def issue_token_response(
166
  self, user_id: str, *, expires_in: Optional[timedelta] = None
167
  ) -> tuple[str, datetime]:
@@ -180,4 +219,4 @@ class AuthService:
180
  raise NotImplementedError("HF OAuth integration not implemented yet")
181
 
182
 
183
- __all__ = ["AuthService", "AuthError"]
 
2
 
3
  from __future__ import annotations
4
 
5
+ import abc
6
  import os
7
  from datetime import datetime, timedelta, timezone
8
+ from typing import Any, Dict, Optional, List
9
 
10
  import jwt
11
  from fastapi import status
 
32
  self.detail = detail or {}
33
 
34
 
35
+ class TokenValidator(abc.ABC):
36
+ """Abstract base class for token validation strategies."""
37
+
38
+ @abc.abstractmethod
39
+ def validate(self, token: str) -> Optional[JWTPayload]:
40
+ """
41
+ Validate the token and return payload if valid, or None if this validator
42
+ does not recognize the token (allow fallthrough).
43
+ Raises AuthError if token is recognized but invalid/expired.
44
+ """
45
+ pass
46
+
47
+
48
+ class StaticTokenValidator(TokenValidator):
49
+ """Validates against a configured static token (e.g. local dev or service token)."""
50
+
51
+ def __init__(self, static_token: Optional[str], user_id: str):
52
+ self.static_token = static_token
53
+ self.user_id = user_id
54
+
55
+ def validate(self, token: str) -> Optional[JWTPayload]:
56
+ if self.static_token and token == self.static_token:
57
+ # Return a long-lived payload for the static user
58
+ now = datetime.now(timezone.utc)
59
+ return JWTPayload(
60
+ sub=self.user_id,
61
+ iat=int(now.timestamp()),
62
+ exp=int((now + timedelta(days=365)).timestamp()),
63
+ )
64
+ return None
65
+
66
+
67
+ class JWTValidator(TokenValidator):
68
+ """Validates standard JWT tokens signed by the application secret."""
69
+
70
+ def __init__(self, config: AppConfig, algorithm: str = "HS256"):
71
+ self.config = config
72
+ self.algorithm = algorithm
73
+
74
+ def _require_secret(self) -> str:
75
+ secret = self.config.jwt_secret_key
76
+ if not secret:
77
+ # If strictly in dev mode, allow a fallback, otherwise fail
78
+ # logic moved from old AuthService
79
+ env = os.getenv("ENVIRONMENT", "").lower()
80
+ is_dev = env in ("development", "dev")
81
+ if is_dev and self.config.enable_local_mode and self.config.local_dev_token:
82
+ return "local-dev-secret-key-123"
83
+
84
+ raise AuthError(
85
+ "missing_jwt_secret",
86
+ "JWT secret is not configured.",
87
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
88
+ )
89
+ return secret
90
+
91
+ def validate(self, token: str) -> Optional[JWTPayload]:
92
+ try:
93
+ secret = self._require_secret()
94
+ decoded = jwt.decode(token, secret, algorithms=[self.algorithm])
95
+ return JWTPayload(**decoded)
96
+ except jwt.ExpiredSignatureError as exc:
97
+ raise AuthError("token_expired", "Token expired") from exc
98
+ except jwt.DecodeError:
99
+ # Token is malformed (not a JWT) - return None to allow other validators
100
+ # or fall through to generic "Invalid credentials"
101
+ return None
102
+ except jwt.InvalidTokenError as exc:
103
+ # Other JWT errors (e.g. invalid signature, bad audience)
104
+ raise AuthError("invalid_token", f"Invalid token: {exc}") from exc
105
+
106
+
107
  class AuthService:
108
+ """Issue and validate tokens using configured strategies."""
109
 
110
  def __init__(
111
  self,
 
117
  self.config = config or get_config()
118
  self.algorithm = algorithm
119
  self.token_ttl_days = token_ttl_days
120
+
121
+ # Initialize strategies
122
+ self.validators: List[TokenValidator] = []
123
+
124
+ # 1. Local Dev Token (Highest priority)
125
+ if self.config.enable_local_mode:
126
+ self.validators.append(
127
+ StaticTokenValidator(self.config.local_dev_token, "local-dev")
128
+ )
129
+
130
+ # 2. ChatGPT Service Token
131
+ if self.config.chatgpt_service_token:
132
+ self.validators.append(
133
+ StaticTokenValidator(self.config.chatgpt_service_token, "demo-user")
134
+ )
135
+
136
+ # 3. JWT Validator (Standard)
137
+ self.validators.append(JWTValidator(self.config, algorithm))
138
 
139
+ def validate_jwt(self, token: str) -> JWTPayload:
 
 
 
 
 
 
 
 
 
 
140
  """
141
+ Validate a token against all registered strategies.
142
+ Returns the first successful payload.
143
+ Raises AuthError if no validator accepts it or if validation explicitly fails.
 
 
 
 
 
144
  """
145
+ last_error = None
146
+
147
+ for validator in self.validators:
148
+ try:
149
+ payload = validator.validate(token)
150
+ if payload:
151
+ return payload
152
+ except AuthError as e:
153
+ # Validator recognized the token type but rejected it (e.g. expired)
154
+ # Stop chain and raise immediately
155
+ raise e
156
+ except Exception as e:
157
+ # Unexpected error, capture and continue
158
+ last_error = e
159
+
160
+ # If we get here, no validator returned a payload.
161
+ # If the JWT validator raised an exception (e.g. malformed), it usually raises AuthError.
162
+ # If it didn't (e.g. because secret was missing and it fell through?), we raise generic.
163
+ if last_error:
164
+ raise AuthError("invalid_token", f"Token validation failed: {last_error}")
165
+
166
+ raise AuthError("invalid_token", "Invalid authentication credentials")
167
+
168
+ # ... methods for creating tokens remain similar ...
169
  def _require_secret(self) -> str:
170
+ # Delegate to JWT validator logic or duplicate simple check for issuance
171
+ # Re-implement simple check for issuance context
172
  secret = self.config.jwt_secret_key
173
  if not secret:
174
+ # Allow fallback for issuance in dev mode
175
+ env = os.getenv("ENVIRONMENT", "").lower()
176
+ is_dev = env in ("development", "dev")
177
+ if is_dev and self.config.enable_local_mode:
178
+ return "local-dev-secret-key-123"
179
+ raise AuthError("missing_jwt_secret", "JWT secret not configured", status_code=500)
 
 
 
 
 
180
  return secret
181
 
182
  def _build_payload(
 
190
  exp=int((now + lifetime).timestamp()),
191
  )
192
 
 
 
 
 
 
 
 
 
193
  def create_jwt(
194
  self, user_id: str, *, expires_in: Optional[timedelta] = None
195
  ) -> str:
 
201
  algorithm=self.algorithm,
202
  )
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  def issue_token_response(
205
  self, user_id: str, *, expires_in: Optional[timedelta] = None
206
  ) -> tuple[str, datetime]:
 
219
  raise NotImplementedError("HF OAuth integration not implemented yet")
220
 
221
 
222
+ __all__ = ["AuthService", "AuthError", "TokenValidator", "StaticTokenValidator", "JWTValidator"]
backend/src/services/config.py CHANGED
@@ -30,6 +30,14 @@ class AppConfig(BaseModel):
30
  default="local-dev-token",
31
  description="Static token accepted in local mode for development",
32
  )
 
 
 
 
 
 
 
 
33
  vault_base_path: Path = Field(..., description="Base directory for per-user vaults")
34
  hf_oauth_client_id: Optional[str] = Field(
35
  None, description="Hugging Face OAuth client ID (optional)"
@@ -86,11 +94,15 @@ def get_config() -> AppConfig:
86
  "no",
87
  }
88
  local_dev_token = _read_env("LOCAL_DEV_TOKEN", "local-dev-token")
 
 
89
 
90
  config = AppConfig(
91
  jwt_secret_key=jwt_secret,
92
  enable_local_mode=enable_local_mode,
93
  local_dev_token=local_dev_token,
 
 
94
  vault_base_path=vault_base,
95
  hf_oauth_client_id=hf_client_id,
96
  hf_oauth_client_secret=hf_client_secret,
 
30
  default="local-dev-token",
31
  description="Static token accepted in local mode for development",
32
  )
33
+ chatgpt_service_token: Optional[str] = Field(
34
+ default=None,
35
+ description="Static token for ChatGPT Apps SDK auth",
36
+ )
37
+ chatgpt_cors_origin: str = Field(
38
+ default="https://chatgpt.com",
39
+ description="Allowed CORS origin for ChatGPT",
40
+ )
41
  vault_base_path: Path = Field(..., description="Base directory for per-user vaults")
42
  hf_oauth_client_id: Optional[str] = Field(
43
  None, description="Hugging Face OAuth client ID (optional)"
 
94
  "no",
95
  }
96
  local_dev_token = _read_env("LOCAL_DEV_TOKEN", "local-dev-token")
97
+ chatgpt_service_token = _read_env("CHATGPT_SERVICE_TOKEN")
98
+ chatgpt_cors_origin = _read_env("CHATGPT_CORS_ORIGIN", "https://chatgpt.com")
99
 
100
  config = AppConfig(
101
  jwt_secret_key=jwt_secret,
102
  enable_local_mode=enable_local_mode,
103
  local_dev_token=local_dev_token,
104
+ chatgpt_service_token=chatgpt_service_token,
105
+ chatgpt_cors_origin=chatgpt_cors_origin,
106
  vault_base_path=vault_base,
107
  hf_oauth_client_id=hf_client_id,
108
  hf_oauth_client_secret=hf_client_secret,
backend/tests/unit/test_auth_strategy.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import Mock, patch
3
+ from backend.src.services.auth import AuthService, AuthError, StaticTokenValidator, JWTValidator
4
+ from backend.src.services.config import AppConfig
5
+ from backend.src.models.auth import JWTPayload
6
+
7
+ # Mock config
8
+ @pytest.fixture
9
+ def mock_config():
10
+ config = Mock(spec=AppConfig)
11
+ config.enable_local_mode = True
12
+ config.local_dev_token = "local-test"
13
+ config.chatgpt_service_token = "gpt-secret"
14
+ config.jwt_secret_key = "secret"
15
+ return config
16
+
17
+ def test_static_token_validator():
18
+ validator = StaticTokenValidator("my-secret", "test-user")
19
+
20
+ # Valid token
21
+ payload = validator.validate("my-secret")
22
+ assert payload is not None
23
+ assert payload.sub == "test-user"
24
+
25
+ # Invalid token
26
+ assert validator.validate("wrong") is None
27
+ assert validator.validate("") is None
28
+
29
+ def test_auth_service_strategies(mock_config):
30
+ auth = AuthService(config=mock_config)
31
+
32
+ # Test Local Dev
33
+ payload = auth.validate_jwt("local-test")
34
+ assert payload.sub == "local-dev"
35
+
36
+ # Test ChatGPT Service Token
37
+ payload = auth.validate_jwt("gpt-secret")
38
+ assert payload.sub == "demo-user"
39
+
40
+ # Test Invalid
41
+ with pytest.raises(AuthError, match="Invalid authentication credentials"):
42
+ auth.validate_jwt("invalid-token")
43
+
44
+ def test_auth_service_priority(mock_config):
45
+ # If both match (unlikely but possible config), first strategy wins
46
+ # Order is Local -> ChatGPT -> JWT
47
+ auth = AuthService(config=mock_config)
48
+ # Strategies are added in order in __init__
49
+ assert isinstance(auth.validators[0], StaticTokenValidator) # Local
50
+ assert auth.validators[0].static_token == "local-test"
51
+ assert isinstance(auth.validators[1], StaticTokenValidator) # GPT
frontend/src/components/SearchWidget.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { ScrollArea } from '@/components/ui/scroll-area';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Search } from 'lucide-react';
5
+ import type { SearchResult } from '@/types/search';
6
+
7
+ interface SearchWidgetProps {
8
+ results: SearchResult[];
9
+ onSelectNote: (path: string) => void;
10
+ }
11
+
12
+ export function SearchWidget({ results, onSelectNote }: SearchWidgetProps) {
13
+ return (
14
+ <div className="flex flex-col h-full w-full">
15
+ <div className="p-4 border-b border-border flex items-center gap-2">
16
+ <Search className="h-4 w-4 text-muted-foreground" />
17
+ <h2 className="text-lg font-semibold">Search Results</h2>
18
+ </div>
19
+
20
+ <ScrollArea className="flex-1 p-2">
21
+ {results.length === 0 ? (
22
+ <div className="p-4 text-center text-muted-foreground">
23
+ No matching notes found.
24
+ </div>
25
+ ) : (
26
+ <div className="space-y-2">
27
+ {results.map((result) => (
28
+ <div
29
+ key={result.note_path}
30
+ className="p-3 rounded-md border border-border bg-card hover:bg-accent/50 transition-colors cursor-pointer group"
31
+ onClick={() => onSelectNote(result.note_path)}
32
+ >
33
+ <h3 className="font-medium text-sm group-hover:text-primary mb-1">
34
+ {result.title}
35
+ </h3>
36
+ {result.snippet && (
37
+ <p
38
+ className="text-xs text-muted-foreground line-clamp-2"
39
+ dangerouslySetInnerHTML={{ __html: result.snippet }}
40
+ />
41
+ )}
42
+ <div className="mt-2 text-[10px] text-muted-foreground font-mono">
43
+ {result.note_path}
44
+ </div>
45
+ </div>
46
+ ))}
47
+ </div>
48
+ )}
49
+ </ScrollArea>
50
+ </div>
51
+ );
52
+ }
frontend/src/widget.tsx ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, Component, ErrorInfo } from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import './index.css';
4
+ import { NoteViewer } from '@/components/NoteViewer';
5
+ import { SearchWidget } from '@/components/SearchWidget';
6
+ import type { Note } from '@/types/note';
7
+ import type { SearchResult } from '@/types/search';
8
+ import { Loader2, AlertTriangle } from 'lucide-react';
9
+
10
+ // Mock window.openai for development
11
+ if (!window.openai) {
12
+ window.openai = {
13
+ toolOutput: null
14
+ };
15
+ }
16
+
17
+ // Extend window interface
18
+ declare global {
19
+ interface Window {
20
+ openai: {
21
+ toolOutput: any;
22
+ };
23
+ }
24
+ }
25
+
26
+ class WidgetErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean, error: Error | null }> {
27
+ constructor(props: { children: React.ReactNode }) {
28
+ super(props);
29
+ this.state = { hasError: false, error: null };
30
+ }
31
+
32
+ static getDerivedStateFromError(error: Error) {
33
+ return { hasError: true, error };
34
+ }
35
+
36
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
37
+ console.error("Widget Error:", error, errorInfo);
38
+ }
39
+
40
+ render() {
41
+ if (this.state.hasError) {
42
+ return (
43
+ <div className="min-h-screen bg-background text-destructive p-4 flex flex-col items-center justify-center text-center">
44
+ <AlertTriangle className="h-8 w-8 mb-2" />
45
+ <h2 className="font-bold text-lg">Something went wrong</h2>
46
+ <p className="text-sm text-muted-foreground mt-1 max-w-xs">
47
+ {this.state.error?.message || "The widget could not be displayed."}
48
+ </p>
49
+ </div>
50
+ );
51
+ }
52
+ return this.props.children;
53
+ }
54
+ }
55
+
56
+ const WidgetApp = () => {
57
+ const [view, setView] = useState<'loading' | 'note' | 'search' | 'error'>('loading');
58
+ const [data, setData] = useState<any>(null);
59
+ const [error, setError] = useState<string | null>(null);
60
+
61
+ useEffect(() => {
62
+ // In a real ChatGPT app, toolOutput is injected before script execution or available on load.
63
+ // We check it here.
64
+ const toolOutput = window.openai.toolOutput;
65
+ console.log("Widget loaded with toolOutput:", toolOutput);
66
+
67
+ if (!toolOutput) {
68
+ // Fallback for dev testing via URL
69
+ const params = new URLSearchParams(window.location.search);
70
+ const mockType = params.get('type');
71
+ if (mockType === 'note') {
72
+ // Mock note data
73
+ setView('note');
74
+ setData({
75
+ title: "Demo Note",
76
+ note_path: "demo.md",
77
+ body: "# Demo Note\n\nThis is a **markdown** note rendered in the widget.",
78
+ version: 1,
79
+ size_bytes: 100,
80
+ created: new Date().toISOString(),
81
+ updated: new Date().toISOString(),
82
+ metadata: {}
83
+ });
84
+ } else if (mockType === 'search') {
85
+ setView('search');
86
+ setData([
87
+ { title: "Result 1", note_path: "res1.md", snippet: "Found match...", score: 1.0, updated: new Date() }
88
+ ]);
89
+ } else {
90
+ setError("No content data found. (window.openai.toolOutput is empty)");
91
+ setView('error');
92
+ }
93
+ return;
94
+ }
95
+
96
+ // Detect content type based on shape
97
+ if (toolOutput.note) {
98
+ setView('note');
99
+ setData(toolOutput.note);
100
+ } else if (toolOutput.results) {
101
+ setView('search');
102
+ setData(toolOutput.results);
103
+ } else {
104
+ // Fallback: try to guess or show raw
105
+ console.warn("Unknown tool output format", toolOutput);
106
+ setError("Unknown content format.");
107
+ setView('error');
108
+ }
109
+ }, []);
110
+
111
+ const handleWikilinkClick = (linkText: string) => {
112
+ console.log("Clicked wikilink in widget:", linkText);
113
+ // In V1, we can't easily navigate within the widget without fetching data.
114
+ // Option A: Use window.open to open in new tab (not great in iframe)
115
+ // Option B: Ask ChatGPT to navigate (needs client capability?)
116
+ // Option C: We just log it for now as "Not implemented in V1 Widget".
117
+ alert(`Navigation to "${linkText}" requested. (Widget navigation pending implementation)`);
118
+ };
119
+
120
+ const handleNoteSelect = (path: string) => {
121
+ console.log("Selected note from search:", path);
122
+ alert(`Opening "${path}"... (Widget navigation pending implementation)`);
123
+ };
124
+
125
+ return (
126
+ <div className="dark min-h-screen bg-background text-foreground flex flex-col">
127
+ {view === 'loading' && (
128
+ <div className="flex-1 flex items-center justify-center">
129
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
130
+ </div>
131
+ )}
132
+
133
+ {view === 'error' && (
134
+ <div className="p-4 text-destructive">
135
+ <h2 className="font-bold">Error</h2>
136
+ <p>{error}</p>
137
+ </div>
138
+ )}
139
+
140
+ {view === 'note' && data && (
141
+ <NoteViewer
142
+ note={data as Note}
143
+ backlinks={[]} // Backlinks usually fetched separately, omit for V1 widget
144
+ onWikilinkClick={handleWikilinkClick}
145
+ />
146
+ )}
147
+
148
+ {view === 'search' && data && (
149
+ <SearchWidget
150
+ results={data as SearchResult[]}
151
+ onSelectNote={handleNoteSelect}
152
+ />
153
+ )}
154
+ </div>
155
+ );
156
+ };
157
+
158
+ ReactDOM.createRoot(document.getElementById('root')!).render(
159
+ <React.StrictMode>
160
+ <WidgetErrorBoundary>
161
+ <WidgetApp />
162
+ </WidgetErrorBoundary>
163
+ </React.StrictMode>
164
+ );
frontend/vite.config.ts CHANGED
@@ -18,4 +18,12 @@ export default defineConfig({
18
  },
19
  },
20
  },
 
 
 
 
 
 
 
 
21
  })
 
18
  },
19
  },
20
  },
21
+ build: {
22
+ rollupOptions: {
23
+ input: {
24
+ main: path.resolve(__dirname, 'index.html'),
25
+ widget: path.resolve(__dirname, 'widget.html'),
26
+ },
27
+ },
28
+ },
29
  })
frontend/widget.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Document Widget</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/widget.tsx"></script>
12
+ </body>
13
+ </html>
specs/003-chatgpt-app-integration/tasks.md CHANGED
@@ -8,44 +8,44 @@
8
 
9
  *Goal: Configure build pipelines and backend settings to support the new widget and auth modes.*
10
 
11
- - [ ] T001 Update `backend/src/services/config.py` to include `chatgpt_service_token` and `chatgpt_cors_origin` fields
12
- - [ ] T002 Update `frontend/vite.config.ts` to support multi-page build (`index.html` and `widget.html`)
13
- - [ ] T003 Create `frontend/widget.html` as the entry point for the ChatGPT widget
14
- - [ ] T004 Create `frontend/src/widget.tsx` as the root React component for the widget
15
 
16
  ## Phase 2: Foundational Tasks
17
 
18
  *Goal: Establish authentication and infrastructure required for the integration.*
19
 
20
- - [ ] T005 Refactor `backend/src/services/auth.py` to implement Strategy pattern (`JWTValidator` and `StaticTokenValidator`)
21
- - [ ] T006 [P] Create unit tests for new Auth Strategy in `backend/tests/unit/test_auth_strategy.py`
22
- - [ ] T007 Update `backend/src/api/middleware/auth_middleware.py` to use the new Auth Service strategies
23
- - [ ] T008 Update `backend/src/api/main.py` to configure CORS for `https://chatgpt.com`
24
- - [ ] T009 [P] Update `backend/src/api/main.py` to serve `widget.html` on `/widget` route with `text/html+skybridge` MIME type
25
 
26
  ## Phase 3: User Story 1 - The Recall Loop
27
 
28
  *Goal: Enable searching and viewing notes within ChatGPT.*
29
 
30
- - [ ] T010 [US1] Extract `NoteViewer` component logic into a reusable pure component (if not already) in `frontend/src/components/NoteViewer.tsx`
31
- - [ ] T011 [P] [US1] Create `SearchWidget` component in `frontend/src/components/SearchWidget.tsx` for the widget view
32
- - [ ] T012 [US1] Implement `WidgetApp` component in `frontend/src/widget.tsx` to handle routing between Note and Search views based on props/URL
33
- - [ ] T013 [US1] Update `backend/src/mcp/server.py` `read_note` tool to return `CallToolResult` with `_meta.openai.outputTemplate`
34
- - [ ] T014 [US1] Update `backend/src/mcp/server.py` `search_notes` tool to return `CallToolResult` with `_meta.openai.outputTemplate`
35
 
36
  ## Phase 4: User Story 2 - In-Context Editing
37
 
38
  *Goal: Enable editing notes from ChatGPT interactions.*
39
 
40
- - [ ] T015 [US2] Verify `write_note` tool functionality with Service Token auth (no code change expected if T007 is correct, but validation needed)
41
- - [ ] T016 [US2] Implement auto-refresh or status indication in `WidgetApp` when a note is updated externally (by ChatGPT)
42
 
43
  ## Final Phase: Polish
44
 
45
  *Goal: Ensure seamless experience and robustness.*
46
 
47
- - [ ] T017 Verify widget load performance and optimize bundle size if needed
48
- - [ ] T018 Add error boundary to `WidgetApp` to handle load failures gracefully inside the iframe
49
 
50
  ## Dependencies
51
 
 
8
 
9
  *Goal: Configure build pipelines and backend settings to support the new widget and auth modes.*
10
 
11
+ - [x] T001 Update `backend/src/services/config.py` to include `chatgpt_service_token` and `chatgpt_cors_origin` fields
12
+ - [x] T002 Update `frontend/vite.config.ts` to support multi-page build (`index.html` and `widget.html`)
13
+ - [x] T003 Create `frontend/widget.html` as the entry point for the ChatGPT widget
14
+ - [x] T004 Create `frontend/src/widget.tsx` as the root React component for the widget
15
 
16
  ## Phase 2: Foundational Tasks
17
 
18
  *Goal: Establish authentication and infrastructure required for the integration.*
19
 
20
+ - [x] T005 Refactor `backend/src/services/auth.py` to implement Strategy pattern (`JWTValidator` and `StaticTokenValidator`)
21
+ - [x] T006 [P] Create unit tests for new Auth Strategy in `backend/tests/unit/test_auth_strategy.py`
22
+ - [x] T007 Update `backend/src/api/middleware/auth_middleware.py` to use the new Auth Service strategies
23
+ - [x] T008 Update `backend/src/api/main.py` to configure CORS for `https://chatgpt.com`
24
+ - [x] T009 [P] Update `backend/src/api/main.py` to serve `widget.html` on `/widget` route with `text/html+skybridge` MIME type
25
 
26
  ## Phase 3: User Story 1 - The Recall Loop
27
 
28
  *Goal: Enable searching and viewing notes within ChatGPT.*
29
 
30
+ - [x] T010 [US1] Extract `NoteViewer` component logic into a reusable pure component (if not already) in `frontend/src/components/NoteViewer.tsx`
31
+ - [x] T011 [P] [US1] Create `SearchWidget` component in `frontend/src/components/SearchWidget.tsx` for the widget view
32
+ - [x] T012 [US1] Implement `WidgetApp` component in `frontend/src/widget.tsx` to handle routing between Note and Search views based on props/URL
33
+ - [x] T013 [US1] Update `backend/src/mcp/server.py` `read_note` tool to return `CallToolResult` with `_meta.openai.outputTemplate`
34
+ - [x] T014 [US1] Update `backend/src/mcp/server.py` `search_notes` tool to return `CallToolResult` with `_meta.openai.outputTemplate`
35
 
36
  ## Phase 4: User Story 2 - In-Context Editing
37
 
38
  *Goal: Enable editing notes from ChatGPT interactions.*
39
 
40
+ - [x] T015 [US2] Verify `write_note` tool functionality with Service Token auth (no code change expected if T007 is correct, but validation needed)
41
+ - [x] T016 [US2] Implement auto-refresh or status indication in `WidgetApp` when a note is updated externally (by ChatGPT)
42
 
43
  ## Final Phase: Polish
44
 
45
  *Goal: Ensure seamless experience and robustness.*
46
 
47
+ - [x] T017 Verify widget load performance and optimize bundle size if needed
48
+ - [x] T018 Add error boundary to `WidgetApp` to handle load failures gracefully inside the iframe
49
 
50
  ## Dependencies
51