Spaces:
Running
Running
bigwolfe
commited on
Commit
·
531624f
1
Parent(s):
c30798d
implementation 1 -- all features built, testing needed
Browse files- backend/src/api/main.py +12 -0
- backend/src/mcp/server.py +39 -34
- backend/src/services/auth.py +130 -91
- backend/src/services/config.py +12 -0
- backend/tests/unit/test_auth_strategy.py +51 -0
- frontend/src/components/SearchWidget.tsx +52 -0
- frontend/src/widget.tsx +164 -0
- frontend/vite.config.ts +8 -0
- frontend/widget.html +13 -0
- specs/003-chatgpt-app-integration/tasks.md +18 -18
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 |
-
) ->
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 213 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def _require_secret(self) -> str:
|
|
|
|
|
|
|
| 79 |
secret = self.config.jwt_secret_key
|
| 80 |
if not secret:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 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 |
-
- [
|
| 12 |
-
- [
|
| 13 |
-
- [
|
| 14 |
-
- [
|
| 15 |
|
| 16 |
## Phase 2: Foundational Tasks
|
| 17 |
|
| 18 |
*Goal: Establish authentication and infrastructure required for the integration.*
|
| 19 |
|
| 20 |
-
- [
|
| 21 |
-
- [
|
| 22 |
-
- [
|
| 23 |
-
- [
|
| 24 |
-
- [
|
| 25 |
|
| 26 |
## Phase 3: User Story 1 - The Recall Loop
|
| 27 |
|
| 28 |
*Goal: Enable searching and viewing notes within ChatGPT.*
|
| 29 |
|
| 30 |
-
- [
|
| 31 |
-
- [
|
| 32 |
-
- [
|
| 33 |
-
- [
|
| 34 |
-
- [
|
| 35 |
|
| 36 |
## Phase 4: User Story 2 - In-Context Editing
|
| 37 |
|
| 38 |
*Goal: Enable editing notes from ChatGPT interactions.*
|
| 39 |
|
| 40 |
-
- [
|
| 41 |
-
- [
|
| 42 |
|
| 43 |
## Final Phase: Polish
|
| 44 |
|
| 45 |
*Goal: Ensure seamless experience and robustness.*
|
| 46 |
|
| 47 |
-
- [
|
| 48 |
-
- [
|
| 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 |
|