Scaffold project, data model and artwork

Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM
schema, and the original SVG identity.

Notable decisions, all recorded in comments at the point they matter:

- No Alembic. SQLite only, schema created at startup, so models carry a
  few columns nothing reads yet (Message.parent_id for branching,
  content_parts_json for multimodal turns). Adding them later to a live
  database without migrations is the painful path.
- Sessions are server-side rows keyed by a SHA-256 of the cookie value,
  not JWTs, so logout and bans revoke access immediately.
- Upstream API keys are Fernet-encrypted with a key derived from
  LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret
  degrades to re-entering keys rather than crashing the admin UI.
- Artwork is generated by scripts/build_artwork.py rather than hand-drawn
  per file: the mallorn leaf appears in the icon, favicon, lockup and
  banner, and one source is the only way those stay in sync. The wordmark
  is Source Serif 4 (OFL) converted to outlines, because a README banner
  cannot load a webfont and <text> would render in whatever serif the
  viewer happens to have.
- Icons live in a template partial, not assets/, because same-document
  <use href="#id"> is universally supported and the cross-document form
  is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 10:34:48 +02:00
parent 0665027bc6
commit 5ef2af6a9f
32 changed files with 2041 additions and 0 deletions
+89
View File
@@ -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"<User {self.email} role={self.role}>"
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)