diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2a0dcbe
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,34 @@
+# LLeMbas configuration
+# Copy to .env and edit. All variables are prefixed LEMBAS_.
+
+# REQUIRED. Secret used to sign session cookies and to derive the key that
+# encrypts stored API keys at rest. Generate one with:
+# python -c "import secrets; print(secrets.token_urlsafe(48))"
+# Changing this invalidates all sessions AND makes stored API keys unreadable.
+LEMBAS_SECRET_KEY=
+
+# Where the SQLite database and uploaded files live.
+LEMBAS_DATA_DIR=./data
+
+# HTTP server bind address.
+LEMBAS_HOST=127.0.0.1
+LEMBAS_PORT=8080
+
+# Autoreload on code change. Development only.
+LEMBAS_RELOAD=false
+
+# debug | info | warning | error
+LEMBAS_LOG_LEVEL=info
+
+# Allow new accounts to register themselves. The very first account created is
+# always an admin, regardless of this setting. Turn off once your users exist.
+LEMBAS_ALLOW_SIGNUP=true
+
+# Default theme for signed-out visitors: moria (dark) or shire (light).
+LEMBAS_DEFAULT_THEME=moria
+
+# Seconds a login session stays valid. Default 30 days.
+LEMBAS_SESSION_TTL=2592000
+
+# Seconds to wait on an upstream LLM endpoint before giving up.
+LEMBAS_REQUEST_TIMEOUT=300
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..30d6526
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,27 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+build/
+dist/
+.venv/
+venv/
+
+# Tooling
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+
+# LLeMbas runtime
+.env
+data/
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
+
+# Editors / OS
+.vscode/
+.idea/
+.DS_Store
+*.swp
diff --git a/assets/banner.svg b/assets/banner.svg
new file mode 100644
index 0000000..a0080b1
--- /dev/null
+++ b/assets/banner.svg
@@ -0,0 +1,264 @@
+
diff --git a/assets/favicon.svg b/assets/favicon.svg
new file mode 100644
index 0000000..6b4c4aa
--- /dev/null
+++ b/assets/favicon.svg
@@ -0,0 +1,27 @@
+
diff --git a/assets/logo-lockup.svg b/assets/logo-lockup.svg
new file mode 100644
index 0000000..37fbd2e
--- /dev/null
+++ b/assets/logo-lockup.svg
@@ -0,0 +1,67 @@
+
diff --git a/assets/logo-mark.svg b/assets/logo-mark.svg
new file mode 100644
index 0000000..787cb74
--- /dev/null
+++ b/assets/logo-mark.svg
@@ -0,0 +1,49 @@
+
diff --git a/assets/wordmark.svg b/assets/wordmark.svg
new file mode 100644
index 0000000..dcfc679
--- /dev/null
+++ b/assets/wordmark.svg
@@ -0,0 +1,23 @@
+
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..60a6266
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,66 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "lembas"
+version = "0.1.0"
+description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
+readme = "README.md"
+requires-python = ">=3.11"
+license = { file = "LICENSE" }
+authors = [{ name = "Jaroslav Benes", email = "admin@ecoposta.sk" }]
+keywords = ["llm", "webui", "openai", "chat", "self-hosted"]
+classifiers = [
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+ "Programming Language :: Python :: 3",
+ "Topic :: Communications :: Chat",
+]
+
+dependencies = [
+ "fastapi>=0.115",
+ "uvicorn[standard]>=0.32",
+ "jinja2>=3.1",
+ "sqlalchemy>=2.0",
+ "pydantic>=2.9",
+ "pydantic-settings>=2.6",
+ "httpx>=0.27",
+ "python-multipart>=0.0.12",
+ "argon2-cffi>=23.1",
+ "cryptography>=43.0",
+ "markdown-it-py>=3.0",
+ "mdit-py-plugins>=0.4",
+ "pygments>=2.18",
+ "nh3>=0.2.18",
+ "typer>=0.12",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.3",
+ "pytest-asyncio>=0.24",
+ "ruff>=0.7",
+]
+
+[project.scripts]
+lembas = "lembas.cli:app"
+
+[project.urls]
+Homepage = "https://github.com/homer/LLeMbas"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/lembas"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "SIM", "C4"]
+ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+asyncio_mode = "auto"
+filterwarnings = ["ignore::DeprecationWarning"]
diff --git a/scripts/build_artwork.py b/scripts/build_artwork.py
new file mode 100644
index 0000000..548c0e8
--- /dev/null
+++ b/scripts/build_artwork.py
@@ -0,0 +1,473 @@
+#!/usr/bin/env python3
+"""Generate every LLeMbas SVG asset from one source of truth.
+
+Why a generator rather than five hand-written files: the leaf mark appears in
+the icon, the favicon, the lockup and the banner. Keeping the geometry in one
+place is the only way those stay identical as the mark is tuned.
+
+Why the wordmark is outlines and not : a README banner on GitHub or Gitea
+cannot load a webfont, so would render in whatever serif the viewer
+happens to have. Outlines look the same everywhere. Letterforms come from
+Source Serif 4 (Adobe, SIL OFL 1.1); only the handful of glyphs actually used
+are extracted, as a static drawing -- no font binary is redistributed.
+
+This is a design-time tool. The application never imports it, and the generated
+files are committed. Re-run it only when the artwork itself changes:
+
+ pip install fonttools
+ python scripts/build_artwork.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import random
+import sys
+from pathlib import Path
+
+try:
+ from fontTools.pens.boundsPen import BoundsPen
+ from fontTools.pens.svgPathPen import SVGPathPen
+ from fontTools.pens.transformPen import TransformPen
+ from fontTools.ttLib import TTFont
+except ImportError: # pragma: no cover - design-time tool
+ sys.exit("fontTools is required for this script: pip install fonttools")
+
+ROOT = Path(__file__).resolve().parent.parent
+ASSETS = ROOT / "assets"
+
+FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
+FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
+
+WORDMARK = "LLeMbas"
+TAGLINE = "Waybread for the long road of thought"
+# The capitals of LLeMbas spell LLM. Those three glyphs carry the accent colour.
+ACCENT_GLYPHS = frozenset({0, 1, 3})
+
+# --- Palette -----------------------------------------------------------------
+GOLD_LIGHT = "#EACB74"
+GOLD = "#C9A227"
+GOLD_DARK = "#916F13"
+GOLD_SCORE = "#7A5C10"
+GOLD_HILIGHT = "#F6E3A8"
+RUNE_GOLD = "#E0B252"
+
+LEAF_EDGE = "#93A5B6"
+LEAF_LIGHT = "#F1F6FA"
+LEAF_MID = "#B8C7D5"
+LEAF_VEIN = "#61758A"
+LEAF_STEM = "#8A9AA8"
+
+NIGHT_TOP = "#080B0F"
+NIGHT_MID = "#101822"
+NIGHT_LOW = "#1A2530"
+PARCHMENT = "#EDE6D6"
+INK = "#1B1F23"
+MUTED = "#9AA7B4"
+
+# --- The mallorn leaf --------------------------------------------------------
+# Drawn once, in a 64x64 box, and reused everywhere. Tuned so the silhouette
+# still reads as a leaf at 16px, where veins and score lines disappear.
+LEAF_BLADE = (
+ "M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
+)
+LEAF_MIDRIB = "M20.5 45.5 C28 38 36 29 45.5 18.5"
+LEAF_STEM_PATH = "M21.2 44.8 L17 49.4"
+LEAF_VEINS = [
+ "M26.9 38.8 Q25.2 35.8 24.9 32.1",
+ "M32.3 33.1 Q30.9 30.3 30.4 26.9",
+ "M37.8 27.0 Q36.6 24.6 36.3 21.7",
+ "M26.9 38.8 Q30.5 40.1 33.7 40.3",
+ "M32.3 33.1 Q35.8 34.3 38.6 34.5",
+ "M37.8 27.0 Q40.8 27.9 43.2 28.1",
+]
+
+HEADER = '
+"""
+
+
+def build_favicon() -> str:
+ """Small-size variant: no score lines or veins, larger blade, tighter tile."""
+ return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64"
+ role="img" aria-label="LLeMbas">
+ LLeMbas
+{mark_defs("f")}
+
+
+
+
+
+
+
+"""
+
+
+def build_wordmark() -> str:
+ """Standalone type. Inherits colour so it can sit on any background."""
+ run = TextRun(FONT_SEMIBOLD, WORDMARK, 100)
+ return f"""{HEADER} viewBox="0 0 {run.width:.2f} {run.height:.2f}"
+ width="{run.width:.2f}" height="{run.height:.2f}" role="img" aria-label="LLeMbas">
+ LLeMbas
+
+{type_style(" ")}
+
+{run.paths(ACCENT_GLYPHS, indent=" ")}
+
+
+"""
+
+
+def build_lockup() -> str:
+ """Horizontal mark + wordmark, for the application header."""
+ cap = 46.0
+ run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
+ mark_size = 64.0
+ gap = 20.0
+ pad = 4.0
+
+ height = mark_size + pad * 2
+ text_x = pad + mark_size + gap
+ # Optically centre on the cap height rather than the full glyph bounds, so
+ # the ascender of "b" and the overshoot of "e" do not shift the baseline.
+ baseline_y = height / 2 + cap / 2
+ width = text_x + run.width + pad
+
+ return f"""{HEADER} viewBox="0 0 {width:.2f} {height:.2f}"
+ width="{width:.2f}" height="{height:.2f}" role="img" aria-label="LLeMbas">
+ LLeMbas
+{mark_defs("l")}
+{type_style(" ")}
+
+{mark_body("l")}
+
+
+{run.paths(ACCENT_GLYPHS, indent=" ")}
+
+
+"""
+
+
+def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
+ """One jagged ridge line spanning the full width."""
+ rng = random.Random(seed)
+ points = [(0.0, base_y)]
+ x = 0.0
+ while x < width:
+ step = rng.uniform(width * 0.045, width * 0.11)
+ x = min(x + step, width)
+ peak = base_y - rng.uniform(height * 0.35, height)
+ points.append((x, peak))
+ # A short shoulder after each peak keeps the ridge from looking like a saw.
+ if x < width:
+ x = min(x + rng.uniform(width * 0.01, width * 0.03), width)
+ points.append((x, peak + rng.uniform(height * 0.08, height * 0.25)))
+ points.append((width, base_y))
+ coords = " ".join(f"{px:.1f},{py:.1f}" for px, py in points)
+ return f' '
+
+
+def _stars(width: float, height: float, count: int, seed: int) -> str:
+ rng = random.Random(seed)
+ out = []
+ for _ in range(count):
+ sx = rng.uniform(0, width)
+ sy = rng.uniform(0, height)
+ r = rng.uniform(0.6, 1.9)
+ opacity = rng.uniform(0.18, 0.85)
+ out.append(
+ f' '
+ )
+ return "\n".join(out)
+
+
+def _drifting_leaves(seed: int) -> str:
+ """A few mallorn leaves adrift in the sky, well behind the type."""
+ rng = random.Random(seed)
+ placements = [
+ (120, 90, 0.42, -18), (250, 250, 0.30, 24), (1035, 95, 0.36, 12),
+ (1160, 215, 0.46, -32), (905, 300, 0.26, 40), (185, 300, 0.24, -8),
+ ]
+ out = []
+ for cx, cy, scale, rot in placements:
+ opacity = rng.uniform(0.10, 0.19)
+ out.append(
+ f' '
+ f''
+ )
+ return "\n".join(out)
+
+
+def build_banner() -> str:
+ """README hero.
+
+ Carries its own dark background rather than relying on the page, because a
+ README is rendered on a light background as often as a dark one.
+ """
+ width, height = 1280.0, 420.0
+ cap = 92.0
+ run = TextRun(FONT_SEMIBOLD, WORDMARK, cap)
+ tag = TextRun(FONT_ITALIC, TAGLINE, 26.0)
+
+ mark_size = 136.0
+ gap = 34.0
+ lockup_w = mark_size + gap + run.width
+ lockup_x = (width - lockup_w) / 2
+ baseline_y = 232.0
+ mark_y = baseline_y - cap / 2 - mark_size / 2
+
+ tag_x = (width - tag.width) / 2 - tag.x0
+ tag_y = baseline_y + 68.0
+
+ mark_scale = mark_size / 64.0
+
+ return f"""{HEADER} viewBox="0 0 {width:.0f} {height:.0f}"
+ width="{width:.0f}" height="{height:.0f}" role="img"
+ aria-label="LLeMbas - {TAGLINE}">
+ LLeMbas
+ {TAGLINE}. A mallorn leaf and wafer above the mountains at night.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{mark_defs("b").removeprefix(" ").removesuffix(" ").rstrip()}
+
+
+
+
+{_stars(width, 300, 130, 11)}
+
+
+
+{_drifting_leaves(5)}
+
+
+{_mountains(width, 366, 3, 150, "#1C2836")}
+{_mountains(width, 392, 8, 112, "#111A25")}
+{_mountains(width, 416, 21, 74, "#080D13")}
+
+
+
+
+{mark_body("b")}
+
+
+
+{run.paths(ACCENT_GLYPHS, indent=" ")}
+
+
+
+{tag.paths(indent=" ", base_class="tag")}
+
+
+"""
+
+
+# --- Entry point -------------------------------------------------------------
+BUILDERS = {
+ "logo-mark.svg": build_logo_mark,
+ "favicon.svg": build_favicon,
+ "wordmark.svg": build_wordmark,
+ "logo-lockup.svg": build_lockup,
+ "banner.svg": build_banner,
+}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--out", type=Path, default=ASSETS)
+ parser.add_argument("--only", nargs="*", choices=sorted(BUILDERS), default=None)
+ args = parser.parse_args()
+
+ args.out.mkdir(parents=True, exist_ok=True)
+ for filename in args.only or BUILDERS:
+ path = args.out / filename
+ path.write_text(BUILDERS[filename](), encoding="utf-8")
+ print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py
new file mode 100644
index 0000000..fe2b959
--- /dev/null
+++ b/src/lembas/__init__.py
@@ -0,0 +1,3 @@
+"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
+
+__version__ = "0.1.0"
diff --git a/src/lembas/api/__init__.py b/src/lembas/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/api/deps.py b/src/lembas/api/deps.py
new file mode 100644
index 0000000..711aeff
--- /dev/null
+++ b/src/lembas/api/deps.py
@@ -0,0 +1,91 @@
+"""Shared FastAPI dependencies: database sessions and the current user."""
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Annotated
+
+from fastapi import Depends, HTTPException, Request, status
+from fastapi.responses import RedirectResponse
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.db.models import User
+from lembas.db.session import get_session_factory
+from lembas.security.sessions import COOKIE_NAME, resolve_session
+
+
+def get_db() -> Iterator[DBSession]:
+ """One database session per request, always closed."""
+ session = get_session_factory()()
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+Db = Annotated[DBSession, Depends(get_db)]
+
+
+def get_current_user(request: Request, db: Db) -> User | None:
+ """Resolve the session cookie to a user, or None when signed out.
+
+ Cached on request.state so several dependencies in one request do not each
+ hit the sessions table.
+ """
+ cached = getattr(request.state, "user", None)
+ if cached is not None:
+ return cached
+ user = resolve_session(db, request.cookies.get(COOKIE_NAME))
+ request.state.user = user
+ return user
+
+
+CurrentUser = Annotated[User | None, Depends(get_current_user)]
+
+
+class RedirectToLogin(HTTPException):
+ """Signals "not signed in" so the exception handler can redirect a browser.
+
+ Raised instead of returning a response because dependencies cannot return
+ one. lembas.main turns this into a 303 for page loads and an HX-Redirect
+ header for HTMX requests, so a partial swap never renders a login form
+ inside the chat pane.
+ """
+
+ def __init__(self, next_url: str = "/") -> None:
+ super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sign in required")
+ self.next_url = next_url
+
+
+def require_user(request: Request, user: CurrentUser) -> User:
+ if user is None:
+ raise RedirectToLogin(next_url=request.url.path)
+ return user
+
+
+RequiredUser = Annotated[User, Depends(require_user)]
+
+
+def require_admin(user: RequiredUser) -> User:
+ if not user.is_admin:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="This area is restricted to administrators.",
+ )
+ return user
+
+
+AdminUser = Annotated[User, Depends(require_admin)]
+
+
+def is_htmx(request: Request) -> bool:
+ return request.headers.get("HX-Request") == "true"
+
+
+def login_redirect(next_url: str = "/") -> RedirectResponse:
+ target = "/auth/login"
+ if next_url and next_url not in ("/", "/auth/login"):
+ from urllib.parse import quote
+
+ target = f"{target}?next={quote(next_url, safe='')}"
+ return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
diff --git a/src/lembas/config.py b/src/lembas/config.py
new file mode 100644
index 0000000..5517cc4
--- /dev/null
+++ b/src/lembas/config.py
@@ -0,0 +1,64 @@
+"""Application configuration, loaded from the environment and/or a .env file."""
+
+from __future__ import annotations
+
+import secrets
+from functools import lru_cache
+from pathlib import Path
+from typing import Literal
+
+from pydantic import Field, field_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ """Runtime configuration. Every variable is prefixed ``LEMBAS_``."""
+
+ model_config = SettingsConfigDict(
+ env_prefix="LEMBAS_",
+ env_file=".env",
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+ secret_key: str = Field(default="")
+ data_dir: Path = Path("./data")
+
+ host: str = "127.0.0.1"
+ port: int = 8080
+ reload: bool = False
+ log_level: Literal["debug", "info", "warning", "error"] = "info"
+
+ allow_signup: bool = True
+ default_theme: Literal["moria", "shire"] = "moria"
+ session_ttl: int = 60 * 60 * 24 * 30
+ request_timeout: float = 300.0
+
+ @field_validator("secret_key")
+ @classmethod
+ def _generate_secret_if_absent(cls, v: str) -> str:
+ # A generated key lets `lembas serve` work out of the box, but it changes
+ # on every restart: sessions drop and stored API keys become unreadable.
+ # main.py warns loudly about this. Never rely on it in production.
+ return v or secrets.token_urlsafe(48)
+
+ @property
+ def db_path(self) -> Path:
+ return self.data_dir / "lembas.db"
+
+ @property
+ def uploads_dir(self) -> Path:
+ return self.data_dir / "uploads"
+
+ def ensure_dirs(self) -> None:
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ self.uploads_dir.mkdir(parents=True, exist_ok=True)
+
+
+@lru_cache
+def get_settings() -> Settings:
+ """Cached singleton so config is parsed once per process."""
+ return Settings()
+
+
+settings = get_settings()
diff --git a/src/lembas/db/__init__.py b/src/lembas/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/db/base.py b/src/lembas/db/base.py
new file mode 100644
index 0000000..cc89a46
--- /dev/null
+++ b/src/lembas/db/base.py
@@ -0,0 +1,54 @@
+"""Declarative base and column conventions shared by every model.
+
+There is no Alembic in this project (SQLite only, schema created at startup).
+That makes adding a column to an existing deployment a manual chore, so models
+carry a few forward-looking columns that are not read yet -- see the notes on
+``Message.parent_id`` and ``Message.content_parts_json``.
+"""
+
+from __future__ import annotations
+
+import uuid
+from datetime import UTC, datetime
+
+from sqlalchemy import DateTime, MetaData, String
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+# Explicit naming convention so constraints have stable, predictable names.
+NAMING_CONVENTION = {
+ "ix": "ix_%(column_0_label)s",
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+ "pk": "pk_%(table_name)s",
+}
+
+
+def new_id() -> str:
+ """Primary keys are UUID4 hex strings: URL-safe and non-enumerable."""
+ return uuid.uuid4().hex
+
+
+def utcnow() -> datetime:
+ return datetime.now(UTC)
+
+
+class Base(DeclarativeBase):
+ metadata = MetaData(naming_convention=NAMING_CONVENTION)
+
+
+class UUIDPrimaryKey:
+ """Mixin: opaque string primary key generated in Python."""
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
+
+
+class Timestamps:
+ """Mixin: creation and modification times, both timezone-aware UTC."""
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=utcnow, nullable=False
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
+ )
diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py
new file mode 100644
index 0000000..def1d8a
--- /dev/null
+++ b/src/lembas/db/models/__init__.py
@@ -0,0 +1,45 @@
+"""All ORM models.
+
+Importing this package registers every table on ``Base.metadata``, which is
+what ``init_db()`` relies on to create the schema at startup. Any new model
+module must be imported here or its table will silently never be created.
+"""
+
+from lembas.db.models.chat import (
+ ROLE_ASSISTANT,
+ ROLE_SYSTEM,
+ ROLE_TOOL,
+ ROLE_USER,
+ Chat,
+ Folder,
+ Message,
+)
+from lembas.db.models.connection import Connection, Model
+from lembas.db.models.setting import Setting
+from lembas.db.models.user import (
+ ROLE_ADMIN,
+ ROLE_PENDING,
+ Group,
+ Session,
+ User,
+ user_groups,
+)
+
+__all__ = [
+ "ROLE_ADMIN",
+ "ROLE_ASSISTANT",
+ "ROLE_PENDING",
+ "ROLE_SYSTEM",
+ "ROLE_TOOL",
+ "ROLE_USER",
+ "Chat",
+ "Connection",
+ "Folder",
+ "Group",
+ "Message",
+ "Model",
+ "Session",
+ "Setting",
+ "User",
+ "user_groups",
+]
diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py
new file mode 100644
index 0000000..6df9625
--- /dev/null
+++ b/src/lembas/db/models/chat.py
@@ -0,0 +1,117 @@
+"""Folders, chats and messages."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
+from lembas.db.types import JSONDict, JSONList
+
+ROLE_SYSTEM = "system"
+ROLE_USER = "user"
+ROLE_ASSISTANT = "assistant"
+ROLE_TOOL = "tool"
+
+
+class Folder(UUIDPrimaryKey, Timestamps, Base):
+ """A user-owned, arbitrarily nested container for chats."""
+
+ __tablename__ = "folders"
+
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ parent_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("folders.id", ondelete="CASCADE")
+ )
+ name: Mapped[str] = mapped_column(String(200), nullable=False)
+ position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ collapsed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ children: Mapped[list[Folder]] = relationship(
+ back_populates="parent",
+ cascade="all, delete-orphan",
+ order_by="Folder.position, Folder.name",
+ )
+ parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
+ chats: Mapped[list[Chat]] = relationship(back_populates="folder")
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class Chat(UUIDPrimaryKey, Timestamps, Base):
+ __tablename__ = "chats"
+
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ # Deleting a folder keeps its chats; they fall back to the unfiled list.
+ folder_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("folders.id", ondelete="SET NULL"), index=True
+ )
+
+ title: Mapped[str] = mapped_column(String(300), default="New chat")
+ # Set once the model writes the first reply, so auto-titling only runs once.
+ title_generated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ # Denormalised rather than a foreign key: chat history must survive an admin
+ # deleting a connection or a model disappearing upstream.
+ model_id: Mapped[str] = mapped_column(String(300), default="")
+ connection_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("connections.id", ondelete="SET NULL")
+ )
+
+ system_prompt: Mapped[str] = mapped_column(Text, default="")
+ params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ folder: Mapped[Folder | None] = relationship(back_populates="chats")
+ messages: Mapped[list[Message]] = relationship(
+ back_populates="chat",
+ cascade="all, delete-orphan",
+ order_by="Message.created_at",
+ )
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class Message(UUIDPrimaryKey, Timestamps, Base):
+ __tablename__ = "messages"
+
+ chat_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+
+ # Reserved for conversation branching (edit a message, regenerate a reply
+ # and keep both). Nothing reads it yet; it exists now because retrofitting a
+ # column onto a live SQLite database without migrations is painful.
+ parent_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("messages.id"))
+
+ role: Mapped[str] = mapped_column(String(16), nullable=False)
+ content: Mapped[str] = mapped_column(Text, default="")
+
+ # Reserved for multimodal turns: [{"type": "image_url", ...}, ...].
+ # Plain-text messages leave this empty and use `content`.
+ content_parts_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
+
+ model_id: Mapped[str] = mapped_column(String(300), default="")
+ tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
+ usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ # Non-empty when generation failed. Rendered as a styled error in the
+ # thread so a failed turn is never an unexplained blank bubble.
+ error: Mapped[str] = mapped_column(Text, default="")
+ # False while a reply is still streaming; flipped when the stream ends.
+ complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ chat: Mapped[Chat] = relationship(back_populates="messages")
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/src/lembas/db/models/connection.py b/src/lembas/db/models/connection.py
new file mode 100644
index 0000000..f7cd000
--- /dev/null
+++ b/src/lembas/db/models/connection.py
@@ -0,0 +1,82 @@
+"""OpenAI-compatible endpoint connections and their discovered models."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
+from lembas.db.types import JSONDict
+
+
+class Connection(UUIDPrimaryKey, Timestamps, Base):
+ """A configured upstream endpoint speaking the OpenAI HTTP API.
+
+ Works for api.openai.com as well as LM Studio, vLLM, llama.cpp, Ollama's
+ compatibility layer, OpenRouter, and anything else exposing /v1.
+ """
+
+ __tablename__ = "connections"
+
+ name: Mapped[str] = mapped_column(String(120), nullable=False)
+ base_url: Mapped[str] = mapped_column(String(500), nullable=False)
+
+ # Fernet ciphertext, never the raw key. See lembas.services.crypto.
+ # Empty string is legitimate: local endpoints often need no auth at all.
+ api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
+
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+
+ # Extra headers merged into every request (e.g. OpenRouter's HTTP-Referer).
+ extra_headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ # Result of the most recent "Test & refresh", surfaced in the admin list.
+ last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ last_error: Mapped[str] = mapped_column(Text, default="")
+
+ models: Mapped[list[Model]] = relationship(
+ back_populates="connection",
+ cascade="all, delete-orphan",
+ order_by="Model.model_id",
+ )
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class Model(UUIDPrimaryKey, Timestamps, Base):
+ """A model advertised by a connection, cached locally.
+
+ Cached rather than fetched live so the chat UI stays responsive and keeps
+ working when an endpoint is briefly unreachable. Refreshed on demand from
+ the admin screen.
+ """
+
+ __tablename__ = "models"
+ __table_args__ = (UniqueConstraint("connection_id", "model_id"),)
+
+ connection_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("connections.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ model_id: Mapped[str] = mapped_column(String(300), nullable=False)
+ display_name: Mapped[str] = mapped_column(String(300), default="")
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ # Endpoints do not reliably advertise capabilities, so these are admin
+ # overrides consumed by later passes (vision uploads, tool calling).
+ capabilities_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+ # Default sampling params applied to new chats using this model.
+ params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ connection: Mapped[Connection] = relationship(back_populates="models")
+
+ @property
+ def label(self) -> str:
+ return self.display_name or self.model_id
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/src/lembas/db/models/setting.py b/src/lembas/db/models/setting.py
new file mode 100644
index 0000000..44b3c6b
--- /dev/null
+++ b/src/lembas/db/models/setting.py
@@ -0,0 +1,28 @@
+"""Instance-wide settings, stored as a key/value table."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from lembas.db.base import Base, Timestamps
+from lembas.db.types import JSONDict
+
+
+class Setting(Timestamps, Base):
+ """One row per settings group, value is an arbitrary JSON object.
+
+ A key/value table rather than a wide typed table: admin settings grow with
+ every feature (tools, agents, image generation) and adding a column to a
+ live SQLite database without migrations is exactly what this avoids.
+ """
+
+ __tablename__ = "settings"
+
+ key: Mapped[str] = mapped_column(String(120), primary_key=True)
+ value: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/src/lembas/db/models/user.py b/src/lembas/db/models/user.py
new file mode 100644
index 0000000..a63c3f8
--- /dev/null
+++ b/src/lembas/db/models/user.py
@@ -0,0 +1,89 @@
+"""Users, groups and login sessions."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
+from lembas.db.types import JSONDict
+
+# Roles are a simple ordered ladder rather than a permission matrix. Groups
+# (below) carry finer-grained permissions once the users/groups UI lands.
+ROLE_ADMIN = "admin"
+ROLE_USER = "user"
+ROLE_PENDING = "pending" # registered but awaiting admin approval
+
+user_groups = Table(
+ "user_groups",
+ Base.metadata,
+ Column("user_id", String(32), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
+ Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
+class User(UUIDPrimaryKey, Timestamps, Base):
+ __tablename__ = "users"
+
+ email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(120), nullable=False)
+ password_hash: Mapped[str] = mapped_column(Text, nullable=False)
+ role: Mapped[str] = mapped_column(String(16), default=ROLE_USER, nullable=False)
+ active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+ # Per-user preferences: theme, default model, composer behaviour, etc.
+ settings_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ groups: Mapped[list[Group]] = relationship(secondary=user_groups, back_populates="users")
+ sessions: Mapped[list[Session]] = relationship(
+ back_populates="user", cascade="all, delete-orphan"
+ )
+
+ @property
+ def is_admin(self) -> bool:
+ return self.role == ROLE_ADMIN
+
+ def __repr__(self) -> str:
+ return f""
+
+
+class Group(UUIDPrimaryKey, Timestamps, Base):
+ """A named set of users. Permissions are enforced once the RBAC pass lands."""
+
+ __tablename__ = "groups"
+
+ name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
+ description: Mapped[str] = mapped_column(Text, default="")
+ permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+
+ users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups")
+
+
+class Session(UUIDPrimaryKey, Timestamps, Base):
+ """Server-side login session.
+
+ Sessions live in the database rather than in a signed JWT so that logging
+ out, banning a user, or rotating a device actually revokes access
+ immediately instead of waiting for a token to expire.
+ """
+
+ __tablename__ = "sessions"
+
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+ )
+ # SHA-256 of the cookie value. The raw token is shown to the browser once
+ # and never stored, so a database leak does not hand over live sessions.
+ token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ user_agent: Mapped[str] = mapped_column(Text, default="")
+ ip_address: Mapped[str] = mapped_column(String(45), default="")
+
+ user: Mapped[User] = relationship(back_populates="sessions")
+
+
+Index("ix_sessions_user_id", Session.user_id)
diff --git a/src/lembas/db/session.py b/src/lembas/db/session.py
new file mode 100644
index 0000000..cdc1790
--- /dev/null
+++ b/src/lembas/db/session.py
@@ -0,0 +1,102 @@
+"""Engine, session factory and startup schema creation."""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Iterator
+from contextlib import contextmanager
+
+from sqlalchemy import Engine, create_engine, event
+from sqlalchemy.orm import Session, sessionmaker
+
+from lembas.config import settings
+from lembas.db.base import Base
+
+log = logging.getLogger(__name__)
+
+_engine: Engine | None = None
+_SessionFactory: sessionmaker[Session] | None = None
+
+
+@event.listens_for(Engine, "connect")
+def _configure_sqlite(dbapi_connection, connection_record) -> None: # noqa: ANN001
+ """Apply the pragmas SQLite needs to behave under a concurrent web server.
+
+ - WAL lets readers proceed while a write is in flight, which matters because
+ a streaming reply holds a write open for the length of the generation.
+ - foreign_keys is OFF by default in SQLite, so every ondelete= in the models
+ would be decoration without this.
+ - busy_timeout makes concurrent writers wait rather than fail instantly.
+ """
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA journal_mode=WAL")
+ cursor.execute("PRAGMA foreign_keys=ON")
+ cursor.execute("PRAGMA busy_timeout=5000")
+ cursor.execute("PRAGMA synchronous=NORMAL")
+ cursor.close()
+
+
+def get_engine() -> Engine:
+ global _engine
+ if _engine is None:
+ settings.ensure_dirs()
+ _engine = create_engine(
+ f"sqlite:///{settings.db_path}",
+ # FastAPI runs sync endpoints in a threadpool, so a connection can
+ # legitimately be used from a thread other than the one that made it.
+ connect_args={"check_same_thread": False},
+ echo=False,
+ future=True,
+ )
+ return _engine
+
+
+def get_session_factory() -> sessionmaker[Session]:
+ global _SessionFactory
+ if _SessionFactory is None:
+ _SessionFactory = sessionmaker(
+ bind=get_engine(),
+ autoflush=False,
+ expire_on_commit=False,
+ )
+ return _SessionFactory
+
+
+def init_db() -> None:
+ """Create any missing tables.
+
+ This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing
+ table. There is no migration tool in this project by design, so changing a
+ column on a model requires migrating the database by hand.
+ """
+ import lembas.db.models # noqa: F401 (registers tables on the metadata)
+
+ Base.metadata.create_all(bind=get_engine())
+ log.debug("schema ensured at %s", settings.db_path)
+
+
+@contextmanager
+def session_scope() -> Iterator[Session]:
+ """Transactional scope for background work and CLI commands.
+
+ Request handlers should use the `db` dependency in lembas.api.deps instead.
+ """
+ factory = get_session_factory()
+ session = factory()
+ try:
+ yield session
+ session.commit()
+ except Exception:
+ session.rollback()
+ raise
+ finally:
+ session.close()
+
+
+def reset_engine() -> None:
+ """Drop cached engine/factory. Used by tests to rebind to a temp database."""
+ global _engine, _SessionFactory
+ if _engine is not None:
+ _engine.dispose()
+ _engine = None
+ _SessionFactory = None
diff --git a/src/lembas/db/types.py b/src/lembas/db/types.py
new file mode 100644
index 0000000..07d21d4
--- /dev/null
+++ b/src/lembas/db/types.py
@@ -0,0 +1,14 @@
+"""Reusable column types.
+
+SQLite stores JSON as text. Wrapping the JSON type in SQLAlchemy's mutation
+tracking means ``obj.settings_json["theme"] = "shire"`` marks the row dirty --
+without it, in-place edits of a dict column are silently dropped on flush.
+"""
+
+from __future__ import annotations
+
+from sqlalchemy import JSON
+from sqlalchemy.ext.mutable import MutableDict, MutableList
+
+JSONDict = MutableDict.as_mutable(JSON)
+JSONList = MutableList.as_mutable(JSON)
diff --git a/src/lembas/schemas/__init__.py b/src/lembas/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/security/__init__.py b/src/lembas/security/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/security/passwords.py b/src/lembas/security/passwords.py
new file mode 100644
index 0000000..6e8b651
--- /dev/null
+++ b/src/lembas/security/passwords.py
@@ -0,0 +1,40 @@
+"""Password hashing.
+
+Argon2id via argon2-cffi, using the library's current recommended parameters.
+``needs_rehash`` lets stored hashes be upgraded transparently when those
+defaults tighten in a future release.
+"""
+
+from __future__ import annotations
+
+from argon2 import PasswordHasher
+from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
+
+_hasher = PasswordHasher()
+
+MIN_PASSWORD_LENGTH = 8
+
+
+def hash_password(password: str) -> str:
+ return _hasher.hash(password)
+
+
+def verify_password(password: str, password_hash: str) -> bool:
+ try:
+ return _hasher.verify(password_hash, password)
+ except (VerifyMismatchError, VerificationError, InvalidHashError):
+ return False
+
+
+def needs_rehash(password_hash: str) -> bool:
+ try:
+ return _hasher.check_needs_rehash(password_hash)
+ except InvalidHashError:
+ return True
+
+
+def validate_password(password: str) -> str | None:
+ """Return a human-readable problem with the password, or None if it is fine."""
+ if len(password) < MIN_PASSWORD_LENGTH:
+ return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
+ return None
diff --git a/src/lembas/security/sessions.py b/src/lembas/security/sessions.py
new file mode 100644
index 0000000..4467337
--- /dev/null
+++ b/src/lembas/security/sessions.py
@@ -0,0 +1,99 @@
+"""Login session lifecycle.
+
+The browser holds an opaque random token in an httpOnly cookie. The database
+stores only its SHA-256, so a dump of the sessions table cannot be replayed as
+a live login. Tokens are compared by hash lookup, and revoking is a DELETE.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import secrets
+from datetime import UTC, datetime, timedelta
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session as DBSession
+
+from lembas.config import settings
+from lembas.db.models import Session as SessionRow
+from lembas.db.models import User
+
+COOKIE_NAME = "lembas_session"
+TOKEN_BYTES = 32
+
+
+def _hash_token(token: str) -> str:
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def create_session(
+ db: DBSession,
+ user: User,
+ *,
+ user_agent: str = "",
+ ip_address: str = "",
+) -> str:
+ """Open a session for a user and return the raw token for the cookie.
+
+ The raw token is returned exactly once and never persisted.
+ """
+ token = secrets.token_urlsafe(TOKEN_BYTES)
+ row = SessionRow(
+ user_id=user.id,
+ token_hash=_hash_token(token),
+ expires_at=datetime.now(UTC) + timedelta(seconds=settings.session_ttl),
+ user_agent=user_agent[:500],
+ ip_address=ip_address[:45],
+ )
+ db.add(row)
+ user.last_login_at = datetime.now(UTC)
+ db.commit()
+ return token
+
+
+def resolve_session(db: DBSession, token: str | None) -> User | None:
+ """Return the signed-in user for a cookie value, or None.
+
+ Expired and orphaned sessions are cleaned up as they are encountered, which
+ keeps the table tidy without needing a scheduled job.
+ """
+ if not token:
+ return None
+
+ row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
+ if row is None:
+ return None
+
+ # SQLite hands back naive datetimes even for timezone-aware columns.
+ expires_at = row.expires_at
+ if expires_at.tzinfo is None:
+ expires_at = expires_at.replace(tzinfo=UTC)
+
+ if expires_at < datetime.now(UTC):
+ db.delete(row)
+ db.commit()
+ return None
+
+ user = db.get(User, row.user_id)
+ if user is None or not user.active:
+ db.delete(row)
+ db.commit()
+ return None
+
+ return user
+
+
+def revoke_session(db: DBSession, token: str | None) -> None:
+ if not token:
+ return
+ row = db.scalar(select(SessionRow).where(SessionRow.token_hash == _hash_token(token)))
+ if row is not None:
+ db.delete(row)
+ db.commit()
+
+
+def revoke_all_for_user(db: DBSession, user: User) -> None:
+ """Sign a user out everywhere. Used when deactivating or changing a password."""
+ for row in db.scalars(select(SessionRow).where(SessionRow.user_id == user.id)):
+ db.delete(row)
+ db.commit()
diff --git a/src/lembas/services/__init__.py b/src/lembas/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/services/crypto.py b/src/lembas/services/crypto.py
new file mode 100644
index 0000000..1aa95e6
--- /dev/null
+++ b/src/lembas/services/crypto.py
@@ -0,0 +1,58 @@
+"""Symmetric encryption for secrets stored in the database.
+
+Only upstream API keys use this today. The key is derived from
+``LEMBAS_SECRET_KEY`` rather than stored separately, which means rotating that
+variable makes every stored API key unreadable -- decrypt() returns "" rather
+than raising, so the app degrades to "re-enter your keys" instead of crashing.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import logging
+from functools import lru_cache
+
+from cryptography.fernet import Fernet, InvalidToken
+
+from lembas.config import settings
+
+log = logging.getLogger(__name__)
+
+
+@lru_cache
+def _fernet() -> Fernet:
+ # Fernet requires a 32-byte urlsafe-base64 key; SECRET_KEY is free-form text.
+ digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
+ return Fernet(base64.urlsafe_b64encode(digest))
+
+
+def encrypt(plaintext: str) -> str:
+ """Encrypt a secret. Empty input stays empty -- keyless endpoints are valid."""
+ if not plaintext:
+ return ""
+ return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii")
+
+
+def decrypt(ciphertext: str) -> str:
+ """Decrypt a secret, returning "" if it cannot be read.
+
+ An unreadable value almost always means LEMBAS_SECRET_KEY changed. Failing
+ soft keeps the admin UI usable so the key can simply be re-entered.
+ """
+ if not ciphertext:
+ return ""
+ try:
+ return _fernet().decrypt(ciphertext.encode("ascii")).decode("utf-8")
+ except (InvalidToken, ValueError):
+ log.warning("could not decrypt a stored secret; has LEMBAS_SECRET_KEY changed?")
+ return ""
+
+
+def mask(secret: str) -> str:
+ """Render a secret for display: never the whole thing, just enough to identify it."""
+ if not secret:
+ return ""
+ if len(secret) <= 8:
+ return "*" * len(secret)
+ return f"{secret[:3]}{'*' * 8}{secret[-4:]}"
diff --git a/src/lembas/services/llm/__init__.py b/src/lembas/services/llm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/web/__init__.py b/src/lembas/web/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/lembas/web/templates/partials/icons.html b/src/lembas/web/templates/partials/icons.html
new file mode 100644
index 0000000..846d25f
--- /dev/null
+++ b/src/lembas/web/templates/partials/icons.html
@@ -0,0 +1,125 @@
+{#
+ Icon sprite, inlined once at the top of .
+
+ It lives here rather than in assets/ and is deliberately NOT loaded as an
+ external file: cross-document