Wothmag07 commited on
Commit
02af15b
·
1 Parent(s): 2471e68

Foundational changes

Browse files
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
+