Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deliverables/fastapi-obsidian-backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
data/*.json
data/secret.key
data/jwt_secret.txt
39 changes: 39 additions & 0 deletions deliverables/fastapi-obsidian-backend/README.md
Original file line number Diff line number Diff line change
@@ -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 <token>` 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.
Empty file.
36 changes: 36 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/config.py
Original file line number Diff line number Diff line change
@@ -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()
61 changes: 61 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/db.py
Original file line number Diff line number Diff line change
@@ -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()
47 changes: 47 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/main.py
Original file line number Diff line number Diff line change
@@ -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"],
}
Empty file.
45 changes: 45 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/routers/billing.py
Original file line number Diff line number Diff line change
@@ -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"})
42 changes: 42 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/routers/programs.py
Original file line number Diff line number Diff line change
@@ -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"})
15 changes: 15 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/routers/security.py
Original file line number Diff line number Diff line change
@@ -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)",
}
97 changes: 97 additions & 0 deletions deliverables/fastapi-obsidian-backend/app/routers/skills.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading