From c606cdc17ae60a27da027872fd27bbcf70301c21 Mon Sep 17 00:00:00 2001 From: "fig-ai-agent[bot]" Date: Thu, 10 Sep 2026 18:35:54 +0000 Subject: [PATCH] feat: add fastapi-obsidian-backend deliverable Integrated FastAPI backend serving an Obsidian skill library as a REST API. - Skills API with per-user access (JWT via /auth/register, /auth/login) - Programs CSV generation, billing records + CSV export - Tool switcher, opt-in encryption at rest (Fernet/AES-128) - Sample skill library under data/skills/ - Self-contained under deliverables/fastapi-obsidian-backend --- .../fastapi-obsidian-backend/.gitignore | 3 + .../fastapi-obsidian-backend/README.md | 39 ++++++++ .../fastapi-obsidian-backend/app/__init__.py | 0 .../fastapi-obsidian-backend/app/config.py | 36 +++++++ .../fastapi-obsidian-backend/app/db.py | 61 ++++++++++++ .../fastapi-obsidian-backend/app/main.py | 47 +++++++++ .../app/routers/__init__.py | 0 .../app/routers/billing.py | 45 +++++++++ .../app/routers/programs.py | 42 ++++++++ .../app/routers/security.py | 15 +++ .../app/routers/skills.py | 97 +++++++++++++++++++ .../app/routers/tools.py | 30 ++++++ .../app/routers/users.py | 44 +++++++++ .../fastapi-obsidian-backend/app/security.py | 67 +++++++++++++ .../data/skills/deck.md | 8 ++ .../data/skills/research.md | 9 ++ .../data/skills/website.md | 9 ++ .../fastapi-obsidian-backend/requirements.txt | 8 ++ 18 files changed, 560 insertions(+) create mode 100644 deliverables/fastapi-obsidian-backend/.gitignore create mode 100644 deliverables/fastapi-obsidian-backend/README.md create mode 100644 deliverables/fastapi-obsidian-backend/app/__init__.py create mode 100644 deliverables/fastapi-obsidian-backend/app/config.py create mode 100644 deliverables/fastapi-obsidian-backend/app/db.py create mode 100644 deliverables/fastapi-obsidian-backend/app/main.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/__init__.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/billing.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/programs.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/security.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/skills.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/tools.py create mode 100644 deliverables/fastapi-obsidian-backend/app/routers/users.py create mode 100644 deliverables/fastapi-obsidian-backend/app/security.py create mode 100644 deliverables/fastapi-obsidian-backend/data/skills/deck.md create mode 100644 deliverables/fastapi-obsidian-backend/data/skills/research.md create mode 100644 deliverables/fastapi-obsidian-backend/data/skills/website.md create mode 100644 deliverables/fastapi-obsidian-backend/requirements.txt diff --git a/deliverables/fastapi-obsidian-backend/.gitignore b/deliverables/fastapi-obsidian-backend/.gitignore new file mode 100644 index 00000000..e3dff343 --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/.gitignore @@ -0,0 +1,3 @@ +data/*.json +data/secret.key +data/jwt_secret.txt diff --git a/deliverables/fastapi-obsidian-backend/README.md b/deliverables/fastapi-obsidian-backend/README.md new file mode 100644 index 00000000..14632cd8 --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/README.md @@ -0,0 +1,39 @@ +# FastAPI + Obsidian Backend + +Integrated FastAPI backend serving an **Obsidian skill library** as a REST API, with JWT auth, per-user skill access, and opt-in encryption at rest. + +## Modules + +| Module | Prefix | Purpose | +|---|---|---| +| Auth | `/auth` | register + login -> JWT | +| Skills | `/skills` | serve Obsidian skill library (per-user) | +| Programs | `/programs` | CSV generation | +| Billing | `/billing` | records + CSV export | +| Tools | `/tools` | module switcher | +| Security | `/security` | encryption-at-rest status | + +## Setup & run + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload # from this directory +``` + +### JWT auth & per-user skill access + +- `POST /auth/register {username,password}` and `POST /auth/login` return a JWT token. +- Send `Authorization: Bearer ` on `/skills*` (401 without). +- Add an `allowed_skills` list to a user record to restrict which skills they see (unrestricted if absent; out-of-list -> 403). + +### Opt-in encryption at rest + +```bash +ENCRYPT_AT_REST=1 uvicorn app.main:app +``` + +Fernet/AES-128; set `ENCRYPTION_KEY` or let it generate a key. Point at your vault with `SKILLS_DIR=/path/to/vault/Skills`. + +## Sample skills + +Three example Obsidian skill notes ship under `data/skills/` so `/skills` returns data out of the box. diff --git a/deliverables/fastapi-obsidian-backend/app/__init__.py b/deliverables/fastapi-obsidian-backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deliverables/fastapi-obsidian-backend/app/config.py b/deliverables/fastapi-obsidian-backend/app/config.py new file mode 100644 index 00000000..017fe62b --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/config.py @@ -0,0 +1,36 @@ +"""Settings via environment variables.""" +import os +from pathlib import Path + +from pydantic import BaseModel + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = BASE_DIR / "data" + + +def _bool_env(key: str, default: bool = False) -> bool: + return os.environ.get(key, str(default)).lower() in ("1", "true", "yes", "on") + + +class Settings(BaseModel): + app_name: str = "fastapi-obsidian-backend" + data_dir: Path = DATA_DIR + skills_dir: Path = DATA_DIR / "skills" + encrypt_at_rest: bool = False + encryption_key_env: str = "ENCRYPTION_KEY" + jwt_secret_env: str = "JWT_SECRET" + + class Config: + arbitrary_types_allowed = True + + @classmethod + def load(cls) -> "Settings": + data = Path(os.environ.get("DATA_DIR", str(DATA_DIR))) + return cls( + data_dir=data, + skills_dir=Path(os.environ.get("SKILLS_DIR", str(data / "skills"))), + encrypt_at_rest=_bool_env("ENCRYPT_AT_REST", False), + ) + + +settings = Settings.load() diff --git a/deliverables/fastapi-obsidian-backend/app/db.py b/deliverables/fastapi-obsidian-backend/app/db.py new file mode 100644 index 00000000..b7ce0bea --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/db.py @@ -0,0 +1,61 @@ +"""Tiny JSON-file database with optional encryption at rest (Fernet/AES-128).""" +import json +import os +from pathlib import Path + +from cryptography.fernet import Fernet + +from .config import settings + + +class DB: + def __init__(self): + self._fernet = None + + def init(self, encrypt: bool = False): + self._fernet = None + if encrypt: + self._fernet = Fernet(self._load_or_create_key()) + + @property + def encryption_active(self) -> bool: + return self._fernet is not None + + def _load_or_create_key(self) -> bytes: + key = os.environ.get(settings.encryption_key_env) + if key: + return key.encode() + settings.data_dir.mkdir(parents=True, exist_ok=True) + keyfile = settings.data_dir / "secret.key" + if keyfile.exists(): + return keyfile.read_bytes().strip() + k = Fernet.generate_key() + keyfile.write_bytes(k) + return k + + def _store_path(self, name: str) -> Path: + settings.data_dir.mkdir(parents=True, exist_ok=True) + return settings.data_dir / f"{name}.json" + + def get(self, name: str, default=None): + path = self._store_path(name) + if not path.exists(): + return default + raw = path.read_text(encoding="utf-8") + if self._fernet is not None: + raw = self._fernet.decrypt(raw.encode()).decode() + return json.loads(raw) + + def set(self, name: str, value) -> None: + raw = json.dumps(value, ensure_ascii=False) + if self._fernet is not None: + raw = self._fernet.encrypt(raw.encode()).decode() + path = self._store_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(raw, encoding="utf-8") + + def close(self): + self._fernet = None + + +db = DB() diff --git a/deliverables/fastapi-obsidian-backend/app/main.py b/deliverables/fastapi-obsidian-backend/app/main.py new file mode 100644 index 00000000..f709304c --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/main.py @@ -0,0 +1,47 @@ +"""Integrated FastAPI backend serving an Obsidian skill library. + +Modules: skills (per-user), programs (CSV), billing, tools, security (encryption at rest), auth (JWT). +""" +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings +from .db import db +from .routers import billing, programs, security, skills, tools, users + + +@asynccontextmanager +async def lifespan(app: FastAPI): + db.init(encrypt=settings.encrypt_at_rest) + yield + db.close() + + +app = FastAPI( + title="Integrated Backend (FastAPI + Obsidian)", + version="1.0.0", + description="Skills API, programs CSV, billing, tool switcher, JWT auth, encryption at rest", + lifespan=lifespan, +) + +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +app.include_router(skills.router) +app.include_router(programs.router) +app.include_router(billing.router) +app.include_router(tools.router) +app.include_router(security.router) +app.include_router(users.router) + + +@app.get("/", tags=["meta"]) +def root(): + return { + "name": "integrated-backend", + "version": "1.0.0", + "encrypt_at_rest": settings.encrypt_at_rest, + "docs": "/docs", + "modules": ["skills", "programs", "billing", "tools", "auth", "security"], + } diff --git a/deliverables/fastapi-obsidian-backend/app/routers/__init__.py b/deliverables/fastapi-obsidian-backend/app/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deliverables/fastapi-obsidian-backend/app/routers/billing.py b/deliverables/fastapi-obsidian-backend/app/routers/billing.py new file mode 100644 index 00000000..f0a83b2e --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/routers/billing.py @@ -0,0 +1,45 @@ +"""Billing interoperability - records + CSV export.""" +import csv +import io + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ..db import db + +router = APIRouter(prefix="/billing", tags=["billing"]) + + +class Charge(BaseModel): + id: str + amount: float + currency: str = "USD" + description: str = "" + status: str = "pending" + + +@router.get("/charges") +def list_charges(): + return db.get("charges", []) + + +@router.post("/charges") +def add_charge(charge: Charge): + charges = db.get("charges", []) + charges.append(charge.dict()) + db.set("charges", charges) + return charge + + +@router.get("/charges/export") +def export_charges(): + charges = db.get("charges", []) + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["id", "amount", "currency", "description", "status"]) + for c in charges: + writer.writerow([c["id"], c["amount"], c["currency"], c["description"], c["status"]]) + buf.seek(0) + return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=charges.csv"}) diff --git a/deliverables/fastapi-obsidian-backend/app/routers/programs.py b/deliverables/fastapi-obsidian-backend/app/routers/programs.py new file mode 100644 index 00000000..48060def --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/routers/programs.py @@ -0,0 +1,42 @@ +"""Program Management - CSV generation.""" +import csv +import io + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +router = APIRouter(prefix="/programs", tags=["programs"]) + + +class Program(BaseModel): + id: str + name: str + status: str = "planned" + owner: str = "" + start: str = "" + end: str = "" + budget: float = 0.0 + progress: int = 0 + + +@router.get("/sample") +def sample_programs(): + return [ + Program(id="P1", name="Website launch", status="active", owner="Ava", + start="2026-09-01", end="2026-10-15", budget=12000, progress=30), + Program(id="P2", name="API v2", status="planned", owner="Ben", + start="2026-10-01", end="2026-12-01", budget=30000, progress=0), + ] + + +@router.post("/export") +def export_csv(programs: list[Program]): + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["id", "name", "status", "owner", "start", "end", "budget", "progress"]) + for p in programs: + writer.writerow([p.id, p.name, p.status, p.owner, p.start, p.end, p.budget, p.progress]) + buf.seek(0) + return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=programs.csv"}) diff --git a/deliverables/fastapi-obsidian-backend/app/routers/security.py b/deliverables/fastapi-obsidian-backend/app/routers/security.py new file mode 100644 index 00000000..6b296ba3 --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/routers/security.py @@ -0,0 +1,15 @@ +"""Security - encryption at rest status.""" +from fastapi import APIRouter + +from ..db import db + +router = APIRouter(prefix="/security", tags=["security"]) + + +@router.get("/encryption") +def encryption_status(): + return { + "encrypt_at_rest": db.encryption_active, + "mode": "fernet-aes128" if db.encryption_active else "plaintext", + "hint": "Enable with ENCRYPT_AT_REST=1 (and set ENCRYPTION_KEY or let it generate one)", + } diff --git a/deliverables/fastapi-obsidian-backend/app/routers/skills.py b/deliverables/fastapi-obsidian-backend/app/routers/skills.py new file mode 100644 index 00000000..dcbf9054 --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/routers/skills.py @@ -0,0 +1,97 @@ +"""Skills router - serves the Obsidian skill library with per-user access.""" +from pathlib import Path + +import yaml +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ..config import settings +from ..db import db +from ..security import get_current_user + +router = APIRouter(prefix="/skills", tags=["skills"]) + + +class Skill(BaseModel): + name: str + description: str = "" + tags: list = [] + type: str = "" + aliases: list = [] + body: str = "" + + +def _walk_skills() -> list[Path]: + root = Path(settings.skills_dir) + if not root.exists(): + return [] + return sorted(p for p in root.rglob("*.md") if p.is_file()) + + +def _parse_skill(path: Path) -> Skill: + text = path.read_text(encoding="utf-8", errors="replace") + body = text + meta = {} + if text.startswith("---"): + end = text.find("\n---", 3) + if end != -1: + try: + meta = yaml.safe_load(text[3:end]) or {} + except Exception: + meta = {} + body = text[end + 4:].lstrip("\n") + return Skill( + name=meta.get("name") or path.stem, + description=meta.get("description", ""), + tags=meta.get("tags") or [], + type=meta.get("type", ""), + aliases=meta.get("aliases") or [], + body=body, + ) + + +def _allowed_skills(username: str) -> set | None: + users = db.get("users", {}) + record = users.get(username) + allowed = (record or {}).get("allowed_skills") + return None if allowed is None else set(allowed) + + +def _filter_for_user(skills_list: list, username: str) -> list: + allowed = _allowed_skills(username) + if allowed is None: + return skills_list + return [s for s in skills_list if s.name in allowed or Path(s.name).stem in allowed] + + +def _authorize(username: str, skill: Skill): + allowed = _allowed_skills(username) + if allowed is not None and skill.name not in allowed and Path(skill.name).stem not in allowed: + raise HTTPException(status_code=403, detail=f"not allowed to access skill '{skill.name}'") + + +@router.get("", response_model=list[Skill]) +def list_skills(user: str = Depends(get_current_user)): + return _filter_for_user([_parse_skill(p) for p in _walk_skills()], user) + + +@router.get("/search", response_model=list[Skill]) +def search_skills(q: str = "", user: str = Depends(get_current_user)): + ql = q.lower() + out = [] + for p in _walk_skills(): + s = _parse_skill(p) + hay = " ".join([s.name, s.description, " ".join(s.tags)]).lower() + if ql in hay: + out.append(s) + return _filter_for_user(out, user) + + +@router.get("/{name}", response_model=Skill) +def get_skill(name: str, user: str = Depends(get_current_user)): + for p in _walk_skills(): + s = _parse_skill(p) + if s.name == name or p.stem == name: + _authorize(user, s) + return s + raise HTTPException(status_code=404, detail=f"skill '{name}' not found") diff --git a/deliverables/fastapi-obsidian-backend/app/routers/tools.py b/deliverables/fastapi-obsidian-backend/app/routers/tools.py new file mode 100644 index 00000000..197c9a7a --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/routers/tools.py @@ -0,0 +1,30 @@ +"""Tool switcher - which modules are active.""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ..db import db + +router = APIRouter(prefix="/tools", tags=["tools"]) + +MODULES = ["skills", "programs", "billing"] + + +class ToolState(BaseModel): + name: str + enabled: bool + + +@router.get("") +def list_tools(): + states = db.get("tool_states", {}) + return [ToolState(name=m, enabled=states.get(m, True)) for m in MODULES] + + +@router.post("/{name}") +def set_tool(name: str, enabled: bool = True): + if name not in MODULES: + raise HTTPException(status_code=404, detail=f"unknown tool '{name}'") + states = db.get("tool_states", {}) + states[name] = enabled + db.set("tool_states", states) + return ToolState(name=name, enabled=enabled) diff --git a/deliverables/fastapi-obsidian-backend/app/routers/users.py b/deliverables/fastapi-obsidian-backend/app/routers/users.py new file mode 100644 index 00000000..2c21837b --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/routers/users.py @@ -0,0 +1,44 @@ +"""Users - register + login returning JWT tokens.""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ..db import db +from ..security import create_access_token, hash_password, verify_password + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class RegisterIn(BaseModel): + username: str + password: str + + +class LoginIn(BaseModel): + username: str + password: str + + +class TokenOut(BaseModel): + access_token: str + token_type: str = "bearer" + + +@router.post("/register", response_model=TokenOut) +def register(body: RegisterIn): + users = db.get("users", {}) + if body.username in users: + raise HTTPException(status_code=409, detail="username already exists") + if len(body.password) < 6: + raise HTTPException(status_code=400, detail="password must be at least 6 chars") + users[body.username] = {"hashed_password": hash_password(body.password)} + db.set("users", users) + return TokenOut(access_token=create_access_token(body.username)) + + +@router.post("/login", response_model=TokenOut) +def login(body: LoginIn): + users = db.get("users", {}) + record = users.get(body.username) + if not record or not verify_password(body.password, record["hashed_password"]): + raise HTTPException(status_code=401, detail="invalid username or password") + return TokenOut(access_token=create_access_token(body.username)) diff --git a/deliverables/fastapi-obsidian-backend/app/security.py b/deliverables/fastapi-obsidian-backend/app/security.py new file mode 100644 index 00000000..359097e8 --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/app/security.py @@ -0,0 +1,67 @@ +"""JWT auth (python-jose HS256) + bcrypt password hashing.""" +import os +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext + +from .config import settings + +ALGORITHM = "HS256" +TOKEN_TTL_MINUTES = int(os.environ.get("JWT_TTL_MINUTES", "120")) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +bearer = HTTPBearer(auto_error=False) + + +def _secret() -> str: + secret = os.environ.get(settings.jwt_secret_env) + if secret: + return secret + settings.data_dir.mkdir(parents=True, exist_ok=True) + keyfile = settings.data_dir / "jwt_secret.txt" + if keyfile.exists(): + return keyfile.read_text().strip() + import secrets + k = secrets.token_hex(32) + keyfile.write_text(k) + return k + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + return pwd_context.verify(password, hashed) + + +def create_access_token(username: str) -> str: + payload = { + "sub": username, + "exp": datetime.now(timezone.utc) + timedelta(minutes=TOKEN_TTL_MINUTES), + } + return jwt.encode(payload, _secret(), algorithm=ALGORITHM) + + +def decode_token(token: str) -> str: + payload = jwt.decode(token, _secret(), algorithms=[ALGORITHM]) + sub = payload.get("sub") + if not sub: + raise JWTError("missing subject") + return sub + + +def get_current_user( + creds: HTTPAuthorizationCredentials = Depends(bearer), +) -> str: + if creds is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated") + try: + return decode_token(creds.credentials) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token") diff --git a/deliverables/fastapi-obsidian-backend/data/skills/deck.md b/deliverables/fastapi-obsidian-backend/data/skills/deck.md new file mode 100644 index 00000000..2bdf39d0 --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/data/skills/deck.md @@ -0,0 +1,8 @@ +--- +name: deck +description: Art-directed HTML slide decks with speaker notes +type: skill +tags: [deck, slides, presentation] +--- +# Slide decks +Build research-grounded decks. diff --git a/deliverables/fastapi-obsidian-backend/data/skills/research.md b/deliverables/fastapi-obsidian-backend/data/skills/research.md new file mode 100644 index 00000000..cacf49bd --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/data/skills/research.md @@ -0,0 +1,9 @@ +--- +name: research +description: Multi-source research delivered as a cited markdown report +type: skill +aliases: [research-report, investigation] +tags: [research, report, citations] +--- +# Research +Gather sources, cross-check, and produce an answer-first report. diff --git a/deliverables/fastapi-obsidian-backend/data/skills/website.md b/deliverables/fastapi-obsidian-backend/data/skills/website.md new file mode 100644 index 00000000..bea2bbae --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/data/skills/website.md @@ -0,0 +1,9 @@ +--- +name: website +description: Build and publish a React + Tailwind site +type: skill +aliases: [web] +tags: [website, react, tailwind] +--- +# Website builder +Scaffold, build, and publish a site. diff --git a/deliverables/fastapi-obsidian-backend/requirements.txt b/deliverables/fastapi-obsidian-backend/requirements.txt new file mode 100644 index 00000000..df130bdb --- /dev/null +++ b/deliverables/fastapi-obsidian-backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +pydantic +cryptography +PyYAML +python-jose[cryptography] +passlib +bcrypt==4.0.1