Spaces:
Sleeping
Sleeping
Foundational changes
Browse files- backend/src/api/middleware/__init__.py +16 -0
- backend/src/api/middleware/auth_middleware.py +47 -0
- backend/src/api/middleware/error_handlers.py +87 -0
- backend/src/models/__init__.py +23 -0
- backend/src/models/auth.py +26 -0
- backend/src/models/index.py +60 -0
- backend/src/models/note.py +102 -0
- backend/src/models/search.py +27 -0
- backend/src/models/user.py +43 -0
- backend/src/services/__init__.py +18 -0
- backend/src/services/auth.py +99 -0
- backend/src/services/config.py +78 -0
- backend/src/services/vault.py +73 -0
- frontend/src/types/auth.ts +18 -0
- frontend/src/types/note.ts +67 -0
- frontend/src/types/search.ts +29 -0
- frontend/src/types/user.ts +19 -0
backend/src/api/middleware/__init__.py
CHANGED
|
@@ -1 +1,17 @@
|
|
| 1 |
"""FastAPI middleware for authentication and error handling."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""FastAPI middleware for authentication and error handling."""
|
| 2 |
+
|
| 3 |
+
from .auth_middleware import extract_user_id_from_jwt
|
| 4 |
+
from .error_handlers import (
|
| 5 |
+
http_exception_handler,
|
| 6 |
+
internal_exception_handler,
|
| 7 |
+
register_error_handlers,
|
| 8 |
+
validation_exception_handler,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"extract_user_id_from_jwt",
|
| 13 |
+
"register_error_handlers",
|
| 14 |
+
"validation_exception_handler",
|
| 15 |
+
"http_exception_handler",
|
| 16 |
+
"internal_exception_handler",
|
| 17 |
+
]
|
backend/src/api/middleware/auth_middleware.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication dependency helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Annotated, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import Header, HTTPException, status
|
| 8 |
+
|
| 9 |
+
from ...services.auth import AuthError, AuthService
|
| 10 |
+
|
| 11 |
+
auth_service = AuthService()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _unauthorized(message: str, error: str = "unauthorized") -> HTTPException:
|
| 15 |
+
return HTTPException(
|
| 16 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 17 |
+
detail={"error": error, "message": message},
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def extract_user_id_from_jwt(
|
| 22 |
+
authorization: Annotated[Optional[str], Header(default=None, alias="Authorization")] = None,
|
| 23 |
+
) -> str:
|
| 24 |
+
"""
|
| 25 |
+
Extract and validate the user_id from a Bearer token.
|
| 26 |
+
|
| 27 |
+
Raises HTTPException if the header is missing/invalid.
|
| 28 |
+
"""
|
| 29 |
+
if not authorization:
|
| 30 |
+
raise _unauthorized("Authorization header required")
|
| 31 |
+
|
| 32 |
+
scheme, _, token = authorization.partition(" ")
|
| 33 |
+
if scheme.lower() != "bearer" or not token:
|
| 34 |
+
raise _unauthorized("Authorization header must be in format: Bearer <token>")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
payload = auth_service.validate_jwt(token)
|
| 38 |
+
except AuthError as exc:
|
| 39 |
+
raise HTTPException(
|
| 40 |
+
status_code=exc.status_code,
|
| 41 |
+
detail={"error": exc.error, "message": exc.message, "detail": exc.detail},
|
| 42 |
+
) from exc
|
| 43 |
+
|
| 44 |
+
return payload.sub
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
__all__ = ["extract_user_id_from_jwt"]
|
backend/src/api/middleware/error_handlers.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI exception handlers aligned with HTTP API contract."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any, Dict, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI, Request, status
|
| 9 |
+
from fastapi.exceptions import RequestValidationError
|
| 10 |
+
from fastapi.responses import JSONResponse
|
| 11 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
DEFAULT_ERRORS: Dict[int, Tuple[str, str]] = {
|
| 16 |
+
status.HTTP_400_BAD_REQUEST: ("validation_error", "Invalid request payload"),
|
| 17 |
+
status.HTTP_401_UNAUTHORIZED: ("unauthorized", "Authorization required"),
|
| 18 |
+
status.HTTP_403_FORBIDDEN: ("forbidden", "Forbidden"),
|
| 19 |
+
status.HTTP_404_NOT_FOUND: ("not_found", "Resource not found"),
|
| 20 |
+
status.HTTP_409_CONFLICT: ("version_conflict", "Resource version conflict"),
|
| 21 |
+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: (
|
| 22 |
+
"payload_too_large",
|
| 23 |
+
"Payload exceeds allowed size",
|
| 24 |
+
),
|
| 25 |
+
status.HTTP_500_INTERNAL_SERVER_ERROR: ("internal_error", "Internal server error"),
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _normalize_error(
|
| 30 |
+
status_code: int, detail: Any
|
| 31 |
+
) -> Tuple[str, str, Optional[Dict[str, Any]]]:
|
| 32 |
+
default_error, default_message = DEFAULT_ERRORS.get(
|
| 33 |
+
status_code, DEFAULT_ERRORS[status.HTTP_500_INTERNAL_SERVER_ERROR]
|
| 34 |
+
)
|
| 35 |
+
if isinstance(detail, dict):
|
| 36 |
+
error = detail.get("error", default_error)
|
| 37 |
+
message = detail.get("message", default_message)
|
| 38 |
+
detail_payload = detail.get("detail")
|
| 39 |
+
if detail_payload is None:
|
| 40 |
+
remainder = {
|
| 41 |
+
k: v for k, v in detail.items() if k not in {"error", "message", "detail"}
|
| 42 |
+
}
|
| 43 |
+
detail_payload = remainder or None
|
| 44 |
+
return error, message, detail_payload
|
| 45 |
+
if isinstance(detail, str) and detail:
|
| 46 |
+
return default_error, detail, None
|
| 47 |
+
return default_error, default_message, None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _response(status_code: int, detail: Any) -> JSONResponse:
|
| 51 |
+
error, message, extra = _normalize_error(status_code, detail)
|
| 52 |
+
return JSONResponse(
|
| 53 |
+
status_code=status_code, content={"error": error, "message": message, "detail": extra}
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def validation_exception_handler(
|
| 58 |
+
request: Request, exc: RequestValidationError
|
| 59 |
+
) -> JSONResponse:
|
| 60 |
+
detail = {"detail": {"errors": exc.errors()}}
|
| 61 |
+
return _response(status.HTTP_400_BAD_REQUEST, detail)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def http_exception_handler(
|
| 65 |
+
request: Request, exc: StarletteHTTPException
|
| 66 |
+
) -> JSONResponse:
|
| 67 |
+
return _response(exc.status_code, exc.detail)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def internal_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
| 71 |
+
logger.exception("Unhandled exception: %s", exc)
|
| 72 |
+
return _response(status.HTTP_500_INTERNAL_SERVER_ERROR, exc.args[0] if exc.args else None)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def register_error_handlers(app: FastAPI) -> None:
|
| 76 |
+
"""Attach shared exception handlers to the FastAPI application."""
|
| 77 |
+
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
| 78 |
+
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
| 79 |
+
app.add_exception_handler(Exception, internal_exception_handler)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
__all__ = [
|
| 83 |
+
"register_error_handlers",
|
| 84 |
+
"validation_exception_handler",
|
| 85 |
+
"http_exception_handler",
|
| 86 |
+
"internal_exception_handler",
|
| 87 |
+
]
|
backend/src/models/__init__.py
CHANGED
|
@@ -1,2 +1,25 @@
|
|
| 1 |
"""Pydantic models for data validation and serialization."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Pydantic models for data validation and serialization."""
|
| 2 |
|
| 3 |
+
from .auth import JWTPayload, TokenResponse
|
| 4 |
+
from .index import IndexHealth, Tag, Wikilink
|
| 5 |
+
from .note import Note, NoteCreate, NoteMetadata, NoteSummary, NoteUpdate
|
| 6 |
+
from .search import SearchRequest, SearchResult
|
| 7 |
+
from .user import HFProfile, User
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"User",
|
| 11 |
+
"HFProfile",
|
| 12 |
+
"Note",
|
| 13 |
+
"NoteMetadata",
|
| 14 |
+
"NoteCreate",
|
| 15 |
+
"NoteUpdate",
|
| 16 |
+
"NoteSummary",
|
| 17 |
+
"Wikilink",
|
| 18 |
+
"Tag",
|
| 19 |
+
"IndexHealth",
|
| 20 |
+
"SearchResult",
|
| 21 |
+
"SearchRequest",
|
| 22 |
+
"TokenResponse",
|
| 23 |
+
"JWTPayload",
|
| 24 |
+
]
|
| 25 |
+
|
backend/src/models/auth.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TokenResponse(BaseModel):
|
| 11 |
+
"""JWT issuance response."""
|
| 12 |
+
|
| 13 |
+
token: str = Field(..., description="JWT access token")
|
| 14 |
+
token_type: str = Field("bearer", description="Token type (always bearer)")
|
| 15 |
+
expires_at: datetime = Field(..., description="Expiration timestamp")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class JWTPayload(BaseModel):
|
| 19 |
+
"""JWT claims payload."""
|
| 20 |
+
|
| 21 |
+
sub: str = Field(..., description="Subject (user_id)")
|
| 22 |
+
iat: int = Field(..., description="Issued at timestamp")
|
| 23 |
+
exp: int = Field(..., description="Expiration timestamp")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
__all__ = ["TokenResponse", "JWTPayload"]
|
backend/src/models/index.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Index and metadata models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Wikilink(BaseModel):
|
| 12 |
+
"""Bidirectional link between notes."""
|
| 13 |
+
|
| 14 |
+
model_config = ConfigDict(
|
| 15 |
+
json_schema_extra={
|
| 16 |
+
"example": {
|
| 17 |
+
"user_id": "alice",
|
| 18 |
+
"source_path": "api/design.md",
|
| 19 |
+
"target_path": "api/endpoints.md",
|
| 20 |
+
"link_text": "Endpoints",
|
| 21 |
+
"is_resolved": True,
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
user_id: str
|
| 27 |
+
source_path: str
|
| 28 |
+
target_path: Optional[str] = Field(None, description="Null if unresolved")
|
| 29 |
+
link_text: str
|
| 30 |
+
is_resolved: bool
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Tag(BaseModel):
|
| 34 |
+
"""Tag with aggregated count."""
|
| 35 |
+
|
| 36 |
+
tag_name: str
|
| 37 |
+
count: int = Field(..., ge=0)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class IndexHealth(BaseModel):
|
| 41 |
+
"""Index health metrics per user."""
|
| 42 |
+
|
| 43 |
+
model_config = ConfigDict(
|
| 44 |
+
json_schema_extra={
|
| 45 |
+
"example": {
|
| 46 |
+
"user_id": "alice",
|
| 47 |
+
"note_count": 142,
|
| 48 |
+
"last_full_rebuild": "2025-01-01T00:00:00Z",
|
| 49 |
+
"last_incremental_update": "2025-01-15T14:30:00Z",
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
user_id: str
|
| 55 |
+
note_count: int = Field(..., ge=0)
|
| 56 |
+
last_full_rebuild: Optional[datetime] = None
|
| 57 |
+
last_incremental_update: Optional[datetime] = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
__all__ = ["Wikilink", "Tag", "IndexHealth"]
|
backend/src/models/note.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Note-related Pydantic models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class NoteMetadata(BaseModel):
|
| 12 |
+
"""Frontmatter metadata (allows arbitrary keys)."""
|
| 13 |
+
|
| 14 |
+
model_config = ConfigDict(extra="allow")
|
| 15 |
+
|
| 16 |
+
title: Optional[str] = None
|
| 17 |
+
tags: Optional[list[str]] = None
|
| 18 |
+
project: Optional[str] = None
|
| 19 |
+
created: Optional[datetime] = None
|
| 20 |
+
updated: Optional[datetime] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Note(BaseModel):
|
| 24 |
+
"""Complete note with content and metadata."""
|
| 25 |
+
|
| 26 |
+
model_config = ConfigDict(
|
| 27 |
+
json_schema_extra={
|
| 28 |
+
"example": {
|
| 29 |
+
"user_id": "alice",
|
| 30 |
+
"note_path": "api/design.md",
|
| 31 |
+
"version": 5,
|
| 32 |
+
"title": "API Design",
|
| 33 |
+
"metadata": {
|
| 34 |
+
"tags": ["backend", "api"],
|
| 35 |
+
"project": "auth-service",
|
| 36 |
+
},
|
| 37 |
+
"body": "# API Design\\n\\nThis document describes...",
|
| 38 |
+
"created": "2025-01-10T09:00:00Z",
|
| 39 |
+
"updated": "2025-01-15T14:30:00Z",
|
| 40 |
+
"size_bytes": 4096,
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
user_id: str = Field(..., description="Owner user ID")
|
| 46 |
+
note_path: str = Field(
|
| 47 |
+
...,
|
| 48 |
+
min_length=1,
|
| 49 |
+
max_length=256,
|
| 50 |
+
description="Relative path to vault root (includes .md)",
|
| 51 |
+
)
|
| 52 |
+
version: int = Field(..., ge=1, description="Optimistic concurrency version")
|
| 53 |
+
title: str = Field(..., min_length=1, description="Display title")
|
| 54 |
+
metadata: NoteMetadata = Field(default_factory=NoteMetadata, description="Frontmatter")
|
| 55 |
+
body: str = Field(..., description="Markdown content")
|
| 56 |
+
created: datetime = Field(..., description="Creation timestamp")
|
| 57 |
+
updated: datetime = Field(..., description="Last update timestamp")
|
| 58 |
+
size_bytes: int = Field(..., ge=0, le=1_048_576, description="File size in bytes")
|
| 59 |
+
|
| 60 |
+
@field_validator("note_path")
|
| 61 |
+
@classmethod
|
| 62 |
+
def validate_path(cls, value: str) -> str:
|
| 63 |
+
if not value.endswith(".md"):
|
| 64 |
+
raise ValueError("Note path must end with .md")
|
| 65 |
+
if ".." in value:
|
| 66 |
+
raise ValueError("Note path must not contain '..'")
|
| 67 |
+
if "\\" in value:
|
| 68 |
+
raise ValueError("Note path must use Unix-style separators (/)")
|
| 69 |
+
if value.startswith("/"):
|
| 70 |
+
raise ValueError("Note path must be relative (no leading /)")
|
| 71 |
+
return value
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class NoteCreate(BaseModel):
|
| 75 |
+
"""Request payload to create a note."""
|
| 76 |
+
|
| 77 |
+
note_path: str = Field(..., min_length=1, max_length=256)
|
| 78 |
+
title: Optional[str] = None
|
| 79 |
+
metadata: Optional[NoteMetadata] = None
|
| 80 |
+
body: str = Field(..., max_length=1_048_576)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class NoteUpdate(BaseModel):
|
| 84 |
+
"""Request payload to update a note."""
|
| 85 |
+
|
| 86 |
+
title: Optional[str] = None
|
| 87 |
+
metadata: Optional[NoteMetadata] = None
|
| 88 |
+
body: str = Field(..., max_length=1_048_576)
|
| 89 |
+
if_version: Optional[int] = Field(
|
| 90 |
+
None, ge=1, description="Expected version for concurrency check"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class NoteSummary(BaseModel):
|
| 95 |
+
"""Lightweight representation used for listings."""
|
| 96 |
+
|
| 97 |
+
note_path: str
|
| 98 |
+
title: str
|
| 99 |
+
updated: datetime
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
__all__ = ["NoteMetadata", "Note", "NoteCreate", "NoteUpdate", "NoteSummary"]
|
backend/src/models/search.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search request/response models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SearchResult(BaseModel):
|
| 11 |
+
"""Full-text search result payload."""
|
| 12 |
+
|
| 13 |
+
note_path: str
|
| 14 |
+
title: str
|
| 15 |
+
snippet: str = Field(..., description="Highlighted body excerpt")
|
| 16 |
+
score: float = Field(..., description="Relevance score (weighted by field)")
|
| 17 |
+
updated: datetime
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SearchRequest(BaseModel):
|
| 21 |
+
"""Full-text search query parameters."""
|
| 22 |
+
|
| 23 |
+
query: str = Field(..., min_length=1, max_length=256)
|
| 24 |
+
limit: int = Field(50, ge=1, le=100)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
__all__ = ["SearchResult", "SearchRequest"]
|
backend/src/models/user.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User and profile models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class HFProfile(BaseModel):
|
| 12 |
+
"""Hugging Face OAuth profile information."""
|
| 13 |
+
|
| 14 |
+
username: str = Field(..., description="HF username")
|
| 15 |
+
name: Optional[str] = Field(None, description="Display name")
|
| 16 |
+
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class User(BaseModel):
|
| 20 |
+
"""User account with authentication details."""
|
| 21 |
+
|
| 22 |
+
model_config = ConfigDict(
|
| 23 |
+
json_schema_extra={
|
| 24 |
+
"example": {
|
| 25 |
+
"user_id": "alice",
|
| 26 |
+
"hf_profile": {
|
| 27 |
+
"username": "alice",
|
| 28 |
+
"name": "Alice Smith",
|
| 29 |
+
"avatar_url": "https://cdn-avatars.huggingface.co/v1/alice",
|
| 30 |
+
},
|
| 31 |
+
"vault_path": "/data/vaults/alice",
|
| 32 |
+
"created": "2025-01-15T10:30:00Z",
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
user_id: str = Field(..., min_length=1, max_length=64, description="Internal user ID")
|
| 38 |
+
hf_profile: Optional[HFProfile] = Field(None, description="HF OAuth profile data")
|
| 39 |
+
vault_path: str = Field(..., description="Absolute path to the user's vault")
|
| 40 |
+
created: datetime = Field(..., description="Account creation timestamp")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
__all__ = ["User", "HFProfile"]
|
backend/src/services/__init__.py
CHANGED
|
@@ -1 +1,19 @@
|
|
| 1 |
"""Service layer for business logic and external integrations."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Service layer for business logic and external integrations."""
|
| 2 |
+
|
| 3 |
+
from .auth import AuthError, AuthService
|
| 4 |
+
from .config import AppConfig, get_config, reload_config
|
| 5 |
+
from .database import DatabaseService, init_database
|
| 6 |
+
from .vault import VaultService, sanitize_path, validate_note_path
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"AppConfig",
|
| 10 |
+
"get_config",
|
| 11 |
+
"reload_config",
|
| 12 |
+
"DatabaseService",
|
| 13 |
+
"init_database",
|
| 14 |
+
"AuthService",
|
| 15 |
+
"AuthError",
|
| 16 |
+
"VaultService",
|
| 17 |
+
"sanitize_path",
|
| 18 |
+
"validate_note_path",
|
| 19 |
+
]
|
backend/src/services/auth.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication helpers (JWT + HF OAuth placeholder)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from typing import Any, Dict, Optional
|
| 7 |
+
|
| 8 |
+
import jwt
|
| 9 |
+
from fastapi import status
|
| 10 |
+
|
| 11 |
+
from ..models.auth import JWTPayload
|
| 12 |
+
from .config import AppConfig, get_config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AuthError(Exception):
|
| 16 |
+
"""Domain-specific authentication error."""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
error: str,
|
| 21 |
+
message: str,
|
| 22 |
+
*,
|
| 23 |
+
status_code: int = status.HTTP_401_UNAUTHORIZED,
|
| 24 |
+
detail: Optional[Dict[str, Any]] = None,
|
| 25 |
+
) -> None:
|
| 26 |
+
super().__init__(message)
|
| 27 |
+
self.error = error
|
| 28 |
+
self.message = message
|
| 29 |
+
self.status_code = status_code
|
| 30 |
+
self.detail = detail or {}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AuthService:
|
| 34 |
+
"""Issue and validate JWT tokens (HF OAuth placeholder)."""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
config: AppConfig | None = None,
|
| 39 |
+
*,
|
| 40 |
+
algorithm: str = "HS256",
|
| 41 |
+
token_ttl_days: int = 90,
|
| 42 |
+
) -> None:
|
| 43 |
+
self.config = config or get_config()
|
| 44 |
+
self.algorithm = algorithm
|
| 45 |
+
self.token_ttl_days = token_ttl_days
|
| 46 |
+
|
| 47 |
+
def _build_payload(
|
| 48 |
+
self, user_id: str, expires_in: Optional[timedelta] = None
|
| 49 |
+
) -> JWTPayload:
|
| 50 |
+
now = datetime.now(timezone.utc)
|
| 51 |
+
lifetime = expires_in or timedelta(days=self.token_ttl_days)
|
| 52 |
+
return JWTPayload(
|
| 53 |
+
sub=user_id,
|
| 54 |
+
iat=int(now.timestamp()),
|
| 55 |
+
exp=int((now + lifetime).timestamp()),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def create_jwt(self, user_id: str, *, expires_in: Optional[timedelta] = None) -> str:
|
| 59 |
+
"""Create a signed JWT for the given user."""
|
| 60 |
+
payload = self._build_payload(user_id, expires_in)
|
| 61 |
+
return jwt.encode(
|
| 62 |
+
payload.model_dump(),
|
| 63 |
+
self.config.jwt_secret_key,
|
| 64 |
+
algorithm=self.algorithm,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
def validate_jwt(self, token: str) -> JWTPayload:
|
| 68 |
+
"""Validate a JWT and return the decoded payload."""
|
| 69 |
+
try:
|
| 70 |
+
decoded = jwt.decode(
|
| 71 |
+
token,
|
| 72 |
+
self.config.jwt_secret_key,
|
| 73 |
+
algorithms=[self.algorithm],
|
| 74 |
+
)
|
| 75 |
+
return JWTPayload(**decoded)
|
| 76 |
+
except jwt.ExpiredSignatureError as exc:
|
| 77 |
+
raise AuthError("token_expired", "Token expired, please re-authenticate") from exc
|
| 78 |
+
except jwt.InvalidTokenError as exc:
|
| 79 |
+
raise AuthError("invalid_token", f"Invalid token: {exc}") from exc
|
| 80 |
+
|
| 81 |
+
def issue_token_response(
|
| 82 |
+
self, user_id: str, *, expires_in: Optional[timedelta] = None
|
| 83 |
+
) -> tuple[str, datetime]:
|
| 84 |
+
"""Return token string and expiry timestamp (helper for API routes)."""
|
| 85 |
+
payload = self._build_payload(user_id, expires_in)
|
| 86 |
+
token = jwt.encode(
|
| 87 |
+
payload.model_dump(),
|
| 88 |
+
self.config.jwt_secret_key,
|
| 89 |
+
algorithm=self.algorithm,
|
| 90 |
+
)
|
| 91 |
+
expires_at = datetime.fromtimestamp(payload.exp, tz=timezone.utc)
|
| 92 |
+
return token, expires_at
|
| 93 |
+
|
| 94 |
+
def exchange_hf_oauth_code(self, code: str) -> Dict[str, Any]:
|
| 95 |
+
"""Placeholder for Hugging Face OAuth code exchange."""
|
| 96 |
+
raise NotImplementedError("HF OAuth integration not implemented yet")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
__all__ = ["AuthService", "AuthError"]
|
backend/src/services/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 11 |
+
|
| 12 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
| 13 |
+
DEFAULT_VAULT_BASE = PROJECT_ROOT / "data" / "vaults"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class AppConfig(BaseModel):
|
| 17 |
+
"""Runtime configuration loaded from environment variables."""
|
| 18 |
+
|
| 19 |
+
model_config = ConfigDict(frozen=True)
|
| 20 |
+
|
| 21 |
+
jwt_secret_key: str = Field(..., min_length=16, description="HMAC secret for JWT signing")
|
| 22 |
+
vault_base_path: Path = Field(..., description="Base directory for per-user vaults")
|
| 23 |
+
hf_oauth_client_id: Optional[str] = Field(
|
| 24 |
+
None, description="Hugging Face OAuth client ID (optional)"
|
| 25 |
+
)
|
| 26 |
+
hf_oauth_client_secret: Optional[str] = Field(
|
| 27 |
+
None, description="Hugging Face OAuth client secret (optional)"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
@field_validator("vault_base_path", mode="before")
|
| 31 |
+
@classmethod
|
| 32 |
+
def _normalize_vault_path(cls, value: str | Path | None) -> Path:
|
| 33 |
+
if value is None or value == "":
|
| 34 |
+
raise ValueError("VAULT_BASE_PATH is required")
|
| 35 |
+
if isinstance(value, Path):
|
| 36 |
+
path = value
|
| 37 |
+
else:
|
| 38 |
+
path = Path(value)
|
| 39 |
+
return path.expanduser().resolve()
|
| 40 |
+
|
| 41 |
+
@field_validator("jwt_secret_key")
|
| 42 |
+
@classmethod
|
| 43 |
+
def _ensure_secret(cls, value: str) -> str:
|
| 44 |
+
if not value or not value.strip():
|
| 45 |
+
raise ValueError("JWT_SECRET_KEY is required")
|
| 46 |
+
return value.strip()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _read_env(key: str, default: Optional[str] = None) -> Optional[str]:
|
| 50 |
+
return os.getenv(key, default)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@lru_cache(maxsize=1)
|
| 54 |
+
def get_config() -> AppConfig:
|
| 55 |
+
"""Load and cache application configuration."""
|
| 56 |
+
jwt_secret = _read_env("JWT_SECRET_KEY")
|
| 57 |
+
vault_base = _read_env("VAULT_BASE_PATH", str(DEFAULT_VAULT_BASE))
|
| 58 |
+
hf_client_id = _read_env("HF_OAUTH_CLIENT_ID")
|
| 59 |
+
hf_client_secret = _read_env("HF_OAUTH_CLIENT_SECRET")
|
| 60 |
+
|
| 61 |
+
config = AppConfig(
|
| 62 |
+
jwt_secret_key=jwt_secret,
|
| 63 |
+
vault_base_path=vault_base,
|
| 64 |
+
hf_oauth_client_id=hf_client_id,
|
| 65 |
+
hf_oauth_client_secret=hf_client_secret,
|
| 66 |
+
)
|
| 67 |
+
# Ensure vault base directory exists for downstream services.
|
| 68 |
+
config.vault_base_path.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
return config
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def reload_config() -> AppConfig:
|
| 73 |
+
"""Clear cached config (useful for tests) and reload."""
|
| 74 |
+
get_config.cache_clear()
|
| 75 |
+
return get_config()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
__all__ = ["AppConfig", "get_config", "reload_config", "PROJECT_ROOT", "DEFAULT_VAULT_BASE"]
|
backend/src/services/vault.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Filesystem vault management."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Tuple
|
| 7 |
+
|
| 8 |
+
from .config import AppConfig, get_config
|
| 9 |
+
|
| 10 |
+
INVALID_PATH_CHARS = {'<', '>', ':', '"', '|', '?', '*'}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def validate_note_path(note_path: str) -> Tuple[bool, str]:
|
| 14 |
+
"""
|
| 15 |
+
Validate a relative Markdown path.
|
| 16 |
+
|
| 17 |
+
Returns (is_valid, message). Message is empty when valid.
|
| 18 |
+
"""
|
| 19 |
+
if not note_path or len(note_path) > 256:
|
| 20 |
+
return False, "Path must be 1-256 characters"
|
| 21 |
+
if not note_path.endswith(".md"):
|
| 22 |
+
return False, "Path must end with .md"
|
| 23 |
+
if ".." in note_path:
|
| 24 |
+
return False, "Path must not contain '..'"
|
| 25 |
+
if "\\" in note_path:
|
| 26 |
+
return False, "Path must use Unix separators (/)"
|
| 27 |
+
if note_path.startswith("/"):
|
| 28 |
+
return False, "Path must be relative (no leading /)"
|
| 29 |
+
if any(char in INVALID_PATH_CHARS for char in note_path):
|
| 30 |
+
return False, "Path contains invalid characters"
|
| 31 |
+
return True, ""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def sanitize_path(user_id: str, vault_root: Path, note_path: str) -> Path:
|
| 35 |
+
"""
|
| 36 |
+
Sanitize and resolve a note path within the vault.
|
| 37 |
+
|
| 38 |
+
Raises ValueError if the resolved path escapes the vault root.
|
| 39 |
+
"""
|
| 40 |
+
vault = (vault_root / user_id).resolve()
|
| 41 |
+
full_path = (vault / note_path).resolve()
|
| 42 |
+
if not str(full_path).startswith(str(vault)):
|
| 43 |
+
raise ValueError(f"Path escapes vault root: {note_path}")
|
| 44 |
+
return full_path
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class VaultService:
|
| 48 |
+
"""Service for managing vault directories and basic path validation."""
|
| 49 |
+
|
| 50 |
+
def __init__(self, config: AppConfig | None = None) -> None:
|
| 51 |
+
self.config = config or get_config()
|
| 52 |
+
self.vault_root = self.config.vault_base_path
|
| 53 |
+
self.vault_root.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
|
| 55 |
+
def initialize_vault(self, user_id: str) -> Path:
|
| 56 |
+
"""Ensure a user's vault directory exists and return its path."""
|
| 57 |
+
path = (self.vault_root / user_id).resolve()
|
| 58 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
return path
|
| 60 |
+
|
| 61 |
+
def resolve_note_path(self, user_id: str, note_path: str) -> Path:
|
| 62 |
+
"""
|
| 63 |
+
Validate and resolve a note path inside a user's vault.
|
| 64 |
+
|
| 65 |
+
Raises ValueError for invalid paths.
|
| 66 |
+
"""
|
| 67 |
+
is_valid, message = validate_note_path(note_path)
|
| 68 |
+
if not is_valid:
|
| 69 |
+
raise ValueError(message)
|
| 70 |
+
return sanitize_path(user_id, self.vault_root, note_path)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
__all__ = ["VaultService", "validate_note_path", "sanitize_path"]
|
frontend/src/types/auth.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Token response returned after authentication.
|
| 3 |
+
*/
|
| 4 |
+
export interface TokenResponse {
|
| 5 |
+
token: string;
|
| 6 |
+
token_type: "bearer";
|
| 7 |
+
expires_at: string; // ISO 8601 timestamp
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Standard API error envelope.
|
| 12 |
+
*/
|
| 13 |
+
export interface APIError {
|
| 14 |
+
error: string;
|
| 15 |
+
message: string;
|
| 16 |
+
detail?: Record<string, unknown>;
|
| 17 |
+
}
|
| 18 |
+
|
frontend/src/types/note.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Note metadata/frontmatter representation.
|
| 3 |
+
*/
|
| 4 |
+
export interface NoteMetadata {
|
| 5 |
+
title?: string;
|
| 6 |
+
tags?: string[];
|
| 7 |
+
project?: string;
|
| 8 |
+
created?: string; // ISO 8601 timestamp
|
| 9 |
+
updated?: string; // ISO 8601 timestamp
|
| 10 |
+
[key: string]: unknown;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Complete note payload returned by APIs.
|
| 15 |
+
*/
|
| 16 |
+
export interface Note {
|
| 17 |
+
user_id: string;
|
| 18 |
+
note_path: string;
|
| 19 |
+
version: number;
|
| 20 |
+
title: string;
|
| 21 |
+
metadata: NoteMetadata;
|
| 22 |
+
body: string;
|
| 23 |
+
created: string; // ISO 8601 timestamp
|
| 24 |
+
updated: string; // ISO 8601 timestamp
|
| 25 |
+
size_bytes: number;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Lightweight summary used for listings.
|
| 30 |
+
*/
|
| 31 |
+
export interface NoteSummary {
|
| 32 |
+
note_path: string;
|
| 33 |
+
title: string;
|
| 34 |
+
updated: string; // ISO 8601 timestamp
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Request payload for creating a note.
|
| 39 |
+
*/
|
| 40 |
+
export interface NoteCreateRequest {
|
| 41 |
+
note_path: string;
|
| 42 |
+
title?: string;
|
| 43 |
+
metadata?: NoteMetadata;
|
| 44 |
+
body: string;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Request payload for updating a note.
|
| 49 |
+
*/
|
| 50 |
+
export interface NoteUpdateRequest {
|
| 51 |
+
title?: string;
|
| 52 |
+
metadata?: NoteMetadata;
|
| 53 |
+
body: string;
|
| 54 |
+
if_version?: number;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Wikilink and backlink representation.
|
| 59 |
+
*/
|
| 60 |
+
export interface Wikilink {
|
| 61 |
+
user_id: string;
|
| 62 |
+
source_path: string;
|
| 63 |
+
target_path: string | null;
|
| 64 |
+
link_text: string;
|
| 65 |
+
is_resolved: boolean;
|
| 66 |
+
}
|
| 67 |
+
|
frontend/src/types/search.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tag with aggregated note count.
|
| 3 |
+
*/
|
| 4 |
+
export interface Tag {
|
| 5 |
+
tag_name: string;
|
| 6 |
+
count: number;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Index health metadata per user.
|
| 11 |
+
*/
|
| 12 |
+
export interface IndexHealth {
|
| 13 |
+
user_id: string;
|
| 14 |
+
note_count: number;
|
| 15 |
+
last_full_rebuild: string | null;
|
| 16 |
+
last_incremental_update: string | null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Full-text search result entry.
|
| 21 |
+
*/
|
| 22 |
+
export interface SearchResult {
|
| 23 |
+
note_path: string;
|
| 24 |
+
title: string;
|
| 25 |
+
snippet: string;
|
| 26 |
+
score: number;
|
| 27 |
+
updated: string; // ISO 8601 timestamp
|
| 28 |
+
}
|
| 29 |
+
|
frontend/src/types/user.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Hugging Face profile metadata attached to a user.
|
| 3 |
+
*/
|
| 4 |
+
export interface HFProfile {
|
| 5 |
+
username: string;
|
| 6 |
+
name?: string;
|
| 7 |
+
avatar_url?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* User account returned by the backend.
|
| 12 |
+
*/
|
| 13 |
+
export interface User {
|
| 14 |
+
user_id: string;
|
| 15 |
+
hf_profile?: HFProfile;
|
| 16 |
+
vault_path: string;
|
| 17 |
+
created: string; // ISO 8601 timestamp
|
| 18 |
+
}
|
| 19 |
+
|