Onyscribe Admin: Initialer Stand

Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.venv
.git
.data
.preview-cache
__pycache__
tests

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# ── Pflicht ───────────────────────────────────────────────────────────────────
# Datenbank: Postgres (docker-compose setzt sie selbst) oder SQLite für lokale Entwicklung
DATABASE_URL=sqlite:///./.data/onyscribe-admin.db
# Master-Key für Secrets in der DB UND Session-Cookies (Fernet-Key). SICHERN – ohne ihn sind Secrets unlesbar.
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
SECRET_KEY=
# ── Login der Admin-Plattform (login.onyva.dev) ───────────────────────────────
AUTH_ENABLED=false
OIDC_ISSUER=https://login.onyva.dev
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://admin.example.de/auth/callback
OIDC_LOGOUT_REDIRECT_URI=https://admin.example.de/
APP_COOKIE_SECURE=true
# Zugriffsregel (mindestens eine, sonst Default-Deny):
ADMIN_ALLOWED_EMAILS=
ADMIN_REQUIRED_ROLE=
# ── Deploys ───────────────────────────────────────────────────────────────────
DEPLOY_ALLOWED_TENANTS= # Tenants, die LIVE angefasst werden dürfen (kommagetrennt, * = alle, leer = keiner) – zum Start nur den Test-Tenant, z. B. demo-admin
DEPLOY_LIVE=false # true erlaubt ECHTE Schreibzugriffe auf Mittwald – bewusst aus lassen, bis der Core umgebaut ist
MITTWALD_API_TOKEN= # optional für lesende Dry-Run-Aufrufe
# ── OIDC-Clients der Tenants automatisch verwalten (login.onyva.dev /api/v1) ──
ONYVA_LOGIN_API_KEY= # Admin-Key aus dem Login-Dashboard (API-Keys); wie ein Passwort behandeln
OIDC_SYNC_LIVE=false # true = darf im Login Apps anlegen/ändern; ohne: nur lesen/Plan (Dry-Run)
# ── KI ────────────────────────────────────────────────────────────────────────
CORTECS_API_KEY= # alternativ: Einstellungen → Geteilte Secrets

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
.env
*.db
.preview-cache/
.data/
.idea/
.gstack/

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.12-slim
# WeasyPrint (PDF-Vorschau) + Fonts – identisch zum Core-Image der Plattform
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi8 \
fonts-noto-core fonts-noto-color-emoji fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY vendor ./vendor
COPY seed ./seed
COPY scripts ./scripts
ENV PORT=8300 PYTHONUNBUFFERED=1
EXPOSE 8300
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8300')+'/healthz')"
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8300} --proxy-headers --forwarded-allow-ips='*'"]

24
Dockerfile.fromcore Normal file
View File

@@ -0,0 +1,24 @@
# Admin-Image auf Basis des Core-Images: Sandbox-Vorschau, Schriften, Python-Pakete und WeasyPrint sind
# damit PER KONSTRUKTION identisch zur Produktion, und der Core-Vertrag (Schema, Manifest) kommt direkt aus dem Image.
#
# docker build -f Dockerfile.fromcore --build-arg CORE_IMAGE=<registry>/transkript-platform:<sha> -t onyscribe-admin .
#
# Coolify: ADMIN_DOCKERFILE=Dockerfile.fromcore, CORE_IMAGE=<registry>/transkript-platform:<sha oder latest>.
# Läuft der Admin auf einem anderen Core als ein gemeldetes Release, zeigt „Core“ das an und lässt keine Prüfung/Freigabe zu.
ARG CORE_IMAGE
FROM ${CORE_IMAGE}
WORKDIR /srv
COPY requirements-extra.txt .
RUN pip install --no-cache-dir -r requirements-extra.txt \
&& apt-get update && apt-get install -y --no-install-recommends fonts-liberation && rm -rf /var/lib/apt/lists/*
COPY app ./app
COPY seed ./seed
COPY scripts ./scripts
# Core liegt im Image unter /app (core/, requirements.txt, BUILD_INFO.json)
ENV PLATFORM_DIR=/app PORT=8300 PYTHONUNBUFFERED=1 ONYSCRIBE_SKIP_DOTENV=1
EXPOSE 8300
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD python -c "import urllib.request,os;urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8300')+'/healthz')"
CMD ["sh", "-c", "cd /srv && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8300} --proxy-headers --forwarded-allow-ips='*'"]

141
README.md Normal file
View File

@@ -0,0 +1,141 @@
# Onyscribe Admin
Separat gehostete Admin-Plattform für die Tenants von **Onyscribe** (Repo `transkript-platform`): Tenants anlegen und bearbeiten,
Live-Vorschau mit dem echten Core, versionieren, Secrets/OIDC/Domain pflegen, deployen, mit KI-Agent (Cortecs).
## Stand
Alles, was **ohne Änderung am Core und ohne echte Deployments** möglich ist, ist umgesetzt:
| Bereich | Stand |
|---|---|
| Tenants (7 importiert, Hash-identisch zu Git) | ✔ Import, Seed, Anlegen/Klonen, Status, Ressourcen, Notizen, Archivieren/Löschen |
| Entwurf → Diff → Version | ✔ unveränderliche Snapshots, Rollback als Entwurf, Hook-Code-Freigabe |
| Editor | ✔ Marke/Farben, Absender, PDF-Layout, Prompt, Dateien, Flags/STT, Hook-Code, tenant.json |
| Live-Vorschau | ✔ Web-UI + PDF mit dem echten Core in Sandbox (kein Secret, CPU-/Datei-Limits), Validierung |
| Prompt-Test | ✔ echter LLM-Lauf (braucht Cortecs-Key), finaler Prompt inkl. Hooks |
| KI-Agent | ✔ echter Tool-Loop (Messages- oder Chat-API gegen Cortecs), Workspace-Entwurf, Diff, Freigabe; ✔ Demo-Modus ohne Key; ⚠ gegen Live-Cortecs noch **ungetestet** (kein Key) |
| Login (OIDC) | ✔ Code + Tests (Mock-IdP); ⚠ gegen login.onyva.dev noch **ungetestet** |
| Secrets | ✔ Fernet-verschlüsselt, Import aus SOPS (`--with-secrets`) |
| OIDC-Clients | ✔ **automatischer Abgleich bei jedem Deploy** über die Login-Developer-API: Client anlegen, Redirect-URIs, auth/totp, Freigaben; Secret wird sofort verschlüsselt gespeichert; Dry-Run liest nur. Gegen Fake-Login getestet, gegen login.onyva.dev **ungetestet** (kein Key) |
| Domain | ✔ Subdomain/eigene Domain, DNS-Check |
| Deploy-Engine | ✔ Port von `infra/deploy.py` (PATCH, Guard, Ingress, Offboard, Abbruch bei Fehler), per Fake-HTTP getestet; **Schreibzugriffe gesperrt** (`DEPLOY_LIVE`) |
| Bundle-Endpoint | ✔ `GET /api/tenants/<id>/bundle` (Token); Gegenstück im Core: Branch `admin-platform-integration` (`core/bundle.py`) |
| **Core-Vertrag** | ✔ Schema/Manifest/Build-Info aus dem Core gelesen (`app/services/contract.py`); Formulare („Funktion & STT“ + automatisch alle neuen Optionen), Einstellungen und Container-Env werden daraus **erzeugt**; Pflicht-Secrets laut Schema |
| **Core-Releases** (Menü „Core“) | ✔ Release-Eingang für die Core-CI, Kompatibilitätsprüfung **aller** Tenants mit dem Core des Admins (Validierung + Fingerabdrücke von PDF/Web/Prompt gegen Baseline), Freigabe, Dry-Run, Canary-Rollout mit Abbruch, Health-Verifikation (Core-SHA, Bundle-Version), Pins pro Tenant, Rollback (Release/Tenant) |
| Zustandsabgleich | ✔ „Zustand prüfen“ im Dashboard und „Läuft?“ pro Tenant: fragt `/health` ab und zeigt Abweichungen |
**Bewusst offen (braucht Core- bzw. Produktionsentscheidung):**
1. Core-Branch `admin-platform-integration` (Vertrag, Bundle-Loader, `/health`) reviewen und mergen/deployen. Erst danach `core_supports_bundle` aktivieren (Einstellungen) – bis dahin sind Deploys von Tenants mit Quelle „Admin“ gesperrt.
2. `DEPLOY_LIVE=true` + `MITTWALD_API_TOKEN` + Projekt-/Stack-ID, sobald echte Deployments gewollt sind.
3. Cortecs-Key hinterlegen → Agent-Modell auswählen (Einstellungen → „Modelle von Cortecs laden“) und gegen Live testen.
4. OIDC-Client für die Admin-Plattform in login.onyva.dev anlegen, `AUTH_ENABLED=true`.
4b. Admin-Key im Login-Dashboard erzeugen → `ONYVA_LOGIN_API_KEY` setzen; mit „Verbindung testen“ (Einstellungen) prüfen; zum Schreiben `OIDC_SYNC_LIVE=true`.
5. Datenbank-Migrationen (Alembic) vor dem ersten produktiven Schemawechsel; Tabellen entstehen derzeit per `create_all`.
## Starten
```bash
# lokal (SQLite, kein Docker)
python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt
# System-Libs für WeasyPrint: libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-core
cp .env.example .env # Keys eintragen (wird beim Start eingelesen; echte Env-Variablen haben Vorrang)
uvicorn app.main:app --port 8300 # http://localhost:8300 (Seed lädt die 7 Tenants automatisch) – nach .env-Änderungen neu starten
# mit Postgres (wie Coolify)
export POSTGRES_PASSWORD=... SECRET_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
docker compose up -d --build
```
**Coolify:** Neue Ressource → Docker Compose → dieses Repo. Env `POSTGRES_PASSWORD` und `SECRET_KEY` setzen (weitere Variablen: `.env.example`),
Domain auf Service `admin`, Port `8300`. Backup: Volume `pgdata` **und** `SECRET_KEY`.
**Login aktivieren:** Client in login.onyva.dev anlegen (Redirect `https://<admin>/auth/callback`), dann `AUTH_ENABLED=true`, `OIDC_CLIENT_ID/SECRET`,
`OIDC_REDIRECT_URI`, `OIDC_LOGOUT_REDIRECT_URI` und eine Zugriffsregel (`ADMIN_ALLOWED_EMAILS` und/oder `ADMIN_REQUIRED_ROLE`). Ohne Regel wird jeder Login abgelehnt.
## Zusammenspiel mit dem Core
Spezifikation: `transkript-platform/docs/CONTRACT.md`. Kurz: Der Core liefert **Image + Schema + Manifest + Bundle-Format + `/health`**, der Admin liest das –
er hält keine eigene Kopie der Tenant-Optionen.
```
Core-Push → CI: Tests → Image (:sha) → POST /api/core/releases (Job „notify-admin“)
→ Admin neu bauen (Dockerfile.fromcore, FROM Core-Image) → „Core“: Prüfen (alle Tenants) → Freigeben → Ausrollen (Canary zuerst) → Health-Check je Tenant
Tenant-Änderung (ohne Core): Veröffentlichen → Deploy mit dem aktiven Core-Tag (nur Bundle-Neustart, kein Image-Build)
```
- **Admin auf Basis des Core-Images:** `Dockerfile.fromcore` (`CORE_IMAGE=<registry>/transkript-platform:<tag>`). Sandbox, Schriften und Pakete sind identisch zur Produktion. Läuft der Admin auf einem anderen Core als ein gemeldetes Release, zeigt „Core“ das an und erlaubt keine Prüfung/Freigabe. *Noch zu verifizieren in Coolify:* wie der Neubau mit neuem Tag ausgelöst wird (Webhook + `CORE_IMAGE` per Env/API oder `latest` mit „immer pullen“).
- **Lokal ohne Core-Image:** vendored Snapshot (`scripts/sync_core.sh`, zieht den aktuellen Stand der Quelle inkl. Branch).
- **Neue Core-Option?** Steht sie im Schema des Cores (CI erzwingt das), erscheint sie hier automatisch in Formularen, Einstellungen und Container-Env.
- **Release-Eingang:** Env `CORE_RELEASE_TOKEN` (Admin) = Secret `ADMIN_RELEASE_TOKEN` (Core-CI) + Variable `ADMIN_URL`.
## OIDC-Abgleich (Login-Developer-API)
Pro Tenant (Domain & Login) gibt es einen Soll-Zustand und eine **Verwaltung**:
| Modus | Verhalten beim Deploy |
|---|---|
| `off` | nichts anfassen |
| `adopt` (Standard für die 7 importierten Altclients) | prüfen, dass die Redirect-URIs (Subdomain + ggf. eigene Domain) enthalten sind, fehlende ergänzen – sonst nichts ändern |
| `managed` (Standard für neue Tenants) | Client fehlt → **anlegen**; sonst Redirect-URIs, `auth_mode`, `totp_mode`, `allow_registration`, Backchannel exakt wie konfiguriert; Freigaben setzen (optional „exakt“: überzählige entziehen) |
Ablauf eines Deploys: **1.** OIDC abgleichen (bei Neuanlage Client-ID/-Secret sofort verschlüsselt speichern) → **2.** Pflicht-Secrets prüfen → **3.** Service-PATCH mit den Werten
im Env → **4.** Ingress/Health. Schlägt der Abgleich fehl, wird **vor** dem Service-Eingriff abgebrochen. Dry-Run liest nur und zeigt „würde anlegen/anpassen“.
Zusätzlich manuell: „Gegen Login prüfen“, „Jetzt abgleichen“, „Aus Login übernehmen“ (Freigaben), „Secret rotieren“, „OIDC-Client im Login löschen“ (Gefahrenbereich).
Das Secret ist nur bei Anlage/Rotation sichtbar; ein Altclient ohne Secret in der Admin-DB wird **nie** automatisch rotiert (er würde laufende Logins unterbrechen) – Secret eintragen oder bewusst rotieren.
Lokal ausprobieren ohne echtes Login: `python scripts/fake_login_server.py` (siehe Datei-Kopf).
## KI-Agent
**Bedienung:** Header → „KI-Agent“. Der Chat ersetzt in der Mitte das Formular, die **Live-Vorschau bleibt rechts** und zeigt den Arbeitsstand des Agenten *während er arbeitet*
(„Agent-Stand (live)“, rendert nach jeder Änderung neu), danach den „Agent-Vorschlag“. „In Entwurf übernehmen“ schreibt ihn in den Entwurf, „Verwerfen“ lässt alles unverändert.
**Key-Suche:** geteiltes Secret (Einstellungen) → Env/`.env` `CORTECS_API_KEY` → Key des Tenants. Der Chat zeigt, woher der Key kommt, bzw. „Demo-Modus – kein Key gefunden“.
Cortecs bietet neben Chat-Completions auch das **Anthropic-Messages-Protokoll** (`/v1/messages`) – der Agent nutzt standardmäßig dieses
(Einstellung `agent_api`), mit Fallback auf Chat-Completions. Modell: Einstellung `agent_model` (Standard `claude-4-6-sonnet`, wie das Protokoll-Modell;
„Modelle von Cortecs laden“ zeigt die verfügbaren IDs und markiert Tool-Unterstützung). Der Agent arbeitet nur auf einem Entwurfs-Workspace; Namenslisten
sind gesperrt; Secrets kennt er nicht; Veröffentlichen/Deployen kann er nicht.
## Aufbau
```
app/
main.py FastAPI, Startup (DB anlegen, Seed laden)
models.py Tenant, Version (unveränderlich), Blob (content-addressed), Secret (verschlüsselt), Deployment, AuditLog, ChatMessage
services/
store.py Entwurf ↔ Version, Diff, Bundle (tar.gz) – Kern der Versionierung
preview.py Live-Vorschau: Subprozess mit Kopie des Cores + Tenant, ohne Secrets (preview_worker.py)
importer.py Import aus ../transkript-platform (tenants/*, Secrets nur als Schlüsselnamen)
deploy_sim.py Deploy-Plan (wie infra/deploy.py) + Simulation ← hier später die echte Mittwald-Logik
agent_mock.py Mock-Agent ← hier später der Cortecs-Loop
seed.py Start-Daten (seed/tenants.seed.json.gz)
routers/ api.py (JSON), pages.py (HTML)
vendor/platform/ Snapshot des Cores (CORE_VERSION) – Vorschau führt genau diesen Stand aus
scripts/ import_tenants.py, verify_import.py, export_seed.py, sync_core.sh
tests/test_flow.py E2E: Import, Vorschau, Entwurf→Veröffentlichen, Validierung, Bundle, Deploy-Sim, Agent
```
## Wichtige Abläufe
```bash
python scripts/import_tenants.py # Tenants (+ Secret-SCHLÜSSEL) aus ../transkript-platform importieren
python scripts/import_tenants.py --with-secrets # zusätzlich Secret-WERTE via `sops -d` lesen und verschlüsselt speichern (Cut-over)
python scripts/verify_import.py # Beweis: DB-Bundle == Git-Ordner (Hash-Vergleich)
python scripts/export_seed.py # DB-Stand als Seed-Datei ins Repo (ohne Secret-Werte)
scripts/sync_core.sh # vendored Core aus transkript-platform aktualisieren
python -m pytest tests # Tests (51) (isoliert: lesen weder .env noch echte Keys, eigene Temp-DB)
```
## Bundle-Endpoint (Schnittstelle zum Core)
`GET /api/tenants/<id>/bundle` mit `Authorization: Bearer <ADMIN_BUNDLE_TOKEN>` liefert `tenant.json` + alle Dateien
(+ `MANIFEST.json` mit Hashes) – ohne Secrets. Das ist, was ein Tenant-Container später beim Start abholt
(`TENANT_SOURCE=admin`, Phase 1 im Core – noch nicht umgesetzt).
## Nächste Schritte
1. Core: `TENANT_SOURCE=git|admin` + Bundle-Loader mit Cache (Phase 1)
2. OIDC-Login der Admin-Plattform über login.onyva.dev
3. Spike: Cortecs-Modell für den Agent (Tool-Calling, Streaming, Bild-Input) → `agent_mock.py` ersetzen
4. Echte Deploys (Mittwald) aus `deploy_sim.py`, Rollout-Abbruch bei Fehler, Health-Check
5. Hook-Code-Freigabe härten (Sandbox ohne Netz), Rollen

0
app/__init__.py Normal file
View File

0
app/agent/__init__.py Normal file
View File

129
app/agent/backends.py Normal file
View File

@@ -0,0 +1,129 @@
"""LLM-Backends für den Agenten. Interne Nachrichten im Anthropic-Block-Format:
assistant: [{"type":"text"|"tool_use",...}] · user: str | [{"type":"tool_result","tool_use_id","content"}]
"""
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass, field
@dataclass
class Turn:
blocks: list[dict]
usage: dict = field(default_factory=dict)
class MessagesBackend:
"""Anthropic-Messages-Protokoll – Cortecs bietet es unter https://api.cortecs.ai/v1/messages an."""
name = "messages"
def __init__(self, api_key: str, base_url: str, model: str):
import anthropic
root = re.sub(r"/v1/?$", "", base_url.rstrip("/")) # SDK hängt /v1/messages selbst an
self.client = anthropic.Anthropic(api_key=api_key, auth_token=api_key, base_url=root, max_retries=2, timeout=180)
self.model = model
def step(self, system: str, messages: list[dict], tools: list[dict]) -> Turn:
r = self.client.messages.create(
model=self.model, max_tokens=8000, system=system, messages=messages,
tools=[{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools])
blocks = []
for b in r.content:
if b.type == "text":
blocks.append({"type": "text", "text": b.text})
elif b.type == "tool_use":
blocks.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
return Turn(blocks, {"in": r.usage.input_tokens, "out": r.usage.output_tokens})
class ChatBackend:
"""OpenAI-Chat-Completions (Fallback, falls ein Modell nur diesen Weg mit Tools kann)."""
name = "chat"
def __init__(self, api_key: str, base_url: str, model: str):
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=2, timeout=180)
self.model = model
@staticmethod
def _convert(system: str, messages: list[dict]) -> list[dict]:
out = [{"role": "system", "content": system}]
for m in messages:
c = m["content"]
if isinstance(c, str):
out.append({"role": m["role"], "content": c})
elif m["role"] == "assistant":
text = "".join(b["text"] for b in c if b["type"] == "text")
calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b["input"])}}
for b in c if b["type"] == "tool_use"]
msg = {"role": "assistant", "content": text or None}
if calls:
msg["tool_calls"] = calls
out.append(msg)
else:
for b in c:
out.append({"role": "tool", "tool_call_id": b["tool_use_id"], "content": b["content"]})
return out
def step(self, system, messages, tools) -> Turn:
r = self.client.chat.completions.create(
model=self.model, max_tokens=8000, temperature=0.2, messages=self._convert(system, messages),
tools=[{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}} for t in tools])
msg = r.choices[0].message
blocks = []
if msg.content:
blocks.append({"type": "text", "text": msg.content})
for tc in msg.tool_calls or []:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
blocks.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": args})
u = r.usage
return Turn(blocks, {"in": getattr(u, "prompt_tokens", 0), "out": getattr(u, "completion_tokens", 0)})
HEX = re.compile(r"#[0-9a-fA-F]{6}\b")
def _shade(h, f):
h = h.lstrip("#"); c = [max(0, min(255, int(int(h[i:i + 2], 16) * f))) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
def _tint(h, a):
h = h.lstrip("#"); c = [int(int(h[i:i + 2], 16) + (255 - int(h[i:i + 2], 16)) * a) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
class MockBackend:
"""Demo ohne Cortecs-Key: spielt über dieselben Tools zwei einfache Aufgaben durch."""
name = "mock"
model = "mock"
def step(self, system, messages, tools) -> Turn:
used = sum(1 for m in messages if m["role"] == "assistant")
user = next(m["content"] for m in messages if m["role"] == "user" and isinstance(m["content"], str))
low = user.lower()
tid = lambda: "mock_" + uuid.uuid4().hex[:8] # noqa: E731
call = lambda name, **a: {"type": "tool_use", "id": tid(), "name": name, "input": a} # noqa: E731
hexes = HEX.findall(user)
if hexes and any(w in low for w in ("farbe", "akzent", "color", "design")):
a = hexes[0].upper()
plan = [[call("set_config", key=f"colors.{k}", value=v) for k, v in
{"accent": a, "accent_dark": _shade(a, .72), "accent_mid": _shade(a, 1.12),
"accent_light": _tint(a, .88), "accent_xlight": _tint(a, .94)}.items()],
[call("validate")]]
final = f"Akzentfarbe auf {a} gesetzt und die abgeleiteten Töne angepasst."
elif "prompt" in low or "regel" in low:
rule = re.sub(r"^(bitte\s+)?(füge\s+)?(dem\s+)?prompt\s*(hinzu)?[:,]?\s*", "", user.strip(), flags=re.I)
plan = [[call("read_core_default", name="prompt.txt")], [call("__append_prompt__", rule=rule)], [call("validate")]]
final = "Zusatzregel im Prompt ergänzt."
else:
return Turn([{"type": "text", "text": "Demo-Modus – es wurde kein Cortecs-Key gefunden (geprüft: Einstellungen → Geteilte Secrets, Env/.env CORTECS_API_KEY, Key des Tenants). "
"Key setzen und die Seite neu laden. Ohne Key verstehe ich nur „Setze die Akzentfarbe auf #RRGGBB“ und „Füge dem Prompt hinzu: …“."}])
if used < len(plan):
return Turn(plan[used])
return Turn([{"type": "text", "text": final}])

175
app/agent/loop.py Normal file
View File

@@ -0,0 +1,175 @@
"""Agent-Loop: Modell ↔ Werkzeuge auf dem Entwurfs-Workspace → Vorschlag zur Freigabe."""
from __future__ import annotations
import json
import uuid
from collections import OrderedDict
from copy import deepcopy
from typing import Iterator
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.agent import backends
from app.agent.tools import TOOLS, ToolRunner
from app.agent.workspace import Workspace
from app.models import ChatMessage, Setting, Tenant
from app.services import llm, store
MAX_TURNS = 14
# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24.
WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict()
def remember(run_id: str, ws: Workspace) -> None:
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files))
WORKSPACES.move_to_end(run_id)
while len(WORKSPACES) > 24:
WORKSPACES.popitem(last=False)
DEFAULT_MODEL = "claude-4-6-sonnet"
SYSTEM = """Du bist der Konfigurations-Agent der Onyscribe-Admin-Plattform. Onyscribe erzeugt aus Gesprächsaufzeichnungen Protokolle
(Transkription → LLM-Protokoll → PDF/Word). Jeder Kunde ist ein „Tenant“; alles, was ihn vom Standard unterscheidet, sind DATEN:
tenant.json (Name, Farben, Kontaktdaten, Flags, STT), prompt.txt, protocol.html/protocol.css (PDF-Layout), optional overrides.py
(Hook-Code) und Zusatzdateien. Der Core-Code der Plattform ist NICHT veränderbar und nicht sichtbar – du arbeitest ausschließlich an Tenant-Daten.
Arbeitsweise:
- Du arbeitest auf einem ENTWURF im Speicher. Nichts ist live; am Ende prüft ein Mensch deinen Vorschlag und gibt ihn frei.
- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese.
- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht.
- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest.
- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes.
- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an.
- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden.
- Antworte am Ende kurz auf Deutsch (du-Form): was du geändert hast und worauf der Mensch beim Prüfen achten soll. Keine Markdown-Überschriften."""
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def make_backend(db: Session, tenant_id: str | None = None):
model = _setting(db, "agent_model", DEFAULT_MODEL)
key = llm.api_key(db, tenant_id)
if not key:
return backends.MockBackend()
api = _setting(db, "agent_api", "messages")
cls = backends.MessagesBackend if api == "messages" else backends.ChatBackend
return cls(key, llm.base_url(db), model)
def _context(tenant: Tenant, ws: Workspace) -> str:
return (f"Tenant: {tenant.id} („{ws.cfg.get('name', '')}“). Dateien: "
+ ", ".join(f['path'] for f in ws.list_files()) + ". Der Entwurf kann bereits unveröffentlichte Änderungen enthalten.")
def _history(db: Session, tenant_id: str, limit: int = 8) -> list[dict]:
rows = db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == tenant_id).order_by(ChatMessage.id.desc()).limit(limit)).all()
out = []
for m in reversed(rows):
if m.content:
out.append({"role": m.role, "content": m.content})
# Anthropic verlangt Rollenwechsel beginnend mit user
while out and out[0]["role"] != "user":
out.pop(0)
merged = []
for m in out:
if merged and merged[-1]["role"] == m["role"]:
merged[-1]["content"] += "\n" + m["content"]
else:
merged.append(dict(m))
return merged
def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterator[dict]:
"""Generator von Ereignissen: step | text | proposal | usage | error. Schreibt NICHT in den Entwurf."""
backend = backend or make_backend(db, tenant.id)
ws = Workspace(db, tenant)
run_id = uuid.uuid4().hex[:12]
remember(run_id, ws)
last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
runner = ToolRunner(ws)
system = SYSTEM + "\n\n" + _context(tenant, ws)
messages = _history(db, tenant.id)
messages.append({"role": "user", "content": user_message})
usage = {"in": 0, "out": 0}
final_text = ""
yield {"type": "mode", "backend": backend.name, "model": getattr(backend, "model", ""), "run": run_id}
try:
for _ in range(MAX_TURNS):
turn = backend.step(system, messages, TOOLS)
usage["in"] += turn.usage.get("in", 0)
usage["out"] += turn.usage.get("out", 0)
messages.append({"role": "assistant", "content": turn.blocks})
calls = [b for b in turn.blocks if b["type"] == "tool_use"]
texts = [b["text"] for b in turn.blocks if b["type"] == "text" and b["text"].strip()]
if texts:
final_text = "\n".join(texts)
if calls:
yield {"type": "thought", "text": final_text}
if not calls:
break
results = []
for c in calls:
name, args = c["name"], c["input"]
if name == "__append_prompt__": # nur Mock: Standard-Prompt kopieren + Regel anhängen
base = ws.read("prompt.txt") if "prompt.txt" in ws.files else (
__import__("app.settings", fromlist=["PLATFORM_DIR"]).PLATFORM_DIR / "core" / "defaults" / "prompt.txt").read_text(encoding="utf-8")
ws.write("prompt.txt", base.rstrip() + f"\n\nZusätzliche Regel:\n{args['rule'].strip()}\n")
arg, res = "prompt.txt", "Zusatzregel angehängt"
name = "edit_file"
else:
arg, res = runner.run(name, args)
short = res if len(res) < 220 else res[:220] + " …"
if name == "validate" and not res.startswith("FEHLER"):
try:
v = json.loads(res)
short = "✔ bestanden" + (f" ({len(v['warnings'])} Hinweis(e))" if v.get("warnings") else "") if v.get("ok") else "✘ " + "; ".join(v.get("errors", []))[:200]
except (ValueError, KeyError):
pass
yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")}
sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
last_sig = sig
remember(run_id, ws)
yield {"type": "changed", "run": run_id}
results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res})
messages.append({"role": "user", "content": results})
else:
final_text = (final_text + "\n\n(Abgebrochen: maximale Schrittzahl erreicht.)").strip()
except llm.LLMNotConfigured as exc:
yield {"type": "error", "text": str(exc)}
return
except Exception as exc: # noqa: BLE001 – Provider-/Netzfehler sauber an die UI melden
yield {"type": "error", "text": f"{type(exc).__name__}: {str(exc)[:400]}"}
return
prop = ws.proposal(final_text.split("\n")[0][:140] or "Änderungsvorschlag")
if prop is None and any(True for m in messages if m["role"] == "assistant" and isinstance(m["content"], list)
and any(b.get("name") in ("write_file", "edit_file", "set_config", "delete_file") for b in m["content"] if b["type"] == "tool_use")):
final_text = (final_text + "\n\n(Keine Änderung gegenüber dem aktuellen Entwurf.)").strip()
yield {"type": "text", "text": final_text or "(keine Antwort)"}
yield {"type": "proposal", "proposal": prop}
yield {"type": "usage", "usage": usage}
def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
"""Freigabe: Vorschlag in den Entwurf übernehmen (nur die betroffenen Pfade)."""
if proposal.get("config") is not None:
store.set_config(db, tenant, proposal["config"])
for path, sha in (proposal.get("files") or {}).items():
store.set_file(db, tenant, path, store.get_blob(db, sha))
for path in proposal.get("deleted") or []:
store.delete_file(db, tenant, path)
def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]:
"""Inhalt, der nach Freigabe im Entwurf stünde (für die Diff-Anzeige)."""
cfg, files = store.content(db, tenant, "draft")
if proposal.get("config") is not None:
cfg = proposal["config"]
files = {**files, **(proposal.get("files") or {})}
for p in proposal.get("deleted") or []:
files.pop(p, None)
return cfg, files

24
app/agent/testrun.py Normal file
View File

@@ -0,0 +1,24 @@
"""Prompt-Test: finalen Prompt vom Core bauen lassen (inkl. Hooks), LLM aufrufen."""
from __future__ import annotations
from app.services import llm, preview
from app.services.samples import SAMPLE_TRANSCRIPT, ROTARY_TRANSCRIPT
def user_content(transcript: str, terminart="", projekt="", kunde="", title="Beispiel") -> str:
return (f"Termin-Art: {terminart or '(aus Gespräch erkennen)'}\nProjektname: {projekt or '(keiner)'}\n"
f"Kunde / Unternehmen: {kunde or '(aus Gespräch erkennen)'}\nAnsprechpartner Kunde: (aus Gespräch erkennen)\n"
f"Meeting-Titel: {title}\n\nGesprächsprotokoll:\n{transcript}")
def default_transcript(tenant_id: str) -> str:
return ROTARY_TRANSCRIPT if tenant_id.startswith("rotary") else SAMPLE_TRANSCRIPT
def prompt_test(db, tenant, cfg, files, transcript: str | None, terminart="", projekt="", kunde="") -> dict:
transcript = (transcript or "").strip() or default_transcript(tenant.id)
r = preview.run_content(db, tenant, cfg, files, "prompt",
args={"title": "Prompt-Test", "terminart": terminart, "projekt": projekt, "kunde": kunde})
out = llm.summarize(db, r["prompt"], user_content(transcript, terminart, projekt, kunde, "Prompt-Test"), tenant_id=tenant.id)
out["prompt_chars"] = r["chars"]
return out

110
app/agent/tools.py Normal file
View File

@@ -0,0 +1,110 @@
"""Werkzeuge des Agenten (neutrale Definition + Ausführung auf dem Workspace)."""
from __future__ import annotations
import io
import json
from app.agent.workspace import Workspace, is_personal
from app.services import llm, preview
from app.settings import PLATFORM_DIR
TOOLS = [
{"name": "list_files", "description": "Listet alle Dateien des Tenants (inkl. tenant.json) mit Größe und Änderungsstatus.",
"schema": {"type": "object", "properties": {}}},
{"name": "read_file", "description": "Liest eine Textdatei des Tenants (prompt.txt, protocol.html, protocol.css, overrides.py, tenant.json …). "
"Fehlt eine Datei, gilt der Core-Standard – nutze dann read_core_default.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "read_core_default", "description": "Liest eine Standarddatei des Cores als Vorlage: prompt.txt, protocol.html, protocol.css oder hooks.py (Hook-Dokumentation).",
"schema": {"type": "object", "properties": {"name": {"type": "string", "enum": ["prompt.txt", "protocol.html", "protocol.css", "hooks.py"]}}, "required": ["name"]}},
{"name": "write_file", "description": "Schreibt/ersetzt eine Textdatei des Tenants vollständig (z. B. um eine eigene prompt.txt oder protocol.css anzulegen). Nur im Entwurf – ein Mensch gibt frei.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Ersetzt in einer Datei genau eine Stelle: old_str muss exakt 1× vorkommen. Bevorzuge das gegenüber write_file bei kleinen Änderungen.",
"schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_str": {"type": "string"}, "new_str": {"type": "string"}}, "required": ["path", "old_str", "new_str"]}},
{"name": "delete_file", "description": "Löscht eine Tenant-Datei (z. B. um auf den Core-Standard zurückzufallen).",
"schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "set_config", "description": "Setzt einen Wert in tenant.json per Punkt-Pfad, z. B. key='colors.accent', value='#0B6E4F' oder key='flags.ui_kontakt_field', value=false.",
"schema": {"type": "object", "properties": {"key": {"type": "string"}, "value": {}}, "required": ["key", "value"]}},
{"name": "validate", "description": "Prüft den aktuellen Workspace mit dem ECHTEN Core (Web-UI + PDF rendern, Prompt laden). Gibt Fehler/Warnungen zurück. Nach Änderungen immer aufrufen.",
"schema": {"type": "object", "properties": {}}},
{"name": "render_pdf", "description": "Rendert das Beispielprotokoll als PDF und gibt Seitenzahl und extrahierten Text zurück, damit du Aufbau und Inhalt prüfen kannst.",
"schema": {"type": "object", "properties": {}}},
{"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.",
"schema": {"type": "object", "properties": {}}},
{"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.",
"schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}},
]
class ToolRunner:
def __init__(self, ws: Workspace):
self.ws, self.db, self.tenant = ws, ws.db, ws.tenant
self.llm_calls = 0
def _preview(self, kind, **kw):
return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw)
def run(self, name: str, a: dict) -> tuple[str, str]:
"""→ (Kurzbeschreibung des Arguments für die Anzeige, Ergebnistext)."""
try:
return getattr(self, f"t_{name}")(a)
except (KeyError, ValueError, PermissionError, SyntaxError, json.JSONDecodeError) as exc:
return a.get("path") or a.get("key") or "", f"FEHLER: {exc}"
except preview.PreviewError as exc:
return "", f"FEHLER (Core-Rendering): {str(exc)[-900:]}"
def t_list_files(self, a):
rows = [{"path": "tenant.json", "text": True}] + self.ws.list_files()
return "", json.dumps(rows, ensure_ascii=False)
def t_read_file(self, a):
return a["path"], self.ws.read(a["path"])
def t_read_core_default(self, a):
n = a["name"]
p = (PLATFORM_DIR / "core" / ("hooks.py" if n == "hooks.py" else ("defaults" if n == "prompt.txt" else "templates")) / n) \
if n != "hooks.py" else PLATFORM_DIR / "core" / "hooks.py"
return n, p.read_text(encoding="utf-8")[:60_000]
def t_write_file(self, a):
self.ws.write(a["path"], a["content"])
return a["path"], f"geschrieben ({len(a['content'])} Zeichen)"
def t_edit_file(self, a):
self.ws.edit(a["path"], a["old_str"], a["new_str"])
return a["path"], "ersetzt"
def t_delete_file(self, a):
self.ws.delete(a["path"])
return a["path"], "gelöscht"
def t_set_config(self, a):
self.ws.set_config_value(a["key"], a["value"])
return a["key"], f"gesetzt = {json.dumps(a['value'], ensure_ascii=False)}"
def t_validate(self, a):
r = self._preview("validate")
return "Core-Rendering", json.dumps(r, ensure_ascii=False)
def t_render_pdf(self, a):
pdf = self._preview("pdf")
from pypdf import PdfReader
rd = PdfReader(io.BytesIO(pdf))
text = "\n".join((pg.extract_text() or "") for pg in rd.pages)
return "Beispielprotokoll", f"{len(rd.pages)} Seite(n)\n---\n{text[:3500]}"
def t_show_final_prompt(self, a):
r = self._preview("prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
p = r["prompt"]
# Namenslisten (personenbezogen) nicht an das Agenten-Modell geben
marker = "Namensregister"
if marker in p:
p = p[:p.index(marker)] + "[Namensregister mit personenbezogenen Daten ausgeblendet]"
return f"{r['chars']} Zeichen", p[:40_000]
def t_run_prompt_test(self, a):
if self.llm_calls >= 1:
return "", "FEHLER: Pro Antwort ist nur ein Prompt-Test erlaubt."
self.llm_calls += 1
from app.agent.testrun import prompt_test
res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript"))
return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}"

106
app/agent/workspace.py Normal file
View File

@@ -0,0 +1,106 @@
"""Entwurfs-Workspace des Agenten: Kopie des Tenant-Entwurfs im Speicher.
Der Agent ändert NUR diese Kopie. Daraus entsteht ein Vorschlag (Diff), den der Mensch freigibt.
Datenschutz: Dateien mit personenbezogenem Inhalt (Namenslisten) sind für den Agenten nicht lesbar.
"""
from __future__ import annotations
import json
from copy import deepcopy
from sqlalchemy.orm import Session
from app.models import Tenant
from app.services import store
PERSONAL_PREFIXES = ("namensliste", "mitglieder", "members", "personen")
READ_LIMIT = 60_000
PROTECTED = {"tenant.json"}
def is_personal(path: str) -> bool:
name = path.rsplit("/", 1)[-1].lower()
return name.startswith(PERSONAL_PREFIXES)
class Workspace:
def __init__(self, db: Session, tenant: Tenant):
self.db, self.tenant = db, tenant
cfg, files = store.content(db, tenant, "draft")
self.base_cfg, self.base_files = deepcopy(cfg), dict(files)
self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha}
# ── Lesen ──
def list_files(self) -> list[dict]:
out = []
for p, sha in sorted(self.files.items()):
b = self.db.get(__import__("app.models", fromlist=["Blob"]).Blob, sha)
out.append({"path": p, "bytes": b.size, "text": store.is_text_path(p), "personal_data": is_personal(p),
"changed": self.base_files.get(p) != sha})
return out
def read(self, path: str) -> str:
if path in ("tenant.json",):
return json.dumps(self.cfg, indent=2, ensure_ascii=False)
if path not in self.files:
raise KeyError(f"Datei nicht im Tenant: {path}")
if is_personal(path):
n = len(store.get_blob(self.db, self.files[path]).decode("utf-8", "replace").splitlines())
return f"[Datei enthält personenbezogene Daten und ist für den Agenten gesperrt – {n} Zeilen. Inhalt nicht verfügbar.]"
if not store.is_text_path(path):
return "[Binärdatei]"
text = store.get_blob(self.db, self.files[path]).decode("utf-8", "replace")
return text[:READ_LIMIT] + ("\n[… gekürzt]" if len(text) > READ_LIMIT else "")
# ── Schreiben ──
def write(self, path: str, content: str) -> None:
path = store.clean_path(path)
if is_personal(path):
raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht ändern.")
if path.endswith(".py"):
compile(content, path, "exec") # Syntaxfehler früh melden
self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path)
def edit(self, path: str, old: str, new: str) -> None:
cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False)
if cur.count(old) != 1:
raise ValueError(f"old_str muss genau 1× vorkommen (gefunden: {cur.count(old)}×).")
if path == "tenant.json":
self.set_config_json(cur.replace(old, new))
else:
self.write(path, cur.replace(old, new))
def delete(self, path: str) -> None:
if is_personal(path):
raise PermissionError("Dateien mit personenbezogenen Daten darf der Agent nicht löschen.")
self.files.pop(path, None)
def set_config_json(self, text: str) -> None:
cfg = json.loads(text)
cfg.pop("deploy", None)
cfg["id"] = self.tenant.id
self.cfg = cfg
def set_config_value(self, key_path: str, value) -> None:
cur = self.cfg
keys = key_path.split(".")
for k in keys[:-1]:
cur = cur.setdefault(k, {})
cur[keys[-1]] = value
# ── Ergebnis ──
def changed(self) -> bool:
return self.cfg != self.base_cfg or self.files != self.base_files
def proposal(self, summary: str) -> dict | None:
if not self.changed():
return None
files = {p: s for p, s in self.files.items() if self.base_files.get(p) != s}
deleted = [p for p in self.base_files if p not in self.files]
items = []
if self.cfg != self.base_cfg:
items.append({"path": "tenant.json", "kind": "geändert"})
items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files]
items += [{"path": p, "kind": "gelöscht"} for p in deleted]
return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None,
"files": files, "deleted": deleted, "items": items}

162
app/auth.py Normal file
View File

@@ -0,0 +1,162 @@
"""OIDC-Login der Admin-Plattform über login.onyva.dev (Authorization-Code + PKCE, wie im Core).
Aktiv mit AUTH_ENABLED=true. Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI,
OIDC_LOGOUT_REDIRECT_URI, APP_COOKIE_SECURE, sowie die Zugriffsregel (mind. eine):
ADMIN_ALLOWED_EMAILS=a@x.de,b@y.de und/oder ADMIN_REQUIRED_ROLE=admin (gegen das Claim `app_roles`)
Ohne Zugriffsregel wird jeder Login abgelehnt (Default-Deny) – die Admin-Plattform hält alle Secrets.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.settings import AUTH_ENABLED, SECRET_KEY
COOKIE, STATE_COOKIE, STATE_TTL, SESSION_TTL = "oa_session", "oa_oauth", 600, 12 * 3600
router = APIRouter()
PUBLIC = re.compile(r"^/(healthz|auth/.*|static/.*|api/tenants/[^/]+/bundle|api/core/releases)$") # bundle: Token · releases: CI-Token (POST), GET prüft die Session selbst
DEV_USER = {"sub": "dev", "email": "dev@local", "name": "Dev (Auth aus)", "app_roles": ["admin"]}
def cfg() -> dict:
g = lambda k, d="": os.getenv(k, d).strip() # noqa: E731
return {
"issuer": g("OIDC_ISSUER", "https://login.onyva.dev").rstrip("/"), "client_id": g("OIDC_CLIENT_ID"),
"client_secret": g("OIDC_CLIENT_SECRET"), "redirect": g("OIDC_REDIRECT_URI", "http://localhost:8300/auth/callback"),
"logout_redirect": g("OIDC_LOGOUT_REDIRECT_URI", "http://localhost:8300/"),
"secure": g("APP_COOKIE_SECURE", "false").lower() == "true",
"emails": {e.strip().lower() for e in g("ADMIN_ALLOWED_EMAILS").split(",") if e.strip()},
"role": g("ADMIN_REQUIRED_ROLE"),
}
def _sign(data: dict) -> str:
payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode().rstrip("=")
return f"{payload}.{hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()}"
def _verify(token: str) -> dict | None:
try:
payload, _, sig = token.partition(".")
if not sig or not hmac.compare_digest(sig, hmac.new(SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()):
return None
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
return data if int(data.get("exp", 0)) > time.time() else None
except Exception: # noqa: BLE001
return None
def session_user(request: Request) -> dict | None:
if not AUTH_ENABLED:
return DEV_USER
tok = request.cookies.get(COOKIE)
return _verify(tok) if tok else None
def is_allowed(user: dict) -> bool:
c = cfg()
if not c["emails"] and not c["role"]:
return False
if user.get("email", "").lower() in c["emails"]:
return True
return bool(c["role"]) and c["role"] in (user.get("app_roles") or [])
async def middleware(request: Request, call_next):
user = session_user(request)
request.state.user = user or {}
if user is None and not PUBLIC.match(request.url.path):
if request.url.path.startswith(("/api/", "/preview/")):
return JSONResponse({"detail": "Nicht angemeldet"}, status_code=401)
return RedirectResponse("/auth/login", status_code=302)
return await call_next(request)
def actor_name(request: Request) -> str:
u = getattr(request.state, "user", None) or {}
return u.get("email") or u.get("name") or "unbekannt"
# ── Routen ──────────────────────────────────────────────────────────────────
def exchange_code(code: str, verifier: str) -> dict:
"""Token-Tausch beim IdP (in Tests ersetzbar)."""
c = cfg()
r = httpx.post(f"{c['issuer']}/oauth/token", timeout=20, data={
"grant_type": "authorization_code", "code": code, "redirect_uri": c["redirect"], "client_id": c["client_id"],
"client_secret": c["client_secret"], "code_verifier": verifier})
if r.status_code >= 400:
raise HTTPException(502, f"Token-Tausch fehlgeschlagen ({r.status_code})")
return r.json()
def _claims(id_token: str) -> dict:
try:
p = id_token.split(".")[1]
return json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)))
except Exception: # noqa: BLE001
return {}
@router.get("/auth/login")
def login():
c = cfg()
if not AUTH_ENABLED:
return RedirectResponse("/", 302)
if not c["client_id"] or not c["client_secret"]:
raise HTTPException(503, "OIDC nicht konfiguriert (OIDC_CLIENT_ID / OIDC_CLIENT_SECRET).")
state, verifier = secrets.token_urlsafe(24), secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
q = urlencode({"response_type": "code", "client_id": c["client_id"], "redirect_uri": c["redirect"], "scope": "openid profile email",
"state": state, "code_challenge": challenge, "code_challenge_method": "S256"})
resp = RedirectResponse(f"{c['issuer']}/oauth/authorize?{q}", 302)
resp.set_cookie(STATE_COOKIE, _sign({"state": state, "cv": verifier, "exp": int(time.time()) + STATE_TTL}),
max_age=STATE_TTL, httponly=True, secure=c["secure"], samesite="lax")
return resp
@router.get("/auth/callback")
def callback(request: Request, code: str = "", state: str = "", error: str = ""):
c = cfg()
if error:
raise HTTPException(400, f"Login abgebrochen: {error}")
pending = _verify(request.cookies.get(STATE_COOKIE, "") or "")
if not pending or not hmac.compare_digest(str(pending.get("state", "")), state) or not code:
return RedirectResponse("/auth/login", 303) # abgelaufen/Reload → neu starten
tokens = exchange_code(code, pending["cv"])
cl = _claims(tokens.get("id_token", ""))
user = {"sub": cl.get("sub", ""), "email": cl.get("email", ""), "name": cl.get("name", ""), "app_roles": cl.get("app_roles", [])}
if not is_allowed(user):
return HTMLResponse(f"<h2 style='font-family:sans-serif'>Kein Zugriff</h2><p style='font-family:sans-serif'>{user['email'] or 'Dieses Konto'} "
"ist für die Onyscribe-Admin-Plattform nicht freigeschaltet.</p><p><a href='/auth/logout'>Abmelden</a></p>", status_code=403)
resp = RedirectResponse("/", 303)
resp.set_cookie(COOKIE, _sign({**user, "exp": int(time.time()) + SESSION_TTL}), max_age=SESSION_TTL, httponly=True,
secure=c["secure"], samesite="lax")
resp.delete_cookie(STATE_COOKIE)
return resp
@router.get("/auth/logout")
def logout():
c = cfg()
url = f"{c['issuer']}/logout?{urlencode({'post_logout_redirect_uri': c['logout_redirect']})}" if AUTH_ENABLED else "/"
resp = RedirectResponse(url, 302)
resp.delete_cookie(COOKIE)
return resp
@router.get("/auth/me")
def me(request: Request):
return {"user": getattr(request.state, "user", {}), "auth_enabled": AUTH_ENABLED}

42
app/db.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.settings import DATABASE_URL
_kwargs = {"connect_args": {"check_same_thread": False}} if DATABASE_URL.startswith("sqlite") else {"pool_pre_ping": True}
engine = create_engine(DATABASE_URL, **_kwargs)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False, autoflush=True)
class Base(DeclarativeBase):
pass
def init_db() -> None:
from app import models # noqa: F401
Base.metadata.create_all(engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@contextmanager
def session():
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()

19
app/deps.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.auth import actor_name
from app.db import get_db
from app.models import Tenant
def actor(request: Request) -> str:
return actor_name(request)
def tenant_or_404(tenant_id: str, db: Session = Depends(get_db)) -> Tenant:
t = db.get(Tenant, tenant_id)
if t is None:
raise HTTPException(404, f"Tenant „{tenant_id}“ nicht gefunden")
return t

42
app/main.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.db import init_db, session
from app import auth
from app.routers import api, pages
from sqlalchemy import func, select
from app.models import Tenant
from app.services import seed
from app.services.importer import seed_settings
from app.settings import ROOT
SEED_FILE = ROOT / "seed" / "tenants.seed.json.gz"
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
with session() as db:
# Frische Instanz → mit den importierten Tenants starten (ohne Secret-Werte)
if SEED_FILE.exists() and not db.scalar(select(func.count()).select_from(Tenant)):
print(f"[seed] {seed.load(db, SEED_FILE)} Tenants aus {SEED_FILE.name} geladen")
seed_settings(db)
yield
app = FastAPI(title="Onyscribe Admin", lifespan=lifespan)
app.middleware("http")(auth.middleware)
app.include_router(auth.router)
app.mount("/static", StaticFiles(directory=str(ROOT / "app" / "static")), name="static")
app.include_router(api.router)
app.include_router(pages.router)
@app.get("/healthz")
def healthz():
return {"ok": True}

149
app/models.py Normal file
View File

@@ -0,0 +1,149 @@
"""Datenmodell. Inhalte sind content-addressed (Blob) und versioniert (Version)."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def now() -> datetime:
return datetime.now(timezone.utc)
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # Slug, == TENANT-Env im Container
name: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(20), default="active") # active | paused | archived
source: Mapped[str] = mapped_column(String(10), default="git") # TENANT_SOURCE im Container: git | admin
subdomain: Mapped[str] = mapped_column(String(100), default="")
custom_domain: Mapped[str] = mapped_column(String(200), default="")
domain_status: Mapped[str] = mapped_column(String(20), default="none") # none | pending | verified
cpu: Mapped[float] = mapped_column(Float, default=0.5)
memory: Mapped[str] = mapped_column(String(20), default="512M")
current_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
deployed_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
deployed_image: Mapped[str] = mapped_column(String(100), default="")
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
draft: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"config": {...}, "files": {path: sha}}
preview_sample: Mapped[str | None] = mapped_column(Text, nullable=True)
oidc: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Soll-Zustand des OIDC-Clients + letzter Abgleich
core_pin: Mapped[str | None] = mapped_column(String(100), nullable=True) # fester Core-Tag (nimmt an Rollouts nicht teil)
render_baseline: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {version, core, fp:{…}} Referenz für die Kompatibilitätsprüfung
notes: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
versions: Mapped[list["Version"]] = relationship(back_populates="tenant", order_by="Version.number.desc()")
class Blob(Base):
"""Dateiinhalt, adressiert über SHA-256. Später: große Binärdateien nach S3 auslagern."""
__tablename__ = "blobs"
sha: Mapped[str] = mapped_column(String(64), primary_key=True)
content: Mapped[bytes] = mapped_column(LargeBinary)
size: Mapped[int] = mapped_column(Integer)
is_text: Mapped[bool] = mapped_column(Boolean, default=True)
class Version(Base):
"""Unveränderlicher Snapshot eines Tenants."""
__tablename__ = "versions"
__table_args__ = (UniqueConstraint("tenant_id", "number"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
number: Mapped[int] = mapped_column(Integer)
message: Mapped[str] = mapped_column(String(500), default="")
author: Mapped[str] = mapped_column(String(100), default="") # "ruben" | "agent:<run>" | "import"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
config: Mapped[dict] = mapped_column(JSON) # Inhalt der tenant.json (ohne deploy-Block)
files: Mapped[dict] = mapped_column(JSON) # {pfad: sha}
tenant: Mapped[Tenant] = relationship(back_populates="versions")
class Secret(Base):
"""Verschlüsselter Secret-Wert. tenant_id NULL = geteilt für alle Tenants."""
__tablename__ = "secrets"
__table_args__ = (UniqueConstraint("tenant_id", "key"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
key: Mapped[str] = mapped_column(String(100))
value_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
source: Mapped[str] = mapped_column(String(30), default="manual") # manual | sops-import
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
class Deployment(Base):
__tablename__ = "deployments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(String(40), default="", index=True)
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
kind: Mapped[str] = mapped_column(String(20), default="deploy") # deploy | dry-run | offboard
version: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_tag: Mapped[str] = mapped_column(String(100), default="")
status: Mapped[str] = mapped_column(String(20), default="running") # running | success | failed
simulated: Mapped[bool] = mapped_column(Boolean, default=True)
log: Mapped[str] = mapped_column(Text, default="")
actor: Mapped[str] = mapped_column(String(100), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
actor: Mapped[str] = mapped_column(String(100), default="")
action: Mapped[str] = mapped_column(String(60))
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
detail: Mapped[str] = mapped_column(Text, default="")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), index=True)
role: Mapped[str] = mapped_column(String(20)) # user | assistant
content: Mapped[str] = mapped_column(Text, default="")
steps: Mapped[list | None] = mapped_column(JSON, nullable=True) # Tool-Aufrufe (Anzeige)
proposal: Mapped[dict | None] = mapped_column(JSON, nullable=True) # vorgeschlagene Änderung
proposal_status: Mapped[str] = mapped_column(String(20), default="") # "" | open | applied | rejected
usage: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {"in":…, "out":…, "backend":…, "model":…}
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
class CoreRelease(Base):
"""Vom Core-CI gemeldetes Release (Image-Tag). Wird geprüft, freigegeben und ausgerollt."""
__tablename__ = "core_releases"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sha: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tag: Mapped[str] = mapped_column(String(100))
ref: Mapped[str] = mapped_column(String(100), default="")
image: Mapped[str] = mapped_column(String(300), default="")
message: Mapped[str] = mapped_column(String(500), default="")
announced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
# new → checked → approved → rolling → active (rejected / failed / superseded)
status: Mapped[str] = mapped_column(String(20), default="new")
compat: Mapped[dict | None] = mapped_column(JSON, nullable=True) # {checked_at, core_sha, tenants:{id:{status,…}}, summary:{…}}
approved_by: Mapped[str] = mapped_column(String(100), default="")
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
previous_tag: Mapped[str] = mapped_column(String(100), default="") # aktiver Tag vor dem Rollout (für Rollback)
rollout_batch: Mapped[str] = mapped_column(String(40), default="")

0
app/routers/__init__.py Normal file
View File

770
app/routers/api.py Normal file
View File

@@ -0,0 +1,770 @@
from __future__ import annotations
import json
import re
import socket
import hmac
import os
from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Request, Response, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.agent import loop as agent_loop
from app.agent.testrun import default_transcript, prompt_test
from app.agent.workspace import is_personal
from app.db import get_db, session
from app.deps import actor, tenant_or_404
from app.models import Blob, ChatMessage, CoreRelease, Deployment, Secret, Setting, Tenant, Version
from app.services import contract, crypto, defaults, deploy, guard, llm, onyva_login, preflight, preview, releases, status as status_svc, store, tenants as tsvc
router = APIRouter(prefix="/api")
def _iso(d):
return d.isoformat() if d else None
# ── Tenants ─────────────────────────────────────────────────────────────────
class NewTenant(BaseModel):
id: str
name: str
clone_from: str | None = None
@router.post("/tenants")
def create_tenant(body: NewTenant, db: Session = Depends(get_db), who: str = Depends(actor)):
try:
t = tsvc.create(db, body.id, body.name, body.clone_from or None, who)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"id": t.id}
@router.get("/tenants/{tenant_id}/state")
def state(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
cfg, files = store.content(db, t, "draft")
meta = {p: {"size": db.get(Blob, sha).size, "text": store.is_text_path(p), "personal": is_personal(p)} for p, sha in files.items()}
return {
"id": t.id, "name": t.name, "status": t.status, "source": t.source, "notes": t.notes,
"has_draft": bool(t.draft), "current_version": t.current_version, "deployed_version": t.deployed_version,
"deployed_image": t.deployed_image, "config": cfg, "files": meta,
"host": deploy.host_for(db, t), "subdomain": t.subdomain, "custom_domain": t.custom_domain,
"domain_status": t.domain_status, "cpu": t.cpu, "memory": t.memory,
"base_domain": deploy.setting(db, "base_domain", "example.de"),
"color_labels": defaults.COLOR_LABELS, "core_version": preview.core_version(),
"sample": preview.sample_for(t), "has_custom_sample": bool(t.preview_sample),
"sample_transcript": default_transcript(t.id), "llm_ready": llm.configured(db, t.id),
"core": {"tag": deploy.effective_tag(db, t), "pin": t.core_pin, "sha": contract.sha()[:10], "contract_ok": contract.supported()},
"schema_problems": contract.check_tenant_json(cfg),
}
@router.put("/tenants/{tenant_id}/meta")
def put_meta(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
if "cpu" in body:
cpu = float(body["cpu"])
if not 0.1 <= cpu <= 8:
raise HTTPException(400, "CPU zwischen 0,1 und 8")
t.cpu = cpu
if "memory" in body:
if not re.fullmatch(r"\d{1,5}[MG]", str(body["memory"])):
raise HTTPException(400, "RAM z. B. 512M oder 1G")
t.memory = str(body["memory"])
if "status" in body:
if body["status"] not in ("active", "paused", "archived"):
raise HTTPException(400, "status: active | paused | archived")
t.status = body["status"]
if "notes" in body:
t.notes = str(body["notes"])[:5000]
store.audit(db, who, "tenant.meta", t.id, ", ".join(body))
db.commit()
return {"ok": True}
@router.delete("/tenants/{tenant_id}")
def delete_tenant(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Löscht nur die Datenbank-Einträge. Infrastruktur (Container/Ingress) vorher per Offboard entfernen."""
if t.status != "archived":
raise HTTPException(400, "Nur archivierte Tenants können gelöscht werden.")
if confirm != t.id:
raise HTTPException(400, "Bestätigung: ID des Tenants angeben.")
for model in (Secret, Version, ChatMessage):
for row in db.scalars(select(model).where(model.tenant_id == t.id)):
db.delete(row)
store.audit(db, who, "tenant.delete", t.id)
db.delete(t)
db.commit()
return {"ok": True}
@router.put("/tenants/{tenant_id}/config")
def put_config(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
store.set_config(db, t, body)
db.commit()
return {"has_draft": bool(t.draft)}
@router.get("/tenants/{tenant_id}/files/{path:path}")
def get_file(path: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
try:
_, files = preview.resolve_content(db, t, source)
except preview.SourceGone:
_, files = store.content(db, t, "draft")
if path not in files:
from app.settings import PLATFORM_DIR
for base in ("defaults", "templates"):
p = PLATFORM_DIR / "core" / base / path
if p.exists() and p.is_file():
return Response(p.read_bytes(), headers={"X-Default": "1"}, media_type="text/plain; charset=utf-8")
raise HTTPException(404, "Datei nicht vorhanden")
data = store.get_blob(db, files[path])
mt = "image/svg+xml" if path.endswith(".svg") else "image/png" if path.endswith(".png") else \
"application/pdf" if path.endswith(".pdf") else "text/plain; charset=utf-8"
return Response(data, media_type=mt)
@router.put("/tenants/{tenant_id}/files/{path:path}")
def put_file(path: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
try:
if path.endswith(".py"):
compile(body.get("content", ""), path, "exec")
store.set_file(db, t, path, body.get("content", "").encode("utf-8"))
except SyntaxError as exc:
raise HTTPException(400, f"Python-Syntaxfehler in Zeile {exc.lineno}: {exc.msg}")
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"has_draft": bool(t.draft)}
@router.post("/tenants/{tenant_id}/upload")
async def upload(file: UploadFile = File(...), path: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
data = await file.read()
if len(data) > 8 * 1024 * 1024:
raise HTTPException(413, "Datei größer als 8 MB")
try:
store.set_file(db, t, path or file.filename, data)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"ok": True}
@router.delete("/tenants/{tenant_id}/files/{path:path}")
def del_file(path: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
store.delete_file(db, t, path)
db.commit()
return {"has_draft": bool(t.draft)}
@router.put("/tenants/{tenant_id}/sample")
def put_sample(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
t.preview_sample = body.get("sample") or None
db.commit()
return {"ok": True}
@router.post("/tenants/{tenant_id}/draft/discard")
def discard(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
store.discard_draft(db, t)
store.audit(db, who, "draft.discard", t.id)
db.commit()
return {"ok": True}
@router.get("/tenants/{tenant_id}/diff")
def diff(a: str = "current", b: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return store.diff_summary(db, t, a, b, with_text=True)
class PublishBody(BaseModel):
message: str = ""
@router.post("/tenants/{tenant_id}/publish")
def publish(body: PublishBody, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
v = store.publish(db, t, body.message, who)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"version": v.number}
@router.post("/tenants/{tenant_id}/validate")
def validate(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "draft"):
try:
res = dict(preview.run(db, t, source, "validate"))
except (preview.PreviewError, preview.SourceGone) as exc:
return {"ok": False, "errors": [str(exc)], "warnings": []}
cfg, _ = preview.resolve_content(db, t, source)
res["warnings"] = [*res.get("warnings", []), *(f"Schema: {p}" for p in contract.check_tenant_json(cfg))] # unbekannte Optionen/Typfehler (Tippfehler)
return res
@router.get("/tenants/{tenant_id}/versions")
def versions(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
out = []
for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number.desc())):
out.append({"number": v.number, "message": v.message, "author": v.author, "created_at": _iso(v.created_at),
"files": len(v.files), "current": v.number == t.current_version, "deployed": v.number == t.deployed_version})
return out
@router.post("/tenants/{tenant_id}/versions/{number}/restore")
def restore(number: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
store.restore_as_draft(db, t, number)
except KeyError:
raise HTTPException(404, "Version nicht gefunden")
store.audit(db, who, "version.restore_draft", t.id, f"v{number}")
db.commit()
return {"has_draft": bool(t.draft)}
# ── Prompt-Test & LLM ───────────────────────────────────────────────────────
@router.post("/tenants/{tenant_id}/prompt-test")
def prompt_test_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Echter LLM-Lauf mit dem (Entwurfs-)Prompt auf einem Beispieltranskript. Kostet Cortecs-Guthaben."""
cfg, files = store.content(db, t, body.get("source", "draft"))
try:
r = prompt_test(db, t, cfg, files, body.get("transcript"), body.get("terminart", ""), body.get("projekt", ""), body.get("kunde", ""))
except llm.LLMNotConfigured as exc:
raise HTTPException(400, str(exc))
except preview.PreviewError as exc:
raise HTTPException(422, f"Core-Rendering fehlgeschlagen: {str(exc)[-600:]}")
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"LLM-Aufruf fehlgeschlagen: {type(exc).__name__}: {str(exc)[:300]}")
store.audit(db, who, "prompt.test", t.id, f"{r['model']} {r['input_tokens']}→{r['output_tokens']} Tokens")
db.commit()
return r
@router.get("/tenants/{tenant_id}/final-prompt")
def final_prompt(source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
"""Finaler System-Prompt, wie der Core ihn an das LLM schickt (Platzhalter ersetzt, Hooks angewandt)."""
try:
return preview.run(db, t, source, "prompt", args={"title": "Beispiel", "terminart": "", "projekt": "", "kunde": ""})
except preview.PreviewError as exc:
raise HTTPException(422, str(exc)[-800:])
@router.get("/llm/status")
def llm_status(tenant: str = "", db: Session = Depends(get_db)):
key, source, error = llm.key_info(db, tenant or None)
return {"configured": bool(key), "source": source, "error": error, "model": deploy.setting(db, "agent_model", "claude-4-6-sonnet"),
"api": deploy.setting(db, "agent_api", "messages"), "base_url": llm.base_url(db)}
@router.get("/llm/models")
def llm_models(db: Session = Depends(get_db)):
try:
return llm.list_models(db)
except llm.LLMNotConfigured as exc:
raise HTTPException(400, str(exc))
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Modellliste nicht abrufbar: {type(exc).__name__}: {str(exc)[:200]}")
# ── Domain, Secrets, Quelle ─────────────────────────────────────────────────
@router.put("/tenants/{tenant_id}/domain")
def put_domain(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
sub = (body.get("subdomain") or "").strip().lower()
custom = (body.get("custom_domain") or "").strip().lower()
if sub and not re.fullmatch(r"[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?", sub):
raise HTTPException(400, "Subdomain: a-z, 0-9, Bindestrich")
if custom and not re.fullmatch(r"([a-z0-9-]+\.)+[a-z]{2,}", custom):
raise HTTPException(400, "Eigene Domain ungültig")
t.subdomain = sub or t.subdomain
if custom != t.custom_domain:
t.custom_domain = custom
t.domain_status = "pending" if custom else ("verified" if t.source == "git" else "none")
store.audit(db, who, "domain.set", t.id, f"{t.subdomain} / {custom or '–'}")
db.commit()
return {"host": deploy.host_for(db, t), "domain_status": t.domain_status}
@router.post("/tenants/{tenant_id}/domain/check")
def check_domain(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
host = t.custom_domain or deploy.host_for(db, t)
try:
ips = sorted({ai[4][0] for ai in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)})
except OSError:
ips = []
if ips:
t.domain_status = "verified"
msg = f"{host} löst auf {', '.join(ips)} auf. (Die Ziel-Prüfung gegen das Mittwald-Projekt folgt mit dem Live-Deploy.)"
else:
t.domain_status = "pending" if t.custom_domain else t.domain_status
msg = f"{host} löst noch nicht auf. DNS-Einträge prüfen; Änderungen können bis zu 24 h dauern."
db.commit()
return {"status": t.domain_status, "message": msg}
# ── OIDC-Client (Developer-API von login.onyva.dev) ─────────────────────────
def _oidc_view(db: Session, t: Tenant) -> dict:
cfg = onyva_login.config_of(t)
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
sec = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_SECRET"))
base = deploy.setting(db, "base_domain", "example.de")
return {**cfg, "client_id": crypto.decrypt(cid.value_enc) if cid and cid.value_enc else None, "secret_set": bool(sec and sec.value_enc),
"redirect_uris": onyva_login.desired_redirects(base, t), "api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(),
"api_base": onyva_login.base_url(db)}
@router.get("/tenants/{tenant_id}/oidc")
def oidc_get(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return _oidc_view(db, t)
@router.put("/tenants/{tenant_id}/oidc")
def oidc_put(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
new = onyva_login.validate_config({**onyva_login.config_of(t), **body})
except ValueError as exc:
raise HTTPException(400, str(exc))
new["last_sync"] = onyva_login.config_of(t)["last_sync"]
t.oidc = new
store.audit(db, who, "oidc.config", t.id, f"mode={new['mode']} auth={new['auth_mode']} members={len(new['members'])}")
db.commit()
return _oidc_view(db, t)
@router.post("/tenants/{tenant_id}/oidc/sync")
def oidc_sync(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
"""apply=false: nur lesen und Plan zeigen. apply=true: schreiben (nur mit OIDC_SYNC_LIVE/DEPLOY_LIVE)."""
apply = bool(body.get("apply"))
if apply and not guard.live_allowed(t.id):
raise HTTPException(403, guard.denial(t.id))
if apply and not onyva_login.writes_allowed():
raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt (Env OIDC_SYNC_LIVE oder DEPLOY_LIVE ≠ true). Plan (Dry-Run) ist möglich.")
lines: list[str] = []
rep = onyva_login.sync_tenant(t.id, apply=apply, log=lines.append, rotate=bool(body.get("rotate")), actor=who)
return {**rep, "log": lines}
@router.post("/tenants/{tenant_id}/oidc/import-members")
def oidc_import_members(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Liest die aktuellen Freigaben aus dem Login und übernimmt sie als Soll-Liste (nur lesender API-Zugriff)."""
client = onyva_login.client_from_env(db)
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
if client is None:
raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.")
if not (cid and cid.value_enc):
raise HTTPException(400, "Tenant hat noch keine OIDC_CLIENT_ID.")
try:
ms = client.memberships(crypto.decrypt(cid.value_enc))
except onyva_login.OnyvaError as exc:
raise HTTPException(502, str(exc))
cfg = onyva_login.config_of(t)
cfg["members"] = [{"email": m["email"], "role": m.get("role", "viewer")} for m in ms]
t.oidc = {**cfg, "last_sync": cfg["last_sync"]}
store.audit(db, who, "oidc.import_members", t.id, f"{len(ms)} Freigaben")
db.commit()
return _oidc_view(db, t)
@router.delete("/tenants/{tenant_id}/oidc/app")
def oidc_delete_app(confirm: str = "", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
"""Löscht den OIDC-Client im Login (nicht umkehrbar, beendet alle Logins dieser App)."""
if confirm != t.id:
raise HTTPException(400, "Bestätigung: ID des Tenants angeben.")
if not onyva_login.writes_allowed():
raise HTTPException(403, "Schreibzugriffe auf das Login-System sind gesperrt.")
if not guard.live_allowed(t.id):
raise HTTPException(403, guard.denial(t.id))
client = onyva_login.client_from_env(db)
cid = db.scalar(select(Secret).where(Secret.tenant_id == t.id, Secret.key == "OIDC_CLIENT_ID"))
if client is None or not (cid and cid.value_enc):
raise HTTPException(400, "Kein API-Key oder keine Client-ID vorhanden.")
try:
client.delete_app(crypto.decrypt(cid.value_enc))
except onyva_login.OnyvaError as exc:
raise HTTPException(502, str(exc))
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"):
tsvc.set_secret(db, t.id, key, None, who)
store.audit(db, who, "oidc.app_deleted", t.id)
db.commit()
return _oidc_view(db, t)
@router.get("/oidc/status")
def oidc_status(db: Session = Depends(get_db)):
return {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}
@router.post("/oidc/test")
def oidc_test(db: Session = Depends(get_db)):
client = onyva_login.client_from_env(db)
if client is None:
raise HTTPException(400, "ONYVA_LOGIN_API_KEY ist nicht gesetzt.")
try:
apps = client.list_apps()
except onyva_login.OnyvaError as exc:
raise HTTPException(502, str(exc))
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Login-API nicht erreichbar: {type(exc).__name__}: {str(exc)[:200]}")
return {"ok": True, "apps": len(apps)}
@router.get("/tenants/{tenant_id}/secrets")
def secrets_list(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return tsvc.secret_rows(db, t.id)
@router.put("/tenants/{tenant_id}/secrets/{key}")
def secret_put(key: str, body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
value = body.get("value")
if body.get("generate"):
value = defaults.new_token()
try:
tsvc.set_secret(db, t.id, key, value, who)
except ValueError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"ok": True}
@router.put("/tenants/{tenant_id}/source")
def set_source(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
src = body.get("source")
if src not in ("git", "admin"):
raise HTTPException(400, "source: git | admin")
t.source = src
store.audit(db, who, "tenant.source", t.id, f"TENANT_SOURCE={src}")
db.commit()
return {"source": t.source}
@router.get("/tenants/{tenant_id}/cutover")
def cutover(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
try:
val = preview.run(db, t, "current", "validate")
except preview.PreviewError:
val = {"ok": False}
return {"checks": tsvc.cutover_checks(db, t, val), "source": t.source}
# ── Deploy ──────────────────────────────────────────────────────────────────
def _dep(d: Deployment):
return {"id": d.id, "batch": d.batch_id, "tenant": d.tenant_id, "kind": d.kind, "version": d.version,
"image": d.image_tag, "status": d.status, "simulated": d.simulated, "log": d.log, "actor": d.actor,
"created_at": _iso(d.created_at), "finished_at": _iso(d.finished_at)}
@router.get("/deploy/mode")
def deploy_mode(db: Session = Depends(get_db)):
return {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe(), "project": bool(deploy.setting(db, "mittwald_project_id")),
"core_bundle": deploy.setting(db, "core_supports_bundle", "false") == "true"}
@router.get("/tenants/{tenant_id}/preflight")
def tenant_preflight(t: Tenant = Depends(tenant_or_404)):
"""Vorflug-Check für einen Live-Deploy (liest nur, schreibt nichts)."""
return preflight.run(t.id)
@router.get("/tenants/{tenant_id}/plan")
def plan(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return deploy.plan(db, t)
def _kind(body: dict, base: str, tenant_id: str | None = None) -> str:
dry = bool(body.get("dry_run"))
if not dry and not deploy.live_enabled():
raise HTTPException(403, "Echte Deployments sind gesperrt (Env DEPLOY_LIVE != true). Nur Dry-Run möglich.")
if not dry and tenant_id and not guard.live_allowed(tenant_id):
raise HTTPException(403, guard.denial(tenant_id))
return f"{base}-dry" if base == "offboard" and dry else ("dry-run" if dry else base)
@router.post("/tenants/{tenant_id}/deploy")
def deploy_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
return {"id": deploy.start(t.id, _kind(body, "deploy", t.id), who)}
@router.post("/tenants/{tenant_id}/offboard")
def offboard_ep(body: dict = Body(default={}), t: Tenant = Depends(tenant_or_404), who: str = Depends(actor)):
return {"id": deploy.start(t.id, _kind(body, "offboard", t.id), who)}
@router.post("/deploy-all")
def deploy_all(body: dict = Body(default={}), who: str = Depends(actor)):
batch, ids = deploy.start_all(_kind(body, "deploy"), who)
return {"batch": batch, "ids": ids}
@router.get("/deployments/{dep_id}")
def deployment(dep_id: int, db: Session = Depends(get_db)):
d = db.get(Deployment, dep_id)
if d is None:
raise HTTPException(404)
return _dep(d)
@router.get("/deployments")
def deployments(batch: str = "", db: Session = Depends(get_db)):
q = select(Deployment).order_by(Deployment.id.desc()).limit(60)
if batch:
q = select(Deployment).where(Deployment.batch_id == batch).order_by(Deployment.id)
return [_dep(d) for d in db.scalars(q)]
# ── Core: Vertrag, Releases, Rollout, Zustand ───────────────────────────────
@router.get("/contract")
def contract_info():
return {"available": contract.available(), "supported": contract.supported(), "manifest": contract.manifest(), "schema": contract.schema(),
"supported_versions": sorted(contract.SUPPORTED_CONTRACT)}
def _require_session(request: Request) -> None:
if not getattr(request.state, "user", None):
raise HTTPException(401, "Nicht angemeldet")
@router.post("/core/releases")
def core_release_intake(request: Request, body: dict = Body(...), authorization: str = Header(default=""), db: Session = Depends(get_db)):
"""Release-Eingang für die Core-CI (Bearer CORE_RELEASE_TOKEN). Meldet nur an – ausgerollt wird erst nach Prüfung und Freigabe."""
expected = os.getenv("CORE_RELEASE_TOKEN", "").strip()
if not expected:
raise HTTPException(503, "Release-Eingang nicht konfiguriert (Env CORE_RELEASE_TOKEN fehlt).")
if not hmac.compare_digest(authorization.removeprefix("Bearer ").strip(), expected):
raise HTTPException(401, "Ungültiges Token")
try:
r, created = releases.announce(db, str(body.get("sha", "")), body.get("tag"), str(body.get("ref", "")), str(body.get("image", "")), str(body.get("message", "")))
except releases.ReleaseError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"id": r.id, "created": created, "status": r.status, "admin_matches": releases.admin_matches(r)}
@router.get("/core/releases")
def core_releases(request: Request, db: Session = Depends(get_db)):
_require_session(request)
active = deploy.setting(db, "core_image_tag", "latest")
rows = db.scalars(select(CoreRelease).order_by(CoreRelease.id.desc()).limit(40)).all()
return {"admin_core": {**contract.build_info(), "contract": contract.contract_version(), "supported": contract.supported(),
"schema_sha": contract.manifest().get("schema_sha256"), "fields": contract.manifest().get("fields"),
"env": contract.manifest().get("env")},
"active_tag": active, "canary": releases.canary_list(db), "live": deploy.live_enabled(),
"intake": bool(os.getenv("CORE_RELEASE_TOKEN", "").strip()), "releases": [releases.view(r, active) for r in rows]}
@router.post("/core/releases/{release_id}/check")
def core_check(release_id: int, body: dict = Body(default={})):
try:
releases.check(release_id, force=bool(body.get("force")))
except releases.ReleaseError as exc:
raise HTTPException(400, str(exc))
return {"started": True}
@router.post("/core/releases/{release_id}/approve")
def core_approve(release_id: int, body: dict = Body(default={}), db: Session = Depends(get_db), who: str = Depends(actor)):
try:
releases.approve(db, release_id, who, bool(body.get("acknowledge")))
except releases.ReleaseError as exc:
raise HTTPException(400, str(exc))
db.commit()
return {"ok": True}
@router.post("/core/releases/{release_id}/reject")
def core_reject(release_id: int, db: Session = Depends(get_db), who: str = Depends(actor)):
r = db.get(CoreRelease, release_id)
if r is None:
raise HTTPException(404)
r.status = "rejected"
store.audit(db, who, "core.reject", None, r.sha[:10])
db.commit()
return {"ok": True}
@router.post("/core/releases/{release_id}/rollout")
def core_rollout(release_id: int, body: dict = Body(default={}), who: str = Depends(actor)):
try:
batch, ids = releases.rollout(release_id, who, dry_run=bool(body.get("dry_run", True)))
except releases.ReleaseError as exc:
raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc))
return {"batch": batch, "ids": ids}
@router.post("/core/releases/{release_id}/rollback")
def core_rollback(release_id: int, who: str = Depends(actor)):
try:
batch, ids = releases.rollback_release(release_id, who)
except releases.ReleaseError as exc:
raise HTTPException(403 if "gesperrt" in str(exc) else 400, str(exc))
return {"batch": batch, "ids": ids}
@router.put("/tenants/{tenant_id}/core-pin")
def tenant_pin(body: dict = Body(...), t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
pin = (body.get("pin") or "").strip() or None
if pin and not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", pin):
raise HTTPException(400, "Tag: Buchstaben, Ziffern, . _ -")
t.core_pin = pin
store.audit(db, who, "tenant.core_pin", t.id, pin or "aufgehoben")
db.commit()
return {"pin": t.core_pin}
@router.post("/tenants/{tenant_id}/rollback")
def tenant_rollback(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
if not deploy.live_enabled():
raise HTTPException(403, "Echte Deployments sind gesperrt (DEPLOY_LIVE != true).")
if not guard.live_allowed(t.id):
raise HTTPException(403, guard.denial(t.id))
tag = releases.tenant_rollback_tag(db, t)
if not tag:
raise HTTPException(400, "Kein früherer, erfolgreich deployter Core-Tag bekannt.")
return {"id": deploy.start(t.id, "deploy", who, core_tag=tag), "tag": tag}
@router.get("/status")
def tenants_status(refresh: bool = False):
"""Was läuft wirklich? Fragt /health jedes aktiven Tenants ab (30 s Cache)."""
return status_svc.probe_all(refresh=refresh)
# ── Einstellungen ───────────────────────────────────────────────────────────
@router.get("/settings")
def get_settings(db: Session = Depends(get_db)):
return {s.key: s.value for s in db.scalars(select(Setting))}
@router.put("/settings")
def put_settings(body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)):
for k, v in body.items():
s = db.get(Setting, k)
if s is None:
s = Setting(key=k)
db.add(s)
s.value = str(v)
store.audit(db, who, "settings.update", None, ", ".join(body))
db.commit()
return {"ok": True}
@router.put("/shared-secrets/{key}")
def shared_secret(key: str, body: dict = Body(...), db: Session = Depends(get_db), who: str = Depends(actor)):
r = db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == key))
if r is None:
r = Secret(tenant_id=None, key=key)
db.add(r)
r.value_enc = crypto.encrypt(body["value"]) if body.get("value") else None
r.source = "manual"
store.audit(db, who, "shared-secret.set", None, key)
db.commit()
return {"ok": True}
# ── Agent ───────────────────────────────────────────────────────────────────
def _msg(m: ChatMessage):
return {"id": m.id, "role": m.role, "content": m.content, "steps": m.steps or [], "proposal": m.proposal,
"proposal_status": m.proposal_status, "usage": m.usage, "created_at": _iso(m.created_at)}
@router.get("/tenants/{tenant_id}/chat")
def chat_history(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return [_msg(m) for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id).order_by(ChatMessage.id))]
def _sse(ev: dict) -> str:
return f"data: {json.dumps(ev, ensure_ascii=False)}\n\n"
@router.post("/tenants/{tenant_id}/chat")
def chat(tenant_id: str, body: dict = Body(...), who: str = Depends(actor)):
"""Agent-Lauf als Server-Sent-Events: mode, step*, thought*, text, proposal, usage, done."""
text = (body.get("message") or "").strip()
if not text:
raise HTTPException(400, "Leere Nachricht")
def gen():
with session() as db:
t = db.get(Tenant, tenant_id)
steps, final, proposal, usage, mode, error = [], "", None, None, {}, None
for ev in agent_loop.run(db, t, text):
if ev["type"] == "step":
steps.append({k: ev[k] for k in ("tool", "arg", "result", "error")})
elif ev["type"] == "text":
final = ev["text"]
elif ev["type"] == "proposal":
proposal = ev["proposal"]
elif ev["type"] == "usage":
usage = ev["usage"]
elif ev["type"] == "mode":
mode = ev
elif ev["type"] == "error":
error = ev["text"]
yield _sse(ev if ev["type"] != "proposal" else {"type": "proposal", "summary": (proposal or {}).get("summary"),
"items": (proposal or {}).get("items", [])})
db.add(ChatMessage(tenant_id=tenant_id, role="user", content=text))
m = ChatMessage(tenant_id=tenant_id, role="assistant", content=error and f"⚠ {error}" or final, steps=steps, proposal=proposal,
proposal_status="open" if proposal else "",
usage={**(usage or {}), "backend": mode.get("backend"), "model": mode.get("model")})
db.add(m)
store.audit(db, who, "agent.run", tenant_id, f"{mode.get('backend')}/{mode.get('model')} {text[:80]}")
db.commit()
yield _sse({"type": "done", "message": _msg(m)})
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@router.get("/tenants/{tenant_id}/chat/{msg_id}/diff")
def chat_diff(msg_id: int, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
m = db.get(ChatMessage, msg_id)
if m is None or m.tenant_id != t.id or not m.proposal:
raise HTTPException(404)
return store.diff_content(db, store.content(db, t, "draft"), agent_loop.proposal_content(db, t, m.proposal), with_text=True)
@router.post("/tenants/{tenant_id}/chat/{msg_id}/{decision}")
def chat_decide(msg_id: int, decision: str, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), who: str = Depends(actor)):
m = db.get(ChatMessage, msg_id)
if m is None or m.tenant_id != t.id or m.proposal_status != "open":
raise HTTPException(400, "Kein offener Vorschlag")
if decision == "apply":
agent_loop.apply_proposal(db, t, m.proposal)
m.proposal_status = "applied"
store.audit(db, "agent", "agent.proposal.apply", t.id, f"freigegeben von {who}: {(m.proposal or {}).get('summary')}")
elif decision == "reject":
m.proposal_status = "rejected"
else:
raise HTTPException(400)
db.commit()
return _msg(m)
@router.delete("/tenants/{tenant_id}/chat")
def chat_clear(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
for m in db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == t.id)):
db.delete(m)
db.commit()
return {"ok": True}
# ── Bundle: genau das holt ein Tenant-Container beim Start ──────────────────
@router.get("/tenants/{tenant_id}/bundle")
def bundle(tenant_id: str, authorization: str = Header(default=""), db: Session = Depends(get_db)):
token = authorization.removeprefix("Bearer ").strip()
if not tsvc.check_bundle_token(db, tenant_id, token):
raise HTTPException(401, "Ungültiges Bundle-Token")
t = db.get(Tenant, tenant_id)
data, manifest = store.bundle_tar(db, t, "current")
return Response(data, media_type="application/gzip", headers={"X-Bundle-Version": str(t.current_version), "ETag": f'"{t.current_version}"'})

107
app/routers/pages.py Normal file
View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import HTMLResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.deps import tenant_or_404
from app.models import AuditLog, Deployment, Secret, Setting, Tenant
from app.services import deploy, guard, onyva_login, preview, store
from app.services.importer import DEFAULT_SETTINGS
from app.templating import templates
router = APIRouter()
def _logo(db, t: Tenant, source="current") -> str | None:
_, files = store.content(db, t, source)
for name in ("logo.svg", "logo.png"):
if name in files:
return f"/api/tenants/{t.id}/files/{name}?source={source}"
return None
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, db: Session = Depends(get_db)):
rows = []
for t in db.scalars(select(Tenant).order_by(Tenant.id)):
cfg, files = store.content(db, t, "current")
missing = db.scalar(select(func.count()).select_from(Secret).where(Secret.tenant_id == t.id, Secret.value_enc.is_(None))) or 0
rows.append({
"t": t, "cfg": cfg, "accent": (cfg.get("colors") or {}).get("accent", "#327F24"),
"logo": _logo(db, t), "host": deploy.host_for(db, t), "outdated": t.deployed_version != t.current_version,
"missing_secrets": missing, "stt": (cfg.get("stt") or {}).get("provider", "mistral"),
"has_code": "overrides.py" in files, "custom_prompt": "prompt.txt" in files,
"custom_pdf": "protocol.html" in files or "protocol.css" in files,
})
stats = {
"tenants": len(rows), "admin": sum(1 for r in rows if r["t"].source == "admin"),
"drafts": sum(1 for r in rows if r["t"].draft), "outdated": sum(1 for r in rows if r["outdated"]),
"secrets": sum(1 for r in rows if r["missing_secrets"]),
}
last = db.scalars(select(Deployment).order_by(Deployment.id.desc()).limit(5)).all()
return templates.TemplateResponse(request, "dashboard.html", {
"rows": rows, "stats": stats, "last": last, "core": preview.core_version(),
"tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "dashboard"})
@router.get("/tenants/{tenant_id}", response_class=HTMLResponse)
def tenant_page(request: Request, t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
return templates.TemplateResponse(request, "tenant.html", {"t": t, "nav": "dashboard", "logo": _logo(db, t, "draft")})
@router.get("/tenants/{tenant_id}/bundle.tar.gz")
def bundle_download(t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db), source: str = "current"):
data, _ = store.bundle_tar(db, t, source)
return Response(data, media_type="application/gzip",
headers={"Content-Disposition": f'attachment; filename="{t.id}-v{t.current_version}.tar.gz"'})
@router.get("/preview/{tenant_id}/{kind}")
def preview_view(kind: str, source: str = "draft", t: Tenant = Depends(tenant_or_404), db: Session = Depends(get_db)):
if kind not in ("web", "pdf"):
raise HTTPException(404)
try:
data = preview.run(db, t, source, "index" if kind == "web" else "pdf")
except (preview.PreviewError, preview.SourceGone) as exc:
return Response(str(exc), status_code=422, media_type="text/plain; charset=utf-8")
if kind == "pdf":
return Response(data, media_type="application/pdf", headers={"Content-Disposition": "inline"})
html = data.decode("utf-8")
# PNG-Logos referenziert der Core über /brand/logo → auf die Admin-Datei umbiegen
html = html.replace('src="/brand/logo"', f'src="/api/tenants/{t.id}/files/logo.png?source={source}"')
html = html.replace("</head>", "<base target='_blank'></head>", 1)
return HTMLResponse(html)
@router.get("/deployments", response_class=HTMLResponse)
def deployments_page(request: Request, db: Session = Depends(get_db)):
tenants = db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)).all()
return templates.TemplateResponse(request, "deployments.html", {
"tenants": tenants, "tag": deploy.setting(db, "core_image_tag", "latest"), "nav": "deployments"})
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
values = {k: deploy.setting(db, k, "") for k in DEFAULT_SETTINGS}
shared = {s.key: bool(s.value_enc) for s in db.scalars(select(Secret).where(Secret.tenant_id.is_(None)))}
from app.services import llm
return templates.TemplateResponse(request, "settings.html", {
"v": values, "shared": shared, "core": preview.core_version(), "nav": "settings",
"llm_ready": llm.configured(db), "mode": {"live": deploy.live_enabled(), "token": deploy.token_present(), "allowed": guard.describe()},
"oidc": {"api_key": onyva_login.key_present(), "writes_allowed": onyva_login.writes_allowed(), "api_base": onyva_login.base_url(db)}})
@router.get("/audit", response_class=HTMLResponse)
def audit_page(request: Request, db: Session = Depends(get_db)):
rows = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(200)).all()
return templates.TemplateResponse(request, "audit.html", {"rows": rows, "nav": "audit"})
@router.get("/core", response_class=HTMLResponse)
def core_page(request: Request):
return templates.TemplateResponse(request, "core.html", {"nav": "core"})

0
app/services/__init__.py Normal file
View File

125
app/services/contract.py Normal file
View File

@@ -0,0 +1,125 @@
"""Vertrag mit dem Core (siehe transkript-platform/docs/CONTRACT.md): Schema, Manifest, Build-Info, Env-Spezifikation.
Gelesen wird aus dem Core, auf dem die Admin-Plattform läuft (PLATFORM_DIR: im Container das Core-Image, lokal der vendored Stand).
`core/contract.py` ist import-sicher (nur Standardbibliothek) und wird per Dateipfad geladen – der Core-Code selbst wird NIE
in diesem Prozess importiert (er hält Prozess-Singletons); gerendert wird in der Sandbox.
Ältere Cores ohne Vertrag („legacy“) funktionieren weiter: Schema fehlt → feste Admin-Formulare und feste Env-Liste.
"""
from __future__ import annotations
import importlib.util
import json
from functools import lru_cache
from pathlib import Path
from app.settings import PLATFORM_DIR
SUPPORTED_CONTRACT = {1}
# Rückfall für Cores ohne Schema (entspricht dem Stand vor dem Vertrag)
LEGACY_SHARED_ENV = {
"OIDC_ISSUER": "oidc_issuer", "AUTH_ENABLED": "auth_enabled", "APP_COOKIE_SECURE": "app_cookie_secure",
"MAX_FILE_SIZE_MB": "max_file_size_mb", "LLM_MODEL": "llm_model", "LLM_MAX_TOKENS": "llm_max_tokens",
"LLM_TEMPERATURE": "llm_temperature", "CORTECS_BASE_URL": "cortecs_base_url", "SMTP_HOST": "smtp_host",
"SMTP_PORT": "smtp_port", "SMTP_USER": "smtp_user", "SMTP_FROM": "smtp_from",
"MISTRAL_TRANSCRIBE_MODEL": "mistral_transcribe_model", "MISTRAL_DIARIZE": "mistral_diarize",
"MISTRAL_TIMEOUT": "mistral_timeout", "COST_API_BASE": "cost_api_base", "COST_TRACKING_ENABLED": "cost_tracking_enabled",
}
LEGACY_REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET"]
@lru_cache(maxsize=1)
def _module():
path = Path(PLATFORM_DIR) / "core" / "contract.py"
if not path.exists():
return None
spec = importlib.util.spec_from_file_location("onyscribe_core_contract", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def reload() -> None:
_module.cache_clear()
schema.cache_clear()
def available() -> bool:
return _module() is not None
@lru_cache(maxsize=1)
def schema() -> dict | None:
m = _module()
return m.load_schema() if m else None
def build_info() -> dict:
m = _module()
if m:
return m.build_info()
f = Path(PLATFORM_DIR) / "CORE_VERSION"
return {"sha": f.read_text().split()[0] if f.exists() else "unbekannt", "built_at": None, "source": "legacy"}
def manifest() -> dict:
m = _module()
if m:
return m.build_manifest()
return {"contract": 0, "core": build_info(), "legacy": True}
def contract_version() -> int:
return int(manifest().get("contract", 0))
def supported() -> bool:
return (not available()) or contract_version() in SUPPORTED_CONTRACT
def sha() -> str:
return build_info()["sha"]
def same_sha(a: str, b: str) -> bool:
a, b = (a or "").lower(), (b or "").lower()
return bool(a and b) and (a.startswith(b) or b.startswith(a)) and min(len(a), len(b)) >= 7
def check_tenant_json(raw: dict) -> list[str]:
m = _module()
return m.check_tenant_json(raw, schema()) if m else []
def field_paths() -> set[str]:
s = schema()
return {f["path"] for f in s["fields"]} if s else set()
def env_spec() -> list[dict]:
s = schema()
if s:
return s["env"]
return [{"name": n, "kind": "setting", "scope": "shared", "setting": k, "default": None, "label": n, "group": "Allgemein"} for n, k in LEGACY_SHARED_ENV.items()]
def required_secrets(tenant_cfg: dict | None = None) -> list[str]:
"""Pflicht-Secrets laut Schema (required, required_if wie „stt.provider!=cortecs“ gegen die Tenant-Config ausgewertet)."""
s = schema()
if not s:
return list(LEGACY_REQUIRED_SECRETS)
out = []
for e in s["env"]:
if e["kind"] != "secret":
continue
if e.get("required"):
out.append(e["name"])
elif e.get("required_if"):
path, _, rhs = e["required_if"].partition("!=")
cur = tenant_cfg or {}
for k in path.split("."):
cur = cur.get(k, {}) if isinstance(cur, dict) else {}
val = cur if isinstance(cur, str) else ""
if (val or "mistral") != rhs: # Provider-Standard ist mistral
out.append(e["name"])
return out

15
app/services/crypto.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from cryptography.fernet import Fernet
from app.settings import SECRET_KEY
_f = Fernet(SECRET_KEY.encode())
def encrypt(v: str) -> str:
return _f.encrypt(v.encode()).decode()
def decrypt(v: str) -> str:
return _f.decrypt(v.encode()).decode()

44
app/services/defaults.py Normal file
View File

@@ -0,0 +1,44 @@
import json
import secrets
# Standardfarben des Cores (core/config.py DEFAULT_COLORS) – Basis für neue Tenants
DEFAULT_COLORS = {
"accent": "#327F24", "accent_dark": "#235A19", "accent_mid": "#3E9130", "accent_light": "#EAF4E7",
"accent_xlight": "#F3FAF1", "bg": "#F1F5F0", "surface": "#FFFFFF", "surface2": "#F6FAF5",
"border": "#DCE8D9", "border2": "#BDD1B8", "text": "#444444", "text_2": "#666666", "text_3": "#9AA89A",
"red": "#DC2626", "progress_to": "#8bbf43",
}
COLOR_LABELS = {
"accent": "Akzent", "accent_dark": "Akzent dunkel", "accent_mid": "Akzent mittel", "accent_light": "Akzent hell",
"accent_xlight": "Akzent extra hell", "bg": "Seitenhintergrund", "surface": "Karten", "surface2": "Karten 2",
"border": "Rahmen", "border2": "Rahmen 2", "text": "Text", "text_2": "Text sekundär", "text_3": "Text tertiär",
"red": "Rot (Hinweis)", "progress_to": "Fortschritt (Verlauf-Ende)",
}
# Secret-Schlüssel = Env-Namen im Container (siehe infra/deploy.py)
REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY"]
OPTIONAL_SECRETS = ["MISTRAL_API_KEY"]
SECRET_HELP = {
"OIDC_CLIENT_ID": "Client-ID aus login.onyva.dev (manuell angelegt)",
"OIDC_CLIENT_SECRET": "Client-Secret aus login.onyva.dev",
"SESSION_SECRET": "Signiert Session-Cookies – kann hier erzeugt werden",
"COST_INGEST_KEY": "Schlüssel für die Kostenerfassung (Cost-API)",
"MISTRAL_API_KEY": "Nur nötig bei STT-Provider „mistral“ (bei „cortecs“ wird der geteilte Key genutzt)",
"ADMIN_BUNDLE_TOKEN": "Token, mit dem der Container sein Bundle bei der Admin-Plattform abholt",
}
def new_token() -> str:
return secrets.token_urlsafe(48)
def skeleton_config(tenant_id: str, name: str) -> dict:
return {
"id": tenant_id, "name": name, "title": f"{name} – Gesprächsprotokoll",
"description": f"Gesprächsprotokoll-Tool für {name}", "ai_detect_org": name,
"org_short": name, "org_profile": "",
"flags": {}, "stt": {"provider": "cortecs", "model": "voxtral-mini-transcribe-2602"},
"colors": dict(DEFAULT_COLORS),
"office": {"name": name, "subtitle": "", "address": "", "tel": "", "email": "", "website": ""},
}

314
app/services/deploy.py Normal file
View File

@@ -0,0 +1,314 @@
"""Deploy-Orchestrierung: Plan (wie infra/deploy.py), Ausführung über den Mittwald-Client.
Sicherheit: Ohne `DEPLOY_LIVE=true` (Env der Admin-Plattform) ist JEDE schreibende Operation gesperrt – es gibt nur Dry-Runs.
Außerdem blockiert der Core-Kompatibilitäts-Schalter (`core_supports_bundle`) Tenants mit Quelle „Admin“,
solange der Core das Bundle noch nicht laden kann.
"""
from __future__ import annotations
import os
import threading
import time
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import session
from app.models import Deployment, Secret, Setting, Tenant
from app.services import contract, crypto, guard, onyva_login, store
from app.services.mittwald import DeployError, Mittwald, mask_env
PORT = "8276"
def live_enabled() -> bool:
return os.getenv("DEPLOY_LIVE", "false").lower() == "true"
def token_present() -> bool:
return bool(os.getenv("MITTWALD_API_TOKEN", "").strip())
def setting(db, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value != "" else default
def host_for(db, t: Tenant) -> str:
if t.custom_domain and t.domain_status == "verified":
return t.custom_domain
return f"{t.subdomain or t.id}.{setting(db, 'base_domain', 'example.de')}"
def effective_tag(db, t: Tenant | None = None, override: str | None = None) -> str:
"""Welcher Core-Tag gilt? Override (Rollout) > Pin des Tenants > freigegebener Standard-Tag."""
return override or (t.core_pin if t is not None and t.core_pin else None) or setting(db, "core_image_tag", "latest")
def image_ref(db, t: Tenant | None = None, tag: str | None = None) -> str:
registry = setting(db, "registry", "").rstrip("/")
name = setting(db, "image_name", "transkript-platform")
base = f"{registry}/{name}" if registry else name
return f"{base.lower()}:{effective_tag(db, t, tag)}"
def build_env(db, t: Tenant, reveal: bool) -> dict[str, str]:
"""Container-Env, abgeleitet aus dem Env-Schema des Cores (kind/scope) – neue Core-Variablen erscheinen ohne Admin-Änderung.
reveal=False → Secrets als Platzhalter."""
host = host_for(db, t)
env = {"TENANT": t.id, "DATA_DIR": "/data", "PORT": PORT,
"OIDC_REDIRECT_URI": f"https://{host}/auth/callback", "OIDC_LOGOUT_REDIRECT_URI": f"https://{host}/"}
spec = contract.env_spec()
for e in spec: # geteilte, nicht geheime Einstellungen
if e["kind"] == "setting" and e["scope"] == "shared":
v = setting(db, e.get("setting") or e["name"].lower())
if v:
env[e["name"]] = v
shared = db.scalars(select(Secret).where(Secret.tenant_id.is_(None))).all()
own = db.scalars(select(Secret).where(Secret.tenant_id == t.id)).all()
for s_ in [*shared, *own]: # Tenant-Werte überschreiben geteilte; auch Secrets ohne Schema-Eintrag werden durchgereicht
if s_.key == "ADMIN_BUNDLE_TOKEN" and t.source != "admin":
continue
if s_.value_enc:
env[s_.key] = crypto.decrypt(s_.value_enc) if reveal else "•••• gesetzt"
elif not reveal:
env[s_.key] = "⚠ fehlt (Wert noch nicht in der Admin-DB)"
if t.source == "admin":
env["TENANT_SOURCE"] = "admin"
env["ADMIN_URL"] = setting(db, "admin_public_url", "https://<admin-host>")
return env
def missing_secrets(db, t: Tenant) -> list[str]:
"""Pflicht-Secrets laut Schema, die weder beim Tenant noch geteilt einen Wert haben."""
cfg, _ = store.content(db, t, "current")
have = {s.key for s in db.scalars(select(Secret).where((Secret.tenant_id == t.id) | Secret.tenant_id.is_(None))) if s.value_enc}
need = set(contract.required_secrets(cfg))
if t.source == "admin":
need.add("ADMIN_BUNDLE_TOKEN")
return sorted(need - have)
def plan(db, t: Tenant) -> dict:
cfg, _ = store.content(db, t, "current")
return {
"service": f"app-{t.id}", "volume": f"vol-{t.id}",
"patch": {"description": cfg.get("name") or f"Onyscribe {t.id}", "image": image_ref(db, t),
"environment": build_env(db, t, reveal=False), "ports": [f"{PORT}:{PORT}/tcp"], "volumes": [f"vol-{t.id}:/data"]},
"ingress": {"host": host_for(db, t), "target": f"app-{t.id}:{PORT}"},
"missing_secrets": missing_secrets(db, t),
"oidc": {"mode": onyva_login.config_of(t)["mode"], "key": onyva_login.key_present(), "writes": onyva_login.writes_allowed(),
"last_sync": onyva_login.config_of(t)["last_sync"]},
"mode": {"live": live_enabled(), "token": token_present(), "tenant_allowed": guard.live_allowed(t.id), "allowed": guard.describe(), "project": bool(setting(db, "mittwald_project_id")),
"core_bundle": setting(db, "core_supports_bundle", "false") == "true"},
"core": {"tag": effective_tag(db, t), "pinned": bool(t.core_pin), "admin_core": contract.sha()[:10], "contract": contract.contract_version()},
}
# ── Ausführung ──────────────────────────────────────────────────────────────
def _append(dep_id: int, line: str) -> None:
with session() as db:
db.get(Deployment, dep_id).log += f"{datetime.now(timezone.utc).strftime('%H:%M:%S')} {line}\n"
def _finish(dep_id: int, status: str) -> None:
with session() as db:
d = db.get(Deployment, dep_id)
d.status, d.finished_at = status, datetime.now(timezone.utc)
def _client(live: bool, log, http=None) -> Mittwald:
return Mittwald(os.getenv("MITTWALD_API_TOKEN", "").strip(), os.getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), live, log, http)
def _hex_tag(tag: str) -> bool:
return bool(tag) and all(c in "0123456789abcdef" for c in tag.lower()) and len(tag) >= 7
def verify_health(host: str, expect_sha: str | None, expect_bundle: int | None, log, *, get=None, tries: int = 18, delay: float = 5.0,
sleep=time.sleep) -> None:
"""Prüft nach dem Deploy, ob wirklich läuft, was wir ausgerollt haben (Vertrag: /health.core / .bundle).
Wirft DeployError, wenn der Container nach `tries` Versuchen nicht gesund ist oder die falsche Version meldet."""
if get is None:
import httpx
def get(url):
r = httpx.get(url, timeout=8, follow_redirects=True)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
last = "keine Antwort"
for i in range(tries):
try:
status, body = get(f"https://{host}/health")
problems = []
if status != 200 or body.get("status") != "ok":
problems.append(f"HTTP {status}")
core = body.get("core")
if expect_sha and _hex_tag(expect_sha):
if core is None:
log("⚠ /health meldet keine Core-Version (Image vor dem Vertrag) – Version nicht prüfbar")
elif not contract.same_sha(core.get("version", ""), expect_sha):
problems.append(f"Core {str(core.get('version'))[:10]} statt {expect_sha[:10]}")
if expect_bundle is not None and body.get("bundle"):
b = body["bundle"]
if b.get("source") != "admin" or b.get("version") != expect_bundle or b.get("stale"):
problems.append(f"Bundle {b.get('source')} v{b.get('version')}{' (veraltet)' if b.get('stale') else ''} statt admin v{expect_bundle}"
+ (f" – {b.get('error')}" if b.get("error") else ""))
if not problems:
log(f"Health-Check https://{host}/health ✔" + (f" · Core {core['version'][:10]}" if core else "")
+ (f" · Bundle v{body['bundle']['version']}" if body.get("bundle", {}).get("source") == "admin" else ""))
return
last = "; ".join(problems)
except Exception as exc: # noqa: BLE001
last = f"{type(exc).__name__}: {exc}"
if i < tries - 1:
sleep(delay)
raise DeployError(f"Health-Check fehlgeschlagen nach {tries} Versuchen: {last}")
def execute(dep_id: int, tenant_id: str, kind: str, http=None, oidc_client=None, core_tag: str | None = None, health=None) -> bool:
"""Führt einen Deployment-Datensatz aus. kind: dry-run | deploy | offboard-dry | offboard.
core_tag überschreibt den Core-Tag (Rollout/Rollback); `health(host, expect_sha, expect_bundle, log)` ist in Tests ersetzbar."""
log = lambda m: _append(dep_id, m) # noqa: E731
dry = kind in ("dry-run", "offboard-dry")
live = live_enabled() and not dry
try:
with session() as db:
t = db.get(Tenant, tenant_id)
ver = t.current_version
project, stack = setting(db, "mittwald_project_id"), setting(db, "mittwald_stack_id")
host = host_for(db, t) if setting(db, "base_domain") else None
source, bundle_ok = t.source, setting(db, "core_supports_bundle", "false") == "true"
image, tag = image_ref(db, t, core_tag), effective_tag(db, t, core_tag)
pinned = bool(t.core_pin) and not core_tag
log(f"▶ {kind} „{tenant_id}“ · Version v{ver} · {image}{' (gepinnt)' if pinned else ''} · Modus: {'LIVE' if live else 'Dry-Run (keine Schreibzugriffe)'}")
if not contract.supported():
raise DeployError(f"Core-Vertrag {contract.contract_version()} wird von dieser Admin-Version nicht unterstützt – Admin aktualisieren.")
if source == "admin" and not bundle_ok and not dry:
raise DeployError("Quelle „Admin“ ist noch gesperrt: Der Core kann das Bundle noch nicht laden (Einstellung core_supports_bundle).")
if not dry and not live_enabled():
raise DeployError("Echte Deployments sind gesperrt (DEPLOY_LIVE != true).")
if not dry and not guard.live_allowed(tenant_id):
raise DeployError(guard.denial(tenant_id))
if live and source == "admin":
with session() as db:
url = setting(db, "admin_public_url")
if not url.startswith("https://") or "<" in url:
raise DeployError("Einstellung „Öffentliche URL der Admin-Plattform“ (https://…) fehlt – der Container könnte sein Bundle nicht holen.")
if not kind.startswith("offboard"):
# 1) OIDC-Client abgleichen (legt bei Bedarf an) – VOR dem Service, weil dessen Env die Client-Daten braucht
rep = onyva_login.sync_tenant(tenant_id, apply=live, log=log, client=oidc_client, actor="deploy")
if live and not rep["ok"]:
raise DeployError(f"OIDC-Abgleich fehlgeschlagen: {rep['error']}")
with session() as db:
t = db.get(Tenant, tenant_id)
if live:
lacking = missing_secrets(db, t)
if lacking:
raise DeployError("Pflicht-Secrets ohne Wert: " + ", ".join(lacking) + " (Tenant → Secrets bzw. Domain & Login, geteilte unter Einstellungen).")
definition = {"description": plan(db, t)["patch"]["description"], "image": image, "ports": [f"{PORT}:{PORT}/tcp"],
"volumes": [f"vol-{t.id}:/data"], "environment": build_env(db, t, reveal=live)}
mw = _client(live, log, http)
sid = mw.resolve_stack_id(project, stack)
if kind.startswith("offboard"):
mw.offboard_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", project_id=project, hostname=host)
log("Hinweis: Der OIDC-Client bleibt im Login bestehen (Tenant → Übersicht → Gefahrenbereich: „OIDC-Client im Login löschen“).")
else:
mw.deploy_service(stack_id=sid, service=f"app-{tenant_id}", volume=f"vol-{tenant_id}", definition=definition,
container_port=PORT, project_id=project, hostname=host)
if live and not kind.startswith("offboard") and host:
(health or verify_health)(host, tag, ver if source == "admin" else None, log)
if not dry:
with session() as db:
t = db.get(Tenant, tenant_id)
if kind == "deploy":
t.deployed_version, t.deployed_image, t.deployed_at = ver, tag, datetime.now(timezone.utc)
store.audit(db, "system", f"{kind}.live", tenant_id, f"v{ver} :{tag}")
log("✔ Fertig" if live else "Dry-Run beendet – es wurde nichts geschrieben.")
return True
except DeployError as exc:
log(f"✘ {exc}")
return False
except Exception as exc: # noqa: BLE001
log(f"✘ Unerwarteter Fehler: {type(exc).__name__}: {exc}")
return False
def start(tenant_id: str, kind: str, actor: str, batch_id: str = "", http=None, wait: bool = False, oidc_client=None,
core_tag: str | None = None, health=None) -> int:
with session() as db:
t = db.get(Tenant, tenant_id)
d = Deployment(tenant_id=tenant_id, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag),
actor=actor, batch_id=batch_id, simulated=not (live_enabled() and kind in ("deploy", "offboard")))
db.add(d)
db.flush()
dep_id = d.id
def work():
ok = execute(dep_id, tenant_id, kind, http, oidc_client, core_tag, health)
_finish(dep_id, "success" if ok else "failed")
th = threading.Thread(target=work, daemon=True)
th.start()
if wait:
th.join()
return dep_id
def run_sequence(tenant_ids: list[str], kind: str, actor: str, *, batch: str, core_tag: str | None = None, http=None, oidc_client=None,
health=None, canary: list[str] | None = None, wait: bool = False, on_done=None) -> list[int]:
"""Tenants nacheinander (zuerst die Canary-Tenants), Abbruch beim ersten Fehler – der Rest wird „cancelled“.
on_done(ok, results) wird nach dem Lauf aufgerufen (z. B. um ein Release abzuschließen)."""
if kind in ("deploy", "offboard") and live_enabled(): # Live: nur freigegebene Tenants (siehe guard.py)
skipped = [t for t in tenant_ids if not guard.live_allowed(t)]
tenant_ids = [t for t in tenant_ids if guard.live_allowed(t)]
if skipped:
with session() as db:
store.audit(db, actor, "deploy.skipped_not_allowed", None, ", ".join(skipped))
canary = [c for c in (canary or []) if c in tenant_ids]
order = canary + [t for t in tenant_ids if t not in canary]
with session() as db:
rows = []
for tid in order:
t = db.get(Tenant, tid)
d = Deployment(tenant_id=tid, kind=kind, version=t.current_version, image_tag=effective_tag(db, t, core_tag), actor=actor,
batch_id=batch, status="queued", simulated=not (live_enabled() and kind == "deploy"))
db.add(d)
rows.append(d)
db.flush()
dep_ids = [d.id for d in rows]
def seq():
failed, results = False, {}
for tid, did in zip(order, dep_ids):
if failed:
_append(did, "übersprungen – vorheriger Tenant fehlgeschlagen (Rollout abgebrochen)")
_finish(did, "cancelled")
results[tid] = "cancelled"
continue
with session() as db:
db.get(Deployment, did).status = "running"
ok = execute(did, tid, kind, http, oidc_client, core_tag, health)
_finish(did, "success" if ok else "failed")
results[tid] = "success" if ok else "failed"
failed = not ok
if tid in canary and ok and tid == canary[-1]:
_append(did, "Canary erfolgreich – restliche Tenants folgen")
if on_done:
on_done(not failed, results)
th = threading.Thread(target=seq, daemon=True)
th.start()
if wait:
th.join()
return dep_ids
def start_all(kind: str, actor: str, http=None, wait: bool = False, oidc_client=None, core_tag: str | None = None, health=None) -> tuple[str, list[int]]:
"""Alle aktiven, nicht gepinnten Tenants nacheinander; beim ersten Fehler Abbruch."""
batch = uuid.uuid4().hex[:10]
with session() as db:
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not (core_tag and t.core_pin)]
return batch, run_sequence(ids, kind, actor, batch=batch, core_tag=core_tag, http=http, oidc_client=oidc_client, health=health, wait=wait)

35
app/services/guard.py Normal file
View File

@@ -0,0 +1,35 @@
"""Schreib-Freigabe für externe Systeme (Mittwald, Login-API) – pro Tenant.
`DEPLOY_ALLOWED_TENANTS` (Env der Admin-Plattform) legt fest, welche Tenants live angefasst werden dürfen:
(leer/nicht gesetzt) keiner – auch mit DEPLOY_LIVE=true wird nichts geschrieben
demo-admin,demo2 nur diese
* alle
Greift bei Live-Deploy, Offboard, Rollout/Rollback und beim Schreiben im Login-System (OIDC-Abgleich/Löschen).
Dry-Runs und Lesezugriffe sind nie betroffen. Damit lässt sich die Plattform schrittweise und gefahrlos parallel zum Altbetrieb einführen.
"""
from __future__ import annotations
import os
def allowed_tenants() -> set[str] | None:
"""None = alle, sonst die erlaubten IDs (leere Menge = keiner)."""
raw = os.getenv("DEPLOY_ALLOWED_TENANTS", "").strip()
if raw == "*":
return None
return {x.strip() for x in raw.split(",") if x.strip()}
def live_allowed(tenant_id: str) -> bool:
a = allowed_tenants()
return a is None or tenant_id in a
def describe() -> str:
a = allowed_tenants()
return "alle Tenants" if a is None else (", ".join(sorted(a)) if a else "keiner")
def denial(tenant_id: str) -> str:
return (f"Tenant „{tenant_id}“ ist für Live-Schreibzugriffe nicht freigegeben (Env DEPLOY_ALLOWED_TENANTS = {describe()}). "
"Dry-Runs sind weiterhin möglich.")

151
app/services/importer.py Normal file
View File

@@ -0,0 +1,151 @@
"""Importiert die Tenants der bestehenden transkript-platform (Git-Ordner) in die Datenbank.
Idempotent: ein Tenant, dessen Inhalt bereits identisch als aktuelle Version existiert,
wird übersprungen. Secrets werden standardmäßig NUR als Schlüsselnamen importiert
(Wert „extern/SOPS“); mit with_secrets=True werden sie via `sops -d` entschlüsselt
und verschlüsselt in der DB abgelegt.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import yaml
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Deployment, Secret, Setting, Tenant, Version
from app.services import crypto, store
from app.services.samples import DEFAULT_SAMPLE, ROTARY_SAMPLE
IGNORE = {"secrets.enc.yaml", "tenant.json"}
IGNORE_DIRS = {"__pycache__"}
# Bisherige Gitea-„Variables“ (docs/DEPLOYMENT.md) → geteilte Einstellungen
DEFAULT_SETTINGS = {
"base_domain": "onyscribe.de",
"mittwald_project_id": "",
"mittwald_stack_id": "",
"registry": "",
"image_name": "transkript-platform",
"oidc_issuer": "https://login.onyva.dev",
"onyva_login_api_base": "",
"auth_enabled": "true",
"app_cookie_secure": "true",
"max_file_size_mb": "300",
"cortecs_base_url": "https://api.cortecs.ai/v1",
"llm_model": "claude-4-6-sonnet",
"llm_max_tokens": "16000",
"llm_temperature": "0.2",
"smtp_host": "",
"smtp_port": "587",
"smtp_user": "",
"smtp_from": "Gesprächsprotokoll",
"mistral_transcribe_model": "voxtral-mini-latest",
"mistral_diarize": "true",
"mistral_timeout": "600",
"core_image_tag": "latest",
"core_supports_bundle": "false",
"canary_tenants": "demo",
"admin_public_url": "",
"cost_api_base": "",
"cost_tracking_enabled": "",
"agent_model": "claude-4-6-sonnet",
"agent_api": "messages",
}
def seed_settings(db: Session) -> None:
for k, v in DEFAULT_SETTINGS.items():
if db.get(Setting, k) is None:
db.add(Setting(key=k, value=v))
def _sops_keys(path: Path) -> list[str]:
if not path.exists():
return []
data = yaml.safe_load(path.read_text()) or {}
return [k for k in data if k != "sops"]
def _sops_decrypt(path: Path) -> dict[str, str]:
out = subprocess.run(["sops", "-d", "--output-type", "json", str(path)], capture_output=True, text=True, check=True)
return {k: str(v) for k, v in json.loads(out.stdout).items()}
def _upsert_secrets(db: Session, tenant_id: str | None, path: Path, with_secrets: bool) -> int:
keys = _sops_keys(path)
values = _sops_decrypt(path) if (with_secrets and keys) else {}
n = 0
for key in keys:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
if row is None:
row = Secret(tenant_id=tenant_id, key=key)
db.add(row)
if key in values and values[key]:
row.value_enc = crypto.encrypt(values[key])
row.source = "manual"
elif row.value_enc is None:
row.source = "sops-import"
n += 1
return n
def import_all(db: Session, root: Path, with_secrets: bool = False, force: bool = False) -> list[str]:
log: list[str] = []
seed_settings(db)
tenants_dir = root / "tenants"
src_rev = ""
try:
src_rev = subprocess.run(["git", "-C", str(root), "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
except Exception:
pass
n = _upsert_secrets(db, None, root / "secrets" / "shared.enc.yaml", with_secrets)
log.append(f"geteilte Secrets: {n} Schlüssel")
for tdir in sorted(p for p in tenants_dir.iterdir() if p.is_dir()):
tid = tdir.name
cfg_file = tdir / "tenant.json"
if not cfg_file.exists():
continue
raw = json.loads(cfg_file.read_text(encoding="utf-8"))
deploy = raw.pop("deploy", {}) or {}
files: dict[str, bytes] = {}
for p in sorted(tdir.rglob("*")):
if p.is_dir() or p.name in IGNORE or any(part in IGNORE_DIRS for part in p.relative_to(tdir).parts):
continue
files[p.relative_to(tdir).as_posix()] = p.read_bytes()
t = db.get(Tenant, tid)
if t is None:
res = deploy.get("resources") or {}
t = Tenant(
id=tid, name=raw.get("name", tid), subdomain=deploy.get("subdomain", tid),
cpu=float(res.get("cpu", 0.5)), memory=str(res.get("memory", "512M")),
domain_status="verified", source="git", deployed_image="git-baked",
preview_sample=ROTARY_SAMPLE if tid.startswith("rotary") else None,
# Bestehende Clients wurden von Hand angelegt → nur „adoptieren“: fehlende Redirect-URI ergänzen, sonst nichts anfassen
oidc={"mode": "adopt"},
)
db.add(t)
db.flush()
shas = {p: store.put_blob(db, data, p) for p, data in files.items()}
cur = store.current_version(db, t)
same = cur is not None and cur.files == shas and json.dumps(cur.config, sort_keys=True) == json.dumps(raw, sort_keys=True)
if same and not force:
log.append(f"{tid}: unverändert (v{t.current_version})")
else:
number = (t.current_version or 0) + 1
db.add(Version(tenant_id=tid, number=number, message=f"Import aus transkript-platform @{src_rev}",
author="import", config=raw, files=shas))
t.current_version = number
if t.deployed_version is None:
t.deployed_version = number
store.audit(db, "import", "tenant.import", tid, f"v{number} aus {src_rev}")
log.append(f"{tid}: importiert als v{number} ({len(files)} Dateien)")
k = _upsert_secrets(db, tid, tdir / "secrets.enc.yaml", with_secrets)
log.append(f" Secrets: {k} Schlüssel")
db.flush()
return log

121
app/services/llm.py Normal file
View File

@@ -0,0 +1,121 @@
"""Zugang zu Cortecs (OpenAI-kompatibel + Anthropic-Messages) für Agent und Prompt-Test.
Der Key kommt aus dem geteilten Secret CORTECS_API_KEY (verschlüsselt in der DB) oder der Env CORTECS_API_KEY.
"""
from __future__ import annotations
import os
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Secret, Setting
from app.services import crypto
DEFAULT_BASE = "https://api.cortecs.ai/v1"
class LLMNotConfigured(RuntimeError):
pass
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def key_info(db: Session, tenant_id: str | None = None) -> tuple[str, str, str | None]:
"""(key, quelle, fehler). Reihenfolge: geteiltes Secret (DB) → Env CORTECS_API_KEY → Key des Tenants (Rückfall).
Quelle: 'shared' | 'env' | 'tenant' | 'none'. fehler: z. B. nicht entschlüsselbar (SECRET_KEY geändert?)."""
error = None
rows = [("shared", db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == "CORTECS_API_KEY")))]
for source, row in rows:
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), source, None
except Exception: # noqa: BLE001 – falscher SECRET_KEY o. Ä.
error = "Geteilter CORTECS_API_KEY in der DB ist nicht entschlüsselbar (SECRET_KEY geändert?) – bitte neu setzen."
env = os.getenv("CORTECS_API_KEY", "").strip()
if env:
return env, "env", error
if tenant_id:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "CORTECS_API_KEY"))
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), "tenant", error
except Exception: # noqa: BLE001
error = error or "CORTECS_API_KEY dieses Tenants ist nicht entschlüsselbar."
return "", "none", error
def api_key(db: Session, tenant_id: str | None = None) -> str:
return key_info(db, tenant_id)[0]
def base_url(db: Session) -> str:
return _setting(db, "cortecs_base_url", DEFAULT_BASE).rstrip("/")
def configured(db: Session, tenant_id: str | None = None) -> bool:
return bool(api_key(db, tenant_id))
def require_key(db: Session, tenant_id: str | None = None) -> str:
k, _, err = key_info(db, tenant_id)
if not k:
raise LLMNotConfigured(err or "CORTECS_API_KEY fehlt – unter Einstellungen → Geteilte Secrets setzen (oder Env CORTECS_API_KEY).")
return k
def list_models(db: Session) -> list[dict]:
"""GET /v1/models – Rohdaten von Cortecs, auf die für die Auswahl nötigen Felder reduziert."""
key = require_key(db)
r = httpx.get(f"{base_url(db)}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30)
r.raise_for_status()
data = r.json()
items = data.get("data") if isinstance(data, dict) else data
out = []
for m in items or []:
if isinstance(m, str):
m = {"id": m}
caps = {k: v for k, v in m.items() if isinstance(v, (bool, list, dict)) or k in ("context_size", "context_length")}
out.append({"id": m.get("id") or m.get("name"), "raw": caps,
"tools": _supports_tools(m)})
return sorted((o for o in out if o["id"]), key=lambda o: o["id"])
def _supports_tools(m: dict) -> bool | None:
"""Cortecs dokumentiert die Feldnamen nicht eindeutig → mehrere plausible Varianten prüfen; None = unbekannt."""
flat = {}
def walk(prefix, v):
if isinstance(v, dict):
for k, x in v.items():
walk(f"{prefix}.{k}".lower(), x)
elif isinstance(v, list):
flat[prefix] = [str(i).lower() for i in v]
else:
flat[prefix] = v
walk("", m)
for k, v in flat.items():
if "tool" in k or "function" in k:
return bool(v)
if isinstance(v, list) and any("tool" in i or "function" in i for i in v):
return True
return None
def summarize(db: Session, system_prompt: str, user_content: str, model: str | None = None, tenant_id: str | None = None) -> dict:
"""Ein Protokoll-Lauf wie im Core (Chat Completions), für den Prompt-Test."""
from openai import OpenAI
client = OpenAI(api_key=require_key(db, tenant_id), base_url=base_url(db), max_retries=2, timeout=300)
model = model or _setting(db, "llm_model", "claude-4-6-sonnet")
resp = client.chat.completions.create(
model=model, temperature=float(_setting(db, "llm_temperature", "0.2")),
max_tokens=int(_setting(db, "llm_max_tokens", "16000")), stream=False,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}],
)
text = "\n".join(c.message.content for c in resp.choices if c.message and c.message.content).strip()
u = getattr(resp, "usage", None)
return {"text": text, "model": model, "input_tokens": getattr(u, "prompt_tokens", 0), "output_tokens": getattr(u, "completion_tokens", 0),
"truncated": any(getattr(c, "finish_reason", "") in ("length", "max_tokens") for c in resp.choices)}

159
app/services/mittwald.py Normal file
View File

@@ -0,0 +1,159 @@
"""Mittwald-API-Client (Port der Logik aus transkript-platform/infra/deploy.py).
Sicherheitsnetze wie dort: differenzielles PATCH (nie PUT), Namensraum `app-`, Post-Write-Guard.
Zusätzlich: `live=False` (Standard) blockiert JEDEN schreibenden Aufruf – es wird nur geloggt, was passieren würde.
Lesende Aufrufe laufen nur, wenn ein Token gesetzt ist.
"""
from __future__ import annotations
import json
from typing import Callable
import httpx
class DeployError(RuntimeError):
pass
class DryRunSkip(Exception):
pass
def mask_env(env: dict) -> dict:
import re
return {k: ("••••" if re.search(r"(SECRET|PASSWORD|KEY|TOKEN)$", k) else v) for k, v in env.items()}
class Mittwald:
def __init__(self, token: str, base: str, live: bool, log: Callable[[str], None], http: Callable | None = None):
self.token, self.base, self.live, self.log = token, base.rstrip("/"), live, log
self._http = http or self._real_http
self._project_uuid: str | None = None
@staticmethod
def _real_http(method, url, headers, body):
r = httpx.request(method, url, headers=headers, json=body, timeout=60)
try:
data = r.json() if r.content else None
except ValueError:
data = None
return r.status_code, data, r.text[:500]
def api(self, method: str, path: str, body: dict | None = None, *, write: bool, fatal: bool = True):
if write and not self.live:
self.log(f"[DRY-RUN] {method} {path}")
if body is not None:
shown = json.loads(json.dumps(body))
for svc in (shown.get("services") or {}).values():
if isinstance(svc, dict) and "environment" in svc:
svc["environment"] = mask_env(svc["environment"])
self.log(" body: " + json.dumps(shown, ensure_ascii=False)[:1500])
raise DryRunSkip()
if not self.token:
if not self.live:
self.log(f"(übersprungen: lesender Aufruf {method} {path} – kein MITTWALD_API_TOKEN)")
raise DryRunSkip()
raise DeployError("MITTWALD_API_TOKEN nicht gesetzt.")
status, data, text = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}, body)
if status >= 400:
msg = f"{method} {path} → HTTP {status}: {text}"
if fatal:
raise DeployError(msg)
self.log(f"WARNUNG (nicht fatal): {msg}")
return None
return data
def try_api(self, *a, **kw):
try:
return self.api(*a, **kw)
except DryRunSkip:
return None
# ── Stack ──
def resolve_stack_id(self, project_id: str, stack_id: str) -> str:
if stack_id:
return stack_id
if not project_id:
if not self.live:
self.log("(Hinweis: Mittwald PROJECT_ID/STACK_ID sind noch nicht in den Einstellungen gesetzt – Platzhalter im Dry-Run)")
return "<STACK_ID>"
raise DeployError("Mittwald PROJECT_ID (oder STACK_ID) fehlt in den Einstellungen.")
try:
stacks = self.api("GET", f"/projects/{project_id}/stacks/", write=False)
except DryRunSkip:
return "<STACK_ID>"
items = stacks if isinstance(stacks, list) else (stacks or {}).get("items", [])
for s in items:
if str(s.get("description") or s.get("name") or "").lower() == "default":
return s["id"]
if items:
return items[0]["id"]
raise DeployError("Kein Stack im Projekt gefunden.")
def get_stack(self, stack_id: str) -> dict | None:
return self.try_api("GET", f"/stacks/{stack_id}/", write=False)
@staticmethod
def services_of(stack: dict | None) -> dict[str, dict]:
if not stack:
return {}
svcs = stack.get("services")
if isinstance(svcs, dict):
return svcs
if isinstance(svcs, list):
return {(s.get("serviceName") or s.get("name") or s.get("id")): s for s in svcs if (s.get("serviceName") or s.get("name") or s.get("id"))}
return {}
def project_uuid(self, project_id: str) -> str:
if self._project_uuid:
return self._project_uuid
proj = self.try_api("GET", f"/projects/{project_id}/", write=False, fatal=False)
self._project_uuid = (proj or {}).get("id") or project_id
return self._project_uuid
# ── Operationen ──
def deploy_service(self, *, stack_id: str, service: str, volume: str, definition: dict, container_port: str,
project_id: str, hostname: str | None) -> None:
stack = self.get_stack(stack_id)
existing = set(self.services_of(stack))
protected = existing - {service}
if protected:
self.log(f"geschützte bestehende Services (bleiben unangetastet): {sorted(protected)}")
self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {"name": volume}}}, write=True)
self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: definition}}, write=True)
svc_id = "<service-id>"
if self.live:
after = self.services_of(self.get_stack(stack_id))
missing = protected - set(after)
if missing:
raise DeployError(f"ABBRUCH: bestehende Services verschwunden nach PATCH: {sorted(missing)}")
svc_id = after.get(service, {}).get("id", service)
self.try_api("POST", f"/stacks/{stack_id}/services/{svc_id}/actions/recreate/", write=True)
self.log("Post-Write-Guard: alle vorherigen Services noch vorhanden ✔")
if hostname:
self.ensure_ingress(project_id, hostname, svc_id, container_port)
def ensure_ingress(self, project_id: str, hostname: str, container_id: str, port: str) -> None:
listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False)
items = listing if isinstance(listing, list) else (listing or {}).get("items", [])
if any(i.get("hostname") == hostname for i in items):
self.log(f"Ingress {hostname} existiert bereits → ok")
return
body = {"hostname": hostname, "projectId": self.project_uuid(project_id),
"paths": [{"path": "/", "target": {"container": {"id": container_id, "portProtocol": f"{port}/tcp"}}}]}
res = self.try_api("POST", "/ingresses", body, write=True, fatal=False)
if self.live:
self.log(f"Ingress {hostname} angelegt (Auto-TLS)" if res is not None else
f"⚠ Ingress {hostname} NICHT angelegt – Token braucht Domain/Ingress-Recht (Service läuft trotzdem).")
def offboard_service(self, *, stack_id: str, service: str, volume: str, project_id: str, hostname: str | None) -> None:
self.try_api("PATCH", f"/stacks/{stack_id}/", {"services": {service: {}}}, write=True)
self.try_api("PATCH", f"/stacks/{stack_id}/", {"volumes": {volume: {}}}, write=True)
if hostname:
listing = self.try_api("GET", f"/projects/{project_id}/ingresses/", write=False, fatal=False)
items = listing if isinstance(listing, list) else (listing or {}).get("items", [])
for i in items:
if i.get("hostname") == hostname:
self.try_api("DELETE", f"/ingresses/{i['id']}", write=True, fatal=False)
self.log(f"Ingress {hostname} entfernt")

312
app/services/onyva_login.py Normal file
View File

@@ -0,0 +1,312 @@
"""Abgleich der OIDC-Clients mit der Developer-API von login.onyva.dev (`/api/v1`, siehe LoginSeite/docs/developer-api.md).
Pro Tenant gibt es einen Soll-Zustand (`tenant.oidc`). Bei jedem Deploy wird der Ist-Zustand gelesen und bei Bedarf angelegt/angepasst:
mode = off nichts anfassen
mode = adopt (Standard für importierte Altclients) nur sicherstellen, dass die Redirect-URIs des Tenants enthalten sind
mode = managed Redirect-URIs, auth_mode, totp_mode, allow_registration, backchannel_logout_uri exakt wie konfiguriert
Freigaben (Memberships): `members` werden angelegt/aktualisiert; mit members_mode = exact werden zusätzliche Freigaben entfernt.
Sicherheit: Lesen (GET) läuft immer, wenn ein Key da ist. SCHREIBEN nur mit apply=True – und apply erlaubt die Plattform nur,
wenn OIDC_SYNC_LIVE=true oder DEPLOY_LIVE=true. Das `client_secret` ist nur bei Anlage/Rotation sichtbar und wird SOFORT verschlüsselt gespeichert.
"""
from __future__ import annotations
import os
import re
import time
from datetime import datetime, timezone
from typing import Callable
import httpx
from sqlalchemy import select
from app.db import session
from app.models import Secret, Setting, Tenant
from app.services import crypto, guard, store
MODES = ("off", "adopt", "managed")
AUTH_MODES = ("required", "optional", "disabled")
TOTP_MODES = ("disabled", "optional", "required")
ROLES = ("viewer", "editor", "admin")
DEFAULTS = {"mode": "adopt", "auth_mode": "required", "totp_mode": "disabled", "allow_registration": False,
"backchannel_logout_uri": None, "members": [], "members_mode": "add", "last_sync": None}
class OnyvaError(RuntimeError):
pass
def key_present() -> bool:
return bool(os.getenv("ONYVA_LOGIN_API_KEY", "").strip())
def writes_allowed() -> bool:
return os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true"
def base_url(db) -> str:
s = db.get(Setting, "onyva_login_api_base")
if s and s.value:
return s.value.rstrip("/")
issuer = db.get(Setting, "oidc_issuer")
return f"{(issuer.value if issuer and issuer.value else 'https://login.onyva.dev').rstrip('/')}/api/v1"
class OnyvaLogin:
"""Dünner Client für /api/v1/apps. `http(method, url, headers, body) -> (status, data, headers)` ist in Tests ersetzbar."""
def __init__(self, base: str, key: str, http: Callable | None = None, sleep: Callable[[float], None] = time.sleep):
self.base, self.key, self._http, self._sleep = base.rstrip("/"), key, http or self._real, sleep
@staticmethod
def _real(method, url, headers, body):
r = httpx.request(method, url, headers=headers, json=body, timeout=30)
try:
data = r.json() if r.content else None
except ValueError:
data = None
return r.status_code, data, dict(r.headers)
def _req(self, method: str, path: str, body: dict | None = None):
for attempt in range(4):
status, data, headers = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}, body)
if status == 429 and attempt < 3: # Rate-Limit: 120 Requests / 60 s pro Key
self._sleep(min(int({k.lower(): v for k, v in headers.items()}.get("retry-after", 5)), 60))
continue
if status == 401:
raise OnyvaError("API-Key ungültig, widerrufen oder abgelaufen (401).")
return status, data
raise OnyvaError("Rate-Limit des Login-Systems (429) dauerhaft überschritten.")
def _ok(self, method, path, body=None, ok=(200, 201, 204)):
status, data = self._req(method, path, body)
if status not in ok:
detail = data.get("detail") if isinstance(data, dict) else data
raise OnyvaError(f"{method} {path} → HTTP {status}: {str(detail)[:300]}")
return data
def list_apps(self) -> list[dict]:
return (self._ok("GET", "/apps") or {}).get("apps", [])
def get_app(self, cid: str) -> dict | None:
status, data = self._req("GET", f"/apps/{cid}")
if status == 404:
return None
if status != 200:
raise OnyvaError(f"GET /apps/{cid} → HTTP {status}")
return data
def create_app(self, body: dict) -> dict:
return self._ok("POST", "/apps", body, ok=(201, 200))
def patch_app(self, cid: str, body: dict) -> dict:
return self._ok("PATCH", f"/apps/{cid}", body)
def delete_app(self, cid: str) -> None:
self._ok("DELETE", f"/apps/{cid}", ok=(204, 200))
def rotate_secret(self, cid: str) -> dict:
return self._ok("POST", f"/apps/{cid}/rotate-secret")
def memberships(self, cid: str) -> list[dict]:
return (self._ok("GET", f"/apps/{cid}/memberships") or {}).get("memberships", [])
def put_membership(self, cid: str, email: str, role: str) -> bool:
status, _ = self._req("PUT", f"/apps/{cid}/memberships", {"user_email": email, "role": role})
if status == 404:
return False # Nutzer existiert im Login nicht
if status not in (200, 201):
raise OnyvaError(f"PUT memberships → HTTP {status}")
return True
def delete_membership(self, cid: str, email: str) -> None:
self._req("DELETE", f"/apps/{cid}/memberships/{email}")
def client_from_env(db, http=None) -> OnyvaLogin | None:
key = os.getenv("ONYVA_LOGIN_API_KEY", "").strip()
return OnyvaLogin(base_url(db), key, http) if key else None
# ── Soll-Zustand ────────────────────────────────────────────────────────────
def config_of(t: Tenant) -> dict:
return {**DEFAULTS, **(t.oidc or {})}
def validate_config(cfg: dict) -> dict:
out = {**DEFAULTS, **cfg}
if out["mode"] not in MODES:
raise ValueError("mode: off | adopt | managed")
if out["auth_mode"] not in AUTH_MODES:
raise ValueError("auth_mode: required | optional | disabled")
if out["totp_mode"] not in TOTP_MODES:
raise ValueError("totp_mode: disabled | optional | required")
if out["members_mode"] not in ("add", "exact"):
raise ValueError("members_mode: add | exact")
out["allow_registration"] = bool(out["allow_registration"])
out["backchannel_logout_uri"] = (out["backchannel_logout_uri"] or "").strip() or None
if out["backchannel_logout_uri"] and not out["backchannel_logout_uri"].startswith("https://"):
raise ValueError("Backchannel-Logout-URI muss https:// sein und öffentlich erreichbar")
members, seen = [], set()
for m in out["members"] or []:
email, role = str(m.get("email", "")).strip().lower(), m.get("role", "viewer")
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
raise ValueError(f"Ungültige E-Mail: {email!r}")
if role not in ROLES:
raise ValueError(f"Rolle von {email}: viewer | editor | admin")
if email not in seen:
seen.add(email)
members.append({"email": email, "role": role})
out["members"] = members
out.pop("last_sync", None)
return out
def desired_redirects(base_domain: str, t: Tenant) -> list[str]:
hosts = [f"{t.subdomain or t.id}.{base_domain}"]
if t.custom_domain:
hosts.append(t.custom_domain)
return [f"https://{h}/auth/callback" for h in hosts]
def diff_app(actual: dict, want: dict, mode: str) -> dict:
"""Welche PATCH-Felder wären nötig? adopt: nur fehlende URIs ergänzen; managed: alles exakt."""
changes: dict = {}
have, need = list(actual.get("redirect_uris") or []), want["redirect_uris"]
if mode == "adopt":
missing = [u for u in need if u not in have]
if missing:
changes["redirect_uris"] = have + missing
elif mode == "managed":
if set(have) != set(need):
changes["redirect_uris"] = need
for f in ("auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri"):
if actual.get(f) != want[f]:
changes[f] = want[f]
return changes
def _secret(db, tenant_id: str, key: str) -> str | None:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
return crypto.decrypt(row.value_enc) if row and row.value_enc else None
def _store_credentials(tenant_id: str, client_id: str | None, client_secret: str | None, actor="onyva-login-sync") -> None:
"""Sofort und in eigener Transaktion sichern – das Secret ist danach nicht mehr abrufbar."""
from app.services import tenants as tsvc
with session() as db:
if client_id:
tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_ID", client_id, actor)
if client_secret:
tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_SECRET", client_secret, actor)
# ── Abgleich ────────────────────────────────────────────────────────────────
def sync_tenant(tenant_id: str, *, apply: bool, log: Callable[[str], None] = lambda m: None, client: OnyvaLogin | None = None,
rotate: bool = False, actor: str = "system") -> dict:
"""Gleicht den OIDC-Client des Tenants ab. apply=False → nur lesen und Plan melden.
Rückgabe: {ok, applied, skipped, client_id, actions:[{kind,detail}], warnings:[...], created, error}."""
rep = {"ok": True, "applied": False, "skipped": None, "client_id": None, "actions": [], "warnings": [], "created": False, "error": None}
act = lambda kind, detail: (rep["actions"].append({"kind": kind, "detail": detail}), log(f"OIDC: {detail}")) # noqa: E731
warn = lambda msg: (rep["warnings"].append(msg), log(f"OIDC ⚠ {msg}")) # noqa: E731
if apply and not writes_allowed():
rep.update(ok=False, error="Schreibzugriffe auf das Login-System sind gesperrt (OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv).")
return rep
if apply and not guard.live_allowed(tenant_id):
rep.update(ok=False, error=guard.denial(tenant_id))
return rep
try:
with session() as db:
t = db.get(Tenant, tenant_id)
cfg = config_of(t)
base_domain = (db.get(Setting, "base_domain").value if db.get(Setting, "base_domain") else "example.de")
want = {"name": f"Onyscribe – {t.name}", "redirect_uris": desired_redirects(base_domain, t), "auth_mode": cfg["auth_mode"],
"totp_mode": cfg["totp_mode"], "allow_registration": cfg["allow_registration"], "backchannel_logout_uri": cfg["backchannel_logout_uri"]}
cid, has_secret = _secret(db, tenant_id, "OIDC_CLIENT_ID"), bool(_secret(db, tenant_id, "OIDC_CLIENT_SECRET"))
client = client or client_from_env(db)
mode = cfg["mode"]
if mode == "off":
rep["skipped"] = "OIDC-Verwaltung ist für diesen Tenant aus."
log("OIDC: Verwaltung aus – nichts zu tun")
return rep
if client is None:
rep["skipped"] = "ONYVA_LOGIN_API_KEY nicht gesetzt."
rep["ok"] = not apply # beim echten Deploy ist ein fehlender Key ein Fehler, im Dry-Run nur ein Hinweis
rep["error"] = None if rep["ok"] else rep["skipped"]
log("OIDC: (übersprungen – ONYVA_LOGIN_API_KEY nicht gesetzt)")
return rep
app = client.get_app(cid) if cid else None
new_app = app is None
verb = "" if apply else "würde "
if app is None:
if cid:
warn(f"Client {cid} existiert im Login nicht mehr – es wird ein neuer angelegt.")
body = {k: want[k] for k in ("name", "redirect_uris", "auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri")}
act("create", f"{verb}App „{want['name']}“ anlegen (Redirect: {', '.join(want['redirect_uris'])}; auth_mode={want['auth_mode']})")
if apply:
created = client.create_app(body)
cid = created["client_id"]
_store_credentials(tenant_id, cid, created.get("client_secret"), actor) # ZUERST sichern
rep["created"], has_secret = True, True
log(f"OIDC: App angelegt → {cid} (Secret verschlüsselt gespeichert)")
app = {"redirect_uris": want["redirect_uris"], "auth_mode": want["auth_mode"]}
else:
changes = diff_app(app, want, mode)
if changes:
act("patch", f"{verb}App {cid} anpassen: " + ", ".join(f"{k}={v}" for k, v in changes.items()))
if apply:
client.patch_app(cid, changes)
else:
log(f"OIDC: App {cid} ist abgeglichen")
rep["client_id"] = cid
if not has_secret:
warn("Client-Secret ist nicht in der Admin-DB (beim Login nicht abrufbar) – eintragen oder „Secret rotieren“.")
if rotate and cid:
act("rotate", f"{verb}Secret von {cid} rotieren (altes sofort ungültig)")
if apply:
_store_credentials(tenant_id, None, client.rotate_secret(cid)["client_secret"], actor)
# Freigaben
members = cfg["members"]
effective_auth = want["auth_mode"] if mode == "managed" else (app.get("auth_mode") or want["auth_mode"])
if (members or cfg["members_mode"] == "exact") and (cid or new_app):
current = {} if new_app else {m["email"].lower(): m["role"] for m in client.memberships(cid)}
for m in members:
if current.get(m["email"]) != m["role"]:
act("member_put", f"{verb}{m['email']} als {m['role']} freigeben")
if apply and not client.put_membership(cid, m["email"], m["role"]):
warn(f"{m['email']} existiert im Login nicht – Nutzer dort anlegen, dann erneut abgleichen.")
if cfg["members_mode"] == "exact":
for email in sorted(set(current) - {m["email"] for m in members}):
act("member_delete", f"{verb}Freigabe von {email} entziehen")
if apply:
client.delete_membership(cid, email)
elif effective_auth == "required" and not members:
log("OIDC: auth_mode=required ohne gepflegte Freigaben – bestehende Freigaben bleiben unverändert")
rep["applied"] = apply and bool(rep["actions"])
except OnyvaError as exc:
rep.update(ok=False, error=str(exc))
log(f"OIDC ✘ {exc}")
except Exception as exc: # noqa: BLE001
rep.update(ok=False, error=f"{type(exc).__name__}: {exc}")
log(f"OIDC ✘ {rep['error']}")
if apply:
_record(tenant_id, rep)
return rep
def _record(tenant_id: str, rep: dict) -> None:
with session() as db:
t = db.get(Tenant, tenant_id)
cfg = dict(t.oidc or {})
cfg["last_sync"] = {"ts": datetime.now(timezone.utc).isoformat(), "ok": rep["ok"], "created": rep["created"], "error": rep["error"],
"actions": [a["detail"] for a in rep["actions"]], "warnings": rep["warnings"]}
t.oidc = cfg
store.audit(db, "system", "oidc.sync", tenant_id, "ok" if rep["ok"] else f"Fehler: {rep['error']}")

114
app/services/preflight.py Normal file
View File

@@ -0,0 +1,114 @@
"""Vorflug-Check: ist ein Tenant bereit für einen Live-Deploy? Liest nur (Mittwald-GET, Login-API-GET, eigener Health) und schreibt nichts.
Jeder Punkt: {id, level: ok|warn|fail|info, label, detail, fix}. Ein Deploy ist „bereit“, wenn es kein `fail` gibt.
"""
from __future__ import annotations
import socket
from sqlalchemy import select
from app.db import session
from app.models import Secret, Tenant
from app.services import contract, deploy, guard, onyva_login, preview, store
from app.services.mittwald import DeployError, DryRunSkip, Mittwald
def _item(id_, level, label, detail="", fix=""):
return {"id": id_, "level": level, "label": label, "detail": detail, "fix": fix}
def run(tenant_id: str, *, mw_http=None, oidc_client=None, self_get=None) -> dict:
out: list[dict] = []
add = lambda *a, **k: out.append(_item(*a, **k)) # noqa: E731
with session() as db:
t = db.get(Tenant, tenant_id)
cfg, _ = store.content(db, t, "current")
draft, source, pin = bool(t.draft), t.source, t.core_pin
base_domain, project, stack = deploy.setting(db, "base_domain"), deploy.setting(db, "mittwald_project_id"), deploy.setting(db, "mittwald_stack_id")
registry, admin_url = deploy.setting(db, "registry"), deploy.setting(db, "admin_public_url")
bundle_ok = deploy.setting(db, "core_supports_bundle", "false") == "true"
tag = deploy.effective_tag(db, t)
host = deploy.host_for(db, t)
missing = deploy.missing_secrets(db, t)
oc = onyva_login.config_of(t)
has_oidc_secrets = all(db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == k, Secret.value_enc.is_not(None))) is not None
for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))
stt_provider = (cfg.get("stt") or {}).get("provider") or "mistral"
# Freigaben / Modus
add("live", "ok" if deploy.live_enabled() else "warn", "DEPLOY_LIVE", "aktiv" if deploy.live_enabled() else "nicht aktiv – nur Dry-Run möglich",
"" if deploy.live_enabled() else "Env DEPLOY_LIVE=true der Admin-Plattform setzen und neu starten (erst nach erfolgreichem Dry-Run).")
add("allow", "ok" if guard.live_allowed(tenant_id) else "warn", "Live-Freigabe für diesen Tenant", f"DEPLOY_ALLOWED_TENANTS = {guard.describe()}",
"" if guard.live_allowed(tenant_id) else f"Env DEPLOY_ALLOWED_TENANTS um „{tenant_id}“ ergänzen.")
# Vertrag / Core
add("contract", "ok" if contract.supported() else "fail", "Core-Vertrag", f"Admin-Core {contract.sha()[:10]}, Vertrag v{contract.contract_version()}",
"" if contract.supported() else "Admin-Plattform aktualisieren.")
hex_tag = deploy._hex_tag(tag)
add("tag", "ok" if hex_tag else "warn", "Core-Image-Tag", f":{tag}" + (" (gepinnt)" if pin else ""),
"" if hex_tag else "Besser den vollen Git-SHA als Pin setzen (Tenant → Übersicht → Core-Version) – dann prüft der Health-Check die Core-Version.")
if source == "admin":
add("bundle", "ok" if bundle_ok else "fail", "Core kann Bundle laden", "Einstellung core_supports_bundle", "" if bundle_ok else "Einstellungen → „Core kann Bundle laden“ aktivieren (erst wenn das Image den Bundle-Loader enthält).")
good_url = admin_url.startswith("https://") and "<" not in admin_url
add("adminurl", "ok" if good_url else "fail", "Öffentliche Admin-URL", admin_url or "nicht gesetzt",
"" if good_url else "Einstellungen → „Öffentliche URL der Admin-Plattform“ (https://…): so holt der Tenant-Container sein Bundle.")
if good_url:
try:
status = (self_get or _get)(f"{admin_url.rstrip('/')}/healthz")
add("adminreach", "ok" if status == 200 else "fail", "Admin von außen erreichbar", f"GET /healthz → {status}", "" if status == 200 else "Domain/Proxy in Coolify prüfen.")
except Exception as exc: # noqa: BLE001
add("adminreach", "fail", "Admin von außen erreichbar", f"{type(exc).__name__}: {str(exc)[:120]}", "Domain/Proxy in Coolify prüfen.")
# Mittwald
add("domain", "ok" if base_domain else "fail", "Basis-Domain", base_domain or "nicht gesetzt", "" if base_domain else "Einstellungen → Basis-Domain (= Gitea-Variable DOMAIN).")
add("registry", "ok" if registry else "fail", "Registry", registry or "nicht gesetzt", "" if registry else "Einstellungen → Registry (= Gitea-Variable REGISTRY).")
add("mtoken", "ok" if deploy.token_present() else "fail", "MITTWALD_API_TOKEN", "gesetzt" if deploy.token_present() else "nicht gesetzt",
"" if deploy.token_present() else "Env MITTWALD_API_TOKEN der Admin-Plattform setzen (derselbe Token wie in Gitea).")
add("mproject", "ok" if (project or stack) else "fail", "Mittwald PROJECT_ID / STACK_ID", f"{project or '–'} / {stack or '–'}", "" if (project or stack) else "Einstellungen → PROJECT_ID (und optional STACK_ID).")
if deploy.token_present() and (project or stack):
try:
mw = Mittwald(__import__("os").getenv("MITTWALD_API_TOKEN", ""), __import__("os").getenv("MITTWALD_API_BASE", "https://api.mittwald.de/v2"), False, lambda m: None, mw_http)
sid = mw.resolve_stack_id(project, stack)
services = sorted(Mittwald.services_of(mw.api("GET", f"/stacks/{sid}/", write=False)))
mine = f"app-{tenant_id}"
protected = [s for s in services if s != mine]
add("stack", "ok", "Mittwald-Stack lesbar", f"{len(services)} Services; bestehende bleiben unangetastet: {', '.join(protected) or '–'}")
add("svc", "info", "Service dieses Tenants", f"{mine} " + ("existiert bereits (wird aktualisiert)" if mine in services else "existiert noch nicht (wird angelegt)"))
except (DeployError, DryRunSkip) as exc:
add("stack", "fail", "Mittwald-Stack lesbar", str(exc)[:200], "Token-Rechte, PROJECT_ID/STACK_ID prüfen.")
except Exception as exc: # noqa: BLE001
add("stack", "fail", "Mittwald-Stack lesbar", f"{type(exc).__name__}: {str(exc)[:160]}", "Netzwerk/Token prüfen.")
# Secrets / OIDC / STT
will_create = oc["mode"] == "managed" and onyva_login.key_present() and not has_oidc_secrets
real_missing = [m for m in missing if not (will_create and m in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET"))]
add("secrets", "ok" if not real_missing else "fail", "Pflicht-Secrets", "vollständig" if not real_missing else "fehlen: " + ", ".join(real_missing),
"" if not real_missing else "Tenant → Secrets (tenantspezifisch) bzw. Einstellungen → Geteilte Secrets (z. B. CORTECS_API_KEY).")
if will_create:
add("oidcnew", "info", "OIDC-Client", "wird beim Deploy automatisch im Login angelegt (Client-ID/-Secret sofort gespeichert)")
elif oc["mode"] != "off" and not onyva_login.key_present():
add("oidckey", "fail", "ONYVA_LOGIN_API_KEY", "nicht gesetzt – OIDC-Abgleich beim Deploy nicht möglich", "Env ONYVA_LOGIN_API_KEY setzen oder OIDC-Verwaltung auf „off“ stellen und Client manuell eintragen.")
elif oc["mode"] != "off":
add("oidckey", "ok", "Login-API", f"Modus {oc['mode']}")
if oc["mode"] != "off" and oc["auth_mode"] == "required" and not oc["members"]:
add("members", "warn", "Freigaben", "auth_mode „required“, aber keine Nutzer gepflegt – niemand kann sich anmelden",
"Domain & Login → Freigaben: mindestens dich eintragen (Nutzer muss im Login existieren).")
if stt_provider != "cortecs":
add("stt", "info", "STT-Provider", stt_provider + " – braucht MISTRAL_API_KEY")
# Inhalt
with session() as db:
t = db.get(Tenant, tenant_id)
try:
val = preview.run(db, t, "current", "validate")
add("render", "ok" if val.get("ok") else "fail", "Veröffentlichter Stand rendert (echter Core)", "; ".join(val.get("errors", []))[:200] or "Web-UI und PDF ok",
"" if val.get("ok") else "Tenant → Editor korrigieren und neu veröffentlichen.")
except Exception as exc: # noqa: BLE001
add("render", "fail", "Rendering", str(exc)[-200:])
if draft:
add("draft", "warn", "Unveröffentlichter Entwurf", "wird NICHT mit deployt", "Erst veröffentlichen, wenn er mit soll.")
add("host", "info", "Ziel-URL", f"https://{host}", "")
ready = not any(i["level"] == "fail" for i in out)
return {"ready": ready, "items": out, "host": host, "tag": tag}
def _get(url: str) -> int:
import httpx
return httpx.get(url, timeout=8, follow_redirects=True).status_code

109
app/services/preview.py Normal file
View File

@@ -0,0 +1,109 @@
"""Live-Vorschau: führt den vendored Core mit dem (Entwurfs-)Tenant in einem Subprozess aus."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from sqlalchemy.orm import Session
from app.models import Tenant
from app.services import store
from app.services.samples import DEFAULT_SAMPLE
from app.settings import PLATFORM_DIR, PREVIEW_CACHE
WORKER = Path(__file__).with_name("preview_worker.py")
TIMEOUT = 90
class PreviewError(RuntimeError):
pass
def core_version() -> str:
from app.services import contract
return contract.sha()[:10]
def sample_for(t: Tenant) -> str:
return t.preview_sample or DEFAULT_SAMPLE
EXT = {"index": "html", "pdf": "pdf", "validate": "json", "prompt": "json"}
class SourceGone(LookupError):
pass
def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]:
"""draft | current | v<N> | ws:<run> (Live-Arbeitsstand des Agenten) | proposal:<msg> (Vorschlag des Agenten)."""
if source.startswith("ws:"):
from app.agent.loop import WORKSPACES
if source[3:] not in WORKSPACES:
raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.")
cfg, files = WORKSPACES[source[3:]]
return dict(cfg), dict(files)
if source.startswith("proposal:"):
from app.agent.loop import proposal_content
from app.models import ChatMessage
m = db.get(ChatMessage, int(source[9:] or 0))
if m is None or m.tenant_id != t.id or not m.proposal:
raise SourceGone("Vorschlag nicht gefunden.")
return proposal_content(db, t, m.proposal)
return store.content(db, t, source)
def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict:
cfg, files = resolve_content(db, t, source)
return run_content(db, t, cfg, files, kind, sample, args)
def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None,
args: dict | None = None) -> bytes | dict:
"""Rendert beliebigen Inhalt (cfg + {pfad: sha}) – für Entwurf, Version oder Agent-Workspace."""
sample = sample if sample is not None else sample_for(t)
h = hashlib.sha256(json.dumps([cfg, files, kind, sample, args or {}, core_version()], sort_keys=True).encode())
cached = PREVIEW_CACHE / f"{h.hexdigest()[:32]}.{EXT[kind]}"
if not cached.exists():
_execute(db, t.id, cfg, files, kind, sample, cached, args or {})
data = cached.read_bytes()
return json.loads(data) if EXT[kind] == "json" else data
def _limits() -> None:
import resource
resource.setrlimit(resource.RLIMIT_CPU, (90, 90))
resource.setrlimit(resource.RLIMIT_FSIZE, (200 * 1024 * 1024,) * 2)
os.setsid()
def _execute(db: Session, tenant_id: str, cfg: dict, files: dict, kind: str, sample: str, out: Path, args: dict) -> None:
with tempfile.TemporaryDirectory(prefix="onyscribe-preview-") as tmp_s:
tmp = Path(tmp_s)
shutil.copytree(PLATFORM_DIR / "core", tmp / "core", ignore=shutil.ignore_patterns("__pycache__"))
store.write_tenant_dir(db, cfg, files, tmp / "tenants" / tenant_id)
(tmp / "data").mkdir()
shutil.copy(WORKER, tmp / "preview_worker.py")
env = {
"PATH": os.environ.get("PATH", ""), "HOME": str(tmp), "LANG": "C.UTF-8",
"TENANT": tenant_id, "DATA_DIR": str(tmp / "data"), "AUTH_ENABLED": "false",
"PYTHONPATH": str(tmp), "PYTHONDONTWRITEBYTECODE": "1",
# Kein einziges echtes Secret im Sandbox-Prozess
"OIDC_ISSUER": "https://login.example.invalid", "SESSION_SECRET": "preview",
}
result = tmp / f"result.{out.suffix.lstrip('.')}"
payload = json.dumps({"summary": sample, "title": f"{cfg.get('name', tenant_id)} – Vorschau", **args})
try:
proc = subprocess.run([sys.executable, "-I", "preview_worker.py", kind, str(result)], input=payload,
cwd=tmp, env=env, capture_output=True, text=True, timeout=TIMEOUT, preexec_fn=_limits)
except subprocess.TimeoutExpired as exc:
raise PreviewError("Vorschau-Timeout (Tenant-Code läuft zu lange)") from exc
if proc.returncode != 0 or not result.exists():
raise PreviewError((proc.stderr or proc.stdout or "unbekannter Fehler")[-2500:])
shutil.copy(result, out)

View File

@@ -0,0 +1,65 @@
"""Läuft in einem isolierten Verzeichnis mit Kopie des Cores + materialisiertem Tenant.
Aufruf: python preview_worker.py <index|pdf|validate> <ausgabedatei> (JSON-Eingabe auf stdin)
Führt den ECHTEN Core aus (auch overrides.py) – deshalb nur als Subprozess ohne Secrets/Netz-Keys.
"""
import json
import os
import sys
import traceback
sys.path.insert(0, os.getcwd())
kind, out = sys.argv[1], sys.argv[2]
args = json.loads(sys.stdin.read() or "{}")
def main() -> None:
from core import app as appmod
from core.config import CFG
if kind == "index":
page = appmod._render_index()
open(out, "w", encoding="utf-8").write(page)
elif kind == "pdf":
pdf = appmod.build_pdf(args.get("title") or "Vorschau", args["summary"], "SPRECHER_0: Beispiel.")
open(out, "wb").write(pdf)
elif kind == "prompt":
base = appmod._summary_prompt()
ctx = {"title": args.get("title", ""), "insurance_type": args.get("terminart", ""),
"customer_2": args.get("projekt", ""), "customer_name": args.get("kunde", ""), "customer_address": ""}
final = appmod.HOOKS.build_prompt(base, ctx)
json.dump({"prompt": final, "chars": len(final)}, open(out, "w"))
elif kind == "validate":
errors, warnings = [], []
page = appmod._render_index()
if "@@" in page:
errors.append("Web-UI: nicht ersetzte @@PLATZHALTER@@ im Template")
if not CFG.logo_path:
warnings.append("Kein Logo (logo.svg/logo.png) hinterlegt")
if not CFG.favicon_path:
warnings.append("Kein favicon.svg – es wird ein generisches Favicon verwendet")
try:
pdf = appmod.build_pdf("Validierung", args["summary"], "SPRECHER_0: Beispiel.")
if pdf[:5] != b"%PDF-":
errors.append("PDF-Rendering lieferte kein PDF")
except Exception as exc: # noqa: BLE001
errors.append(f"PDF-Rendering fehlgeschlagen: {exc}")
prompt = CFG.prompt_path.read_text(encoding="utf-8")
if len(prompt.strip()) < 200:
warnings.append("Prompt ist sehr kurz")
json.dump({"ok": not errors, "errors": errors, "warnings": warnings,
"tenant": CFG.id, "prompt_chars": len(prompt)}, open(out, "w"))
try:
main()
except SystemExit:
raise
except BaseException: # noqa: BLE001
tb = traceback.format_exc()
if kind == "validate":
json.dump({"ok": False, "errors": ["Tenant lässt sich nicht laden:\n" + tb[-1500:]], "warnings": []}, open(out, "w"))
else:
sys.stderr.write(tb)
sys.exit(2)

254
app/services/releases.py Normal file
View File

@@ -0,0 +1,254 @@
"""Core-Releases: Eingang (CI), Kompatibilitätsprüfung aller Tenants, Freigabe, Canary-Rollout, Rollback.
Ablauf: CI meldet Image-Tag → Admin (läuft auf genau diesem Core) prüft alle Tenants in der Sandbox → Freigabe →
Rollout (Canary zuerst, Abbruch beim ersten Fehler, Health-Check pro Tenant) → aktiver Tag wird der neue Standard.
Gepinnte Tenants nehmen nicht teil. Echte Rollouts brauchen DEPLOY_LIVE=true; sonst nur Dry-Run.
"""
from __future__ import annotations
import hashlib
import io
import re
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import session
from app.models import CoreRelease, Deployment, Setting, Tenant
from app.services import contract, deploy, guard, preview, store
DATE = re.compile(r"\b\d{1,2}\.\d{1,2}\.\d{2,4}\b")
class ReleaseError(RuntimeError):
pass
# ── Eingang ─────────────────────────────────────────────────────────────────
def announce(db, sha: str, tag: str | None, ref: str, image: str, message: str) -> tuple[CoreRelease, bool]:
if not re.fullmatch(r"[0-9a-fA-F]{7,64}", sha or ""):
raise ReleaseError("sha: 7–64 Hex-Zeichen erwartet")
sha = sha.lower()
r = db.scalar(select(CoreRelease).where(CoreRelease.sha == sha))
created = r is None
if created:
r = CoreRelease(sha=sha, tag=(tag or sha)[:100], ref=ref[:100], image=image[:300], message=message[:500])
db.add(r)
store.audit(db, "ci", "core.release", None, f"{sha[:10]} ({ref}) {message[:80]}")
else:
r.message, r.ref, r.image = message[:500] or r.message, ref[:100] or r.ref, image[:300] or r.image
db.flush()
return r, created
def admin_matches(r: CoreRelease) -> bool:
return contract.same_sha(contract.sha(), r.sha)
def view(r: CoreRelease, active_tag: str) -> dict:
return {"id": r.id, "sha": r.sha, "tag": r.tag, "ref": r.ref, "image": r.image, "message": r.message, "status": r.status,
"announced_at": r.announced_at.isoformat() if r.announced_at else None, "admin_matches": admin_matches(r),
"approved_by": r.approved_by, "compat": r.compat, "rollout_batch": r.rollout_batch, "previous_tag": r.previous_tag,
"active": r.tag == active_tag or contract.same_sha(r.sha, active_tag)}
# ── Kompatibilitätsprüfung ──────────────────────────────────────────────────
def _pdf_fingerprint(pdf: bytes) -> tuple[int, str]:
from pypdf import PdfReader
rd = PdfReader(io.BytesIO(pdf))
text = DATE.sub("<datum>", "\n".join((p.extract_text() or "") for p in rd.pages)) # das Tagesdatum steht im PDF
return len(rd.pages), hashlib.sha256(text.encode()).hexdigest()
def fingerprint(db, t: Tenant) -> dict:
"""Rendert den veröffentlichten Stand mit dem aktuellen Admin-Core und bildet Fingerabdrücke."""
cfg, files = store.content(db, t, "current")
sample = preview.sample_for(t)
fp: dict = {"sample": hashlib.sha256(sample.encode()).hexdigest()[:12]}
val = preview.run_content(db, t, cfg, files, "validate", sample)
fp["validate"] = {"ok": val.get("ok", False), "errors": val.get("errors", []), "warnings": val.get("warnings", [])}
if val.get("ok"):
pages, text_sha = _pdf_fingerprint(preview.run_content(db, t, cfg, files, "pdf", sample))
fp.update(pdf_pages=pages, pdf_text=text_sha,
web=hashlib.sha256(preview.run_content(db, t, cfg, files, "index", sample)).hexdigest(),
prompt=hashlib.sha256(preview.run_content(db, t, cfg, files, "prompt", sample, {"title": "x"})["prompt"].encode()).hexdigest())
fp["schema_problems"] = contract.check_tenant_json(cfg)
return fp
def compare(fp: dict, baseline: dict | None, version: int) -> dict:
"""→ {status: ok|changed|new|error, changed:[…], errors:[…], warnings:[…]}"""
out = {"status": "ok", "changed": [], "errors": fp["validate"]["errors"], "warnings": list(fp["validate"]["warnings"]) + fp.get("schema_problems", [])}
if not fp["validate"]["ok"]:
out["status"] = "error"
return out
if not baseline or baseline.get("version") != version or baseline["fp"].get("sample") != fp["sample"]:
out["status"] = "new"
return out
labels = {"pdf_pages": "PDF-Seitenzahl", "pdf_text": "PDF-Text", "web": "Web-UI", "prompt": "Finaler Prompt"}
out["changed"] = [labels[k] for k in labels if baseline["fp"].get(k) != fp.get(k)]
if out["changed"]:
out["status"] = "changed"
return out
def check(release_id: int, *, force: bool = False, wait: bool = False, workers: int = 3) -> None:
"""Prüft ALLE Tenants (außer archivierten) mit dem Core, auf dem die Admin-Plattform läuft. Ergebnis in release.compat."""
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
if not force and not admin_matches(r):
raise ReleaseError(f"Die Admin-Plattform läuft noch auf Core {contract.sha()[:10]}, das Release ist {r.sha[:10]}. "
"Erst den Admin neu bauen (FROM neues Core-Image) – sonst würde gegen den falschen Core geprüft.")
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status != "archived").order_by(Tenant.id))]
r.compat = {"checked_at": None, "core_sha": contract.sha(), "running": True, "tenants": {}, "summary": {}, "forced": force and not admin_matches(r)}
r.status = "new" if r.status in ("new", "checked") else r.status
def one(tid: str):
with session() as db:
t = db.get(Tenant, tid)
try:
fp = fingerprint(db, t)
res = compare(fp, t.render_baseline, t.current_version)
except Exception as exc: # noqa: BLE001
fp, res = {}, {"status": "error", "changed": [], "errors": [f"{type(exc).__name__}: {str(exc)[-300:]}"], "warnings": []}
return tid, fp, res
def work():
results = {}
with ThreadPoolExecutor(max_workers=workers) as ex:
for tid, fp, res in ex.map(one, ids):
results[tid] = {**res, "fp": fp}
summary = {k: sum(1 for v in results.values() if v["status"] == k) for k in ("ok", "changed", "new", "error")}
with session() as db:
r = db.get(CoreRelease, release_id)
r.compat = {"checked_at": datetime.now(timezone.utc).isoformat(), "core_sha": contract.sha(), "running": False, "tenants": results,
"summary": summary, "forced": bool(r.compat and r.compat.get("forced"))}
if r.status == "new":
r.status = "checked"
store.audit(db, "system", "core.check", None, f"{r.sha[:10]}: {summary}")
th = threading.Thread(target=work, daemon=True)
th.start()
if wait:
th.join()
# ── Freigabe & Rollout ──────────────────────────────────────────────────────
def approve(db, release_id: int, actor: str, acknowledge: bool = False) -> CoreRelease:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
c = r.compat
if not c or c.get("running"):
raise ReleaseError("Erst die Kompatibilitätsprüfung abschließen.")
if not c.get("forced") and not contract.same_sha(c.get("core_sha", ""), r.sha):
raise ReleaseError("Die Prüfung stammt von einem anderen Core als dieses Release – bitte erneut prüfen.")
if c["summary"].get("error"):
bad = [t for t, v in c["tenants"].items() if v["status"] == "error"]
raise ReleaseError(f"Nicht freigebbar: {len(bad)} Tenant(s) mit Fehlern ({', '.join(bad)}).")
if c["summary"].get("changed") and not acknowledge:
raise ReleaseError("Bei einigen Tenants hat sich das Rendering geändert – bitte prüfen und bewusst bestätigen (acknowledge).")
r.status, r.approved_by, r.approved_at = "approved", actor, datetime.now(timezone.utc)
store.audit(db, actor, "core.approve", None, r.sha[:10])
return r
def canary_list(db) -> list[str]:
s = db.get(Setting, "canary_tenants")
return [x.strip() for x in (s.value if s and s.value else "demo").split(",") if x.strip()]
def _set_setting(db, key: str, value: str) -> None:
s = db.get(Setting, key)
if s is None:
s = Setting(key=key)
db.add(s)
s.value = value
def rollout(release_id: int, actor: str, *, dry_run: bool, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
live = not dry_run
if live and not deploy.live_enabled():
raise ReleaseError("Echte Rollouts sind gesperrt (DEPLOY_LIVE != true). Dry-Run ist möglich.")
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None:
raise ReleaseError("Release nicht gefunden")
allowed = {"approved", "active", "failed", "rolled_back"} | ({"new", "checked"} if dry_run else set()) # Dry-Run schon vor der Freigabe
if r.status not in allowed:
raise ReleaseError(f"Release ist im Status „{r.status}“ – erst prüfen und freigeben.")
tag, sha = r.tag, r.sha
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin]
if live: # Live: nur für Live-Schreibzugriffe freigegebene Tenants (guard.py)
ids = [t for t in ids if guard.live_allowed(t)]
if not ids:
raise ReleaseError(f"Kein Tenant für Live-Rollouts freigegeben (DEPLOY_ALLOWED_TENANTS = {guard.describe()}).")
canary = canary_list(db)
batch = uuid.uuid4().hex[:10]
if live:
r.status, r.previous_tag, r.rollout_batch = "rolling", deploy.setting(db, "core_image_tag", "latest"), batch
store.audit(db, actor, "core.rollout", None, f"{sha[:10]} → {len(ids)} Tenants")
def done(ok: bool, results: dict):
if not live:
return
with session() as db:
r = db.get(CoreRelease, release_id)
if ok:
r.status = "active"
_set_setting(db, "core_image_tag", tag)
for other in db.scalars(select(CoreRelease).where(CoreRelease.status == "active", CoreRelease.id != release_id)):
other.status = "superseded"
for tid in results: # Baselines für künftige Kompatibilitätsprüfungen
t = db.get(Tenant, tid)
c = ((r.compat or {}).get("tenants") or {}).get(tid)
if t and c and c.get("fp"):
t.render_baseline = {"version": t.current_version, "core": sha, "fp": c["fp"]}
else:
r.status = "failed"
store.audit(db, "system", "core.rollout.done", None, f"{sha[:10]} {'ok' if ok else 'FEHLER'} {results}")
dep_ids = deploy.run_sequence(ids, "dry-run" if dry_run else "deploy", actor, batch=batch, core_tag=tag, http=http, oidc_client=oidc_client,
health=health, canary=canary, wait=wait, on_done=done)
return batch, dep_ids
def rollback_release(release_id: int, actor: str, *, http=None, oidc_client=None, health=None, wait: bool = False) -> tuple[str, list[int]]:
"""Alle (nicht gepinnten) Tenants auf den Tag vor diesem Rollout zurück."""
if not deploy.live_enabled():
raise ReleaseError("Echte Rollbacks sind gesperrt (DEPLOY_LIVE != true).")
with session() as db:
r = db.get(CoreRelease, release_id)
if r is None or not r.previous_tag:
raise ReleaseError("Kein vorheriger Tag bekannt – Rollback nicht möglich.")
prev = r.previous_tag
ids = [t.id for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)) if not t.core_pin and guard.live_allowed(t.id)]
batch = uuid.uuid4().hex[:10]
store.audit(db, actor, "core.rollback", None, f"{r.sha[:10]} → :{prev}")
def done(ok, results):
with session() as db:
r = db.get(CoreRelease, release_id)
if ok:
r.status = "rolled_back"
_set_setting(db, "core_image_tag", prev)
store.audit(db, "system", "core.rollback.done", None, f"{'ok' if ok else 'FEHLER'} {results}")
return batch, deploy.run_sequence(ids, "deploy", actor, batch=batch, core_tag=prev, http=http, oidc_client=oidc_client, health=health, wait=wait, on_done=done)
def tenant_rollback_tag(db, t: Tenant) -> str | None:
"""Letzter erfolgreich live deployter Tag dieses Tenants, der vom aktuellen abweicht."""
for d in db.scalars(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "deploy", Deployment.status == "success",
Deployment.simulated.is_(False)).order_by(Deployment.id.desc())):
if d.image_tag and d.image_tag != t.deployed_image:
return d.image_tag
return None

76
app/services/samples.py Normal file
View File

@@ -0,0 +1,76 @@
"""Beispielprotokolle für die PDF-Vorschau (so, wie der LLM sie liefern würde)."""
DEFAULT_SAMPLE = """===TERMINART===
Kick-off
===/TERMINART===
===PROJEKT===
Muster GmbH
Relaunch 2026
===/PROJEKT===
===KONTAKT===
Max Mustermann - Geschäftsführer
Erika Beispiel - Projektleitung
===/KONTAKT===
Anlass & Kontext
Erstes Kennenlernen und Abstecken des Projektrahmens. Ziel ist ein gemeinsames Verständnis von Umfang, Zeitplan und Zuständigkeiten.
Besprochene Themen
• Zeitplan und Meilensteine bis zum Go-live im Herbst
• Budgetrahmen und Freigabeprozess
• Anbindung des bestehenden CRM
Entscheidungen
• Projektstart im Juli
• Wöchentlicher Jour fixe, dienstags 10:00 Uhr
Action Items
• Angebot bis Freitag senden [Verantwortlich: Erika Beispiel, Frist: 18.07.]
• Zugänge zum CRM bereitstellen [Verantwortlich: Max Mustermann, Frist: 22.07.]
Offene Punkte
• Finale Designrichtung noch nicht entschieden
• Datenschutzfreigabe der Fachabteilung steht aus
Nächste Schritte
• Folgetermin zum Konzept-Review vereinbaren
"""
ROTARY_SAMPLE = """===TERMINART===
Clubmeeting
===/TERMINART===
===PROJEKT===
Lieferketten im Wandel
===/PROJEKT===
Begrüßung
Der Präsident eröffnet das Meeting und begrüßt die anwesenden Freunde sowie zwei Gäste.
Geburtstage
• Anna Beispiel
• Peter Muster
Aktuelle 5 Minuten
Kurzer Bericht zum Stand der Spendenaktion für das Jugendprojekt. Bisher sind 4.200 Euro zusammengekommen.
Happy Euro
• Für die gelungene Veranstaltung am Samstag
• Für das gute Wetter beim Grillfest
Vortrag / Referat
Globalisierung – historische Einordnung
Der Referent ordnet die Entwicklung der globalen Lieferketten seit den 1990er-Jahren ein.
Folgen für die Region
• Abhängigkeit von wenigen Zulieferern
• Chancen durch regionale Wertschöpfung
Dank
Der Präsident dankt dem Referenten für den anregenden Vortrag.
Abschluss
Nächstes Meeting am kommenden Montag.
"""
SAMPLE_TRANSCRIPT = """SPRECHER_0: Guten Morgen zusammen, schön, dass wir uns heute zum Kick-off treffen. Ich bin Erika Beispiel, Projektleitung auf unserer Seite.
SPRECHER_1: Morgen. Max Mustermann, ich bin Geschäftsführer der Muster GmbH. Wir wollen unsere Website bis zum Herbst neu aufsetzen.
SPRECHER_0: Gut. Dann starten wir im Juli. Ich würde einen wöchentlichen Jour fixe vorschlagen, dienstags um zehn.
SPRECHER_1: Passt. Das Budget liegt bei etwa vierzigtausend Euro, die Freigabe läuft über mich.
SPRECHER_0: Ich schicke Ihnen bis Freitag das Angebot. Und wir brauchen von Ihnen die Zugänge zum CRM, möglichst bis zum zweiundzwanzigsten Juli.
SPRECHER_1: Mache ich. Offen ist noch die Designrichtung und ob die Datenschutzabteilung zustimmt.
SPRECHER_0: Dann vereinbaren wir als Nächstes einen Termin zum Konzept-Review.
"""
ROTARY_TRANSCRIPT = """SPRECHER_0: Liebe Freunde, ich begrüße euch zum Clubmeeting und freue mich über zwei Gäste heute Abend.
SPRECHER_0: Geburtstag haben diese Woche Anna Beispiel und Peter Muster, herzlichen Glückwunsch.
SPRECHER_1: Zur Spendenaktion für das Jugendprojekt: Wir haben bisher viertausendzweihundert Euro zusammen.
SPRECHER_2: Happy Euro für das schöne Wetter beim Grillfest und für die gelungene Veranstaltung am Samstag.
SPRECHER_3: Mein Vortrag heute: Globalisierung und Lieferketten. Seit den Neunzigern hat sich die Abhängigkeit von wenigen Zulieferern stark erhöht. Für unsere Region bedeutet das Risiken, aber auch Chancen durch regionale Wertschöpfung.
SPRECHER_0: Vielen Dank für den Vortrag. Nächstes Meeting ist am kommenden Montag.
"""

54
app/services/seed.py Normal file
View File

@@ -0,0 +1,54 @@
"""Seed-Export/-Import: Startdaten (importierte Tenants) als Datei im Repo.
So startet eine frische Instanz (z. B. Coolify) mit den aktuellen Tenants, ohne Zugriff auf das alte Git-Repo.
Secrets-WERTE sind nie enthalten – nur die Schlüsselnamen.
"""
from __future__ import annotations
import base64
import gzip
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Blob, Secret, Setting, Tenant, Version
COLS = ["id", "name", "status", "source", "subdomain", "custom_domain", "domain_status", "cpu", "memory",
"current_version", "deployed_version", "deployed_image", "preview_sample", "oidc", "notes"]
def export(db: Session, path: Path) -> None:
tenants = db.scalars(select(Tenant).order_by(Tenant.id)).all()
shas = set()
out = {"tenants": [], "versions": [], "secrets": [], "settings": {}}
for t in tenants:
out["tenants"].append({c: getattr(t, c) for c in COLS})
for v in db.scalars(select(Version).where(Version.tenant_id == t.id).order_by(Version.number)):
out["versions"].append({"tenant_id": t.id, "number": v.number, "message": v.message, "author": v.author,
"config": v.config, "files": v.files})
shas |= set(v.files.values())
for s in db.scalars(select(Secret)):
out["secrets"].append({"tenant_id": s.tenant_id, "key": s.key})
out["settings"] = {s.key: s.value for s in db.scalars(select(Setting))}
out["blobs"] = {sha: {"b64": base64.b64encode(db.get(Blob, sha).content).decode(), "text": db.get(Blob, sha).is_text} for sha in shas}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(gzip.compress(json.dumps(out).encode(), 9))
def load(db: Session, path: Path) -> int:
data = json.loads(gzip.decompress(path.read_bytes()))
for sha, b in data["blobs"].items():
raw = base64.b64decode(b["b64"])
db.add(Blob(sha=sha, content=raw, size=len(raw), is_text=b["text"]))
for t in data["tenants"]:
db.add(Tenant(**t))
db.flush()
for v in data["versions"]:
db.add(Version(**v))
for s in data["secrets"]:
db.add(Secret(tenant_id=s["tenant_id"], key=s["key"], value_enc=None, source="sops-import"))
for k, v in data["settings"].items():
db.add(Setting(key=k, value=v))
return len(data["tenants"])

61
app/services/status.py Normal file
View File

@@ -0,0 +1,61 @@
"""Zustandsabgleich: was läuft wirklich? (Vertrag: GET /health → core.version, bundle.source/version/stale)"""
from __future__ import annotations
import time
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import select
from app.db import session
from app.models import Tenant
from app.services import contract, deploy
_CACHE: dict = {"ts": 0.0, "data": None}
TTL = 30.0
def _http_get(url: str):
import httpx
r = httpx.get(url, timeout=6, follow_redirects=True)
return r.status_code, (r.json() if r.headers.get("content-type", "").startswith("application/json") else {})
def probe(host: str, expect_tag: str, expect_bundle: int | None, get=_http_get) -> dict:
out = {"host": host, "reachable": False, "core": None, "bundle": None, "drift": [], "error": None}
try:
status, body = get(f"https://{host}/health")
except Exception as exc: # noqa: BLE001
out["error"] = f"{type(exc).__name__}: {str(exc)[:120]}"
out["drift"].append("nicht erreichbar")
return out
out["reachable"] = status == 200
if status != 200:
out["drift"].append(f"HTTP {status}")
core, bundle = body.get("core"), body.get("bundle")
out["core"], out["bundle"] = core, bundle
if core is None:
out["drift"].append("Core ohne Vertrag (alte Version)")
elif expect_tag and deploy._hex_tag(expect_tag) and not contract.same_sha(core.get("version", ""), expect_tag):
out["drift"].append(f"Core {core.get('version', '')[:10]} statt {expect_tag[:10]}")
if expect_bundle is not None:
if not bundle:
out["drift"].append("meldet keine Bundle-Quelle")
elif bundle.get("source") != "admin" or bundle.get("version") != expect_bundle or bundle.get("stale"):
out["drift"].append(f"Bundle {bundle.get('source')} v{bundle.get('version')}{' (veraltet)' if bundle.get('stale') else ''} statt admin v{expect_bundle}")
elif bundle and bundle.get("source") not in ("git", None):
out["drift"].append(f"Bundle-Quelle {bundle.get('source')}, erwartet git")
return out
def probe_all(get=_http_get, refresh: bool = False, workers: int = 6) -> dict:
if not refresh and _CACHE["data"] is not None and time.time() - _CACHE["ts"] < TTL:
return _CACHE["data"]
with session() as db:
items = []
for t in db.scalars(select(Tenant).where(Tenant.status == "active").order_by(Tenant.id)):
items.append((t.id, deploy.host_for(db, t), deploy.effective_tag(db, t), t.current_version if t.source == "admin" else None))
with ThreadPoolExecutor(max_workers=workers) as ex:
res = list(ex.map(lambda it: (it[0], probe(it[1], it[2], it[3], get)), items))
data = {tid: r for tid, r in res}
_CACHE.update(ts=time.time(), data=data)
return data

225
app/services/store.py Normal file
View File

@@ -0,0 +1,225 @@
"""Inhalte eines Tenants: Blobs, Entwurf, Versionen, Diff, Bundle.
Ein Tenant besteht aus `config` (Inhalt der tenant.json ohne deploy-Block) und
`files` ({pfad: sha}). Bearbeitet wird immer ein Entwurf (tenant.draft);
„Veröffentlichen“ schreibt daraus eine neue unveränderliche Version.
"""
from __future__ import annotations
import difflib
import hashlib
import io
import json
import tarfile
from copy import deepcopy
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import AuditLog, Blob, Tenant, Version
TEXT_SUFFIXES = {".txt", ".html", ".css", ".py", ".json", ".svg", ".md", ".yaml", ".yml", ".csv"}
RESERVED = {"tenant.json"} # wird aus config erzeugt
def is_text_path(path: str) -> bool:
return any(path.lower().endswith(s) for s in TEXT_SUFFIXES)
def put_blob(db: Session, data: bytes, path: str = "") -> str:
sha = hashlib.sha256(data).hexdigest()
if db.get(Blob, sha) is None:
db.add(Blob(sha=sha, content=data, size=len(data), is_text=is_text_path(path)))
db.flush()
return sha
def get_blob(db: Session, sha: str) -> bytes:
b = db.get(Blob, sha)
if b is None:
raise KeyError(sha)
return b.content
def audit(db: Session, actor: str, action: str, tenant_id: str | None = None, detail: str = "") -> None:
db.add(AuditLog(actor=actor, action=action, tenant_id=tenant_id, detail=detail))
def current_version(db: Session, t: Tenant) -> Version | None:
if t.current_version is None:
return None
return db.scalar(select(Version).where(Version.tenant_id == t.id, Version.number == t.current_version))
def get_version(db: Session, tenant_id: str, number: int) -> Version | None:
return db.scalar(select(Version).where(Version.tenant_id == tenant_id, Version.number == number))
def content(db: Session, t: Tenant, source: str = "draft") -> tuple[dict, dict]:
"""(config, files) für source = draft | current | v<N>. Draft fällt auf current zurück."""
if source == "draft" and t.draft:
return deepcopy(t.draft["config"]), dict(t.draft["files"])
if source.startswith("v") and source[1:].isdigit():
v = get_version(db, t.id, int(source[1:]))
else:
v = current_version(db, t)
if v is None:
return {}, {}
return deepcopy(v.config), dict(v.files)
def ensure_draft(db: Session, t: Tenant) -> dict:
"""Arbeitskopie des Entwurfs (nie das am ORM-Objekt hängende Dict mutieren)."""
if t.draft:
return json.loads(json.dumps(t.draft))
cfg, files = content(db, t, "current")
return {"config": cfg, "files": files}
def _save_draft(t: Tenant, draft: dict) -> None:
t.draft = json.loads(json.dumps(draft))
flag_modified(t, "draft")
def set_config(db: Session, t: Tenant, config: dict) -> None:
d = ensure_draft(db, t)
config = {k: v for k, v in config.items() if k != "deploy"}
config["id"] = t.id
d["config"] = config
_save_draft(t, d)
_sync_draft_state(db, t)
def set_file(db: Session, t: Tenant, path: str, data: bytes) -> str:
path = clean_path(path)
d = ensure_draft(db, t)
sha = put_blob(db, data, path)
d["files"][path] = sha
_save_draft(t, d)
_sync_draft_state(db, t)
return sha
def delete_file(db: Session, t: Tenant, path: str) -> None:
d = ensure_draft(db, t)
d["files"].pop(path, None)
_save_draft(t, d)
_sync_draft_state(db, t)
def clean_path(path: str) -> str:
path = path.strip().lstrip("/")
if not path or ".." in path.split("/") or path in RESERVED or "\\" in path:
raise ValueError(f"Ungültiger Dateipfad: {path!r}")
return path
def _sync_draft_state(db: Session, t: Tenant) -> None:
"""Entwurf, der identisch zur aktuellen Version ist, wieder löschen."""
if t.draft and not diff_summary(db, t, "current", "draft")["changed"]:
t.draft = None
def discard_draft(db: Session, t: Tenant) -> None:
t.draft = None
def publish(db: Session, t: Tenant, message: str, author: str) -> Version:
if not t.draft:
raise ValueError("Kein Entwurf zum Veröffentlichen.")
number = (t.current_version or 0) + 1
last = db.scalar(select(Version.number).where(Version.tenant_id == t.id).order_by(Version.number.desc()))
number = max(number, (last or 0) + 1)
v = Version(tenant_id=t.id, number=number, message=message.strip() or f"Version {number}", author=author,
config=t.draft["config"], files=t.draft["files"])
db.add(v)
t.current_version = number
t.draft = None
t.name = v.config.get("name", t.name)
audit(db, author, "version.publish", t.id, f"v{number}: {v.message}")
db.flush()
return v
def restore_as_draft(db: Session, t: Tenant, number: int) -> None:
v = get_version(db, t.id, number)
if v is None:
raise KeyError(number)
t.draft = {"config": deepcopy(v.config), "files": dict(v.files)}
_sync_draft_state(db, t)
# ── Diff ────────────────────────────────────────────────────────────────────
def diff_summary(db: Session, t: Tenant, a: str, b: str, with_text: bool = False) -> dict:
return diff_content(db, content(db, t, a), content(db, t, b), with_text)
def diff_content(db: Session, left: tuple[dict, dict], right: tuple[dict, dict], with_text: bool = False) -> dict:
ca, fa = left
cb, fb = right
items = []
if json.dumps(ca, sort_keys=True) != json.dumps(cb, sort_keys=True):
items.append(_item("tenant.json", "geändert", db, json.dumps(ca, indent=2, ensure_ascii=False, sort_keys=True),
json.dumps(cb, indent=2, ensure_ascii=False, sort_keys=True), True, with_text))
for path in sorted(set(fa) | set(fb)):
sa, sb = fa.get(path), fb.get(path)
if sa == sb:
continue
kind = "neu" if sa is None else "gelöscht" if sb is None else "geändert"
text = is_text_path(path)
ta = get_blob(db, sa).decode("utf-8", "replace") if (sa and text and with_text) else ""
tb = get_blob(db, sb).decode("utf-8", "replace") if (sb and text and with_text) else ""
items.append(_item(path, kind, db, ta, tb, text, with_text))
return {"changed": bool(items), "items": items}
def _item(path, kind, db, ta, tb, text, with_text):
out = {"path": path, "kind": kind, "text": text}
if with_text and text:
lines = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "vorher", "nachher", lineterm="", n=2))
out["diff"] = lines
out["added"] = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
out["removed"] = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
return out
# ── Bundle (genau das, was ein Tenant-Container beim Start abholt) ──────────
def tenant_json_bytes(config: dict) -> bytes:
return (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()
def materialize(db: Session, t: Tenant, source: str, target) -> dict:
"""Schreibt das Tenant-Verzeichnis (tenant.json + Dateien) nach `target`."""
cfg, files = content(db, t, source)
write_tenant_dir(db, cfg, files, target)
return cfg
def write_tenant_dir(db: Session, cfg: dict, files: dict, target) -> None:
target.mkdir(parents=True, exist_ok=True)
(target / "tenant.json").write_bytes(tenant_json_bytes(cfg))
for path, sha in files.items():
p = target / path
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(get_blob(db, sha))
def bundle_tar(db: Session, t: Tenant, source: str = "current") -> tuple[bytes, dict]:
cfg, files = content(db, t, source)
manifest = {"tenant": t.id, "version": t.current_version if source != "draft" else None,
"files": {"tenant.json": hashlib.sha256(tenant_json_bytes(cfg)).hexdigest(), **files}}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
def add(name, data):
ti = tarfile.TarInfo(name)
ti.size = len(data)
ti.mtime = 0
tar.addfile(ti, io.BytesIO(data))
add("tenant.json", tenant_json_bytes(cfg))
for path, sha in sorted(files.items()):
add(path, get_blob(db, sha))
add("MANIFEST.json", json.dumps(manifest, indent=2).encode())
return buf.getvalue(), manifest

95
app/services/tenants.py Normal file
View File

@@ -0,0 +1,95 @@
"""Tenant-Lebenszyklus: anlegen, klonen, Secrets, Cut-over-Check."""
from __future__ import annotations
import re
from copy import deepcopy
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Deployment, Secret, Tenant, Version
from app.services import crypto, defaults, store
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,38}$")
def create(db: Session, tenant_id: str, name: str, clone_from: str | None, actor: str) -> Tenant:
tenant_id = tenant_id.strip().lower()
if not SLUG.match(tenant_id):
raise ValueError("ID: nur a-z, 0-9 und Bindestrich, 2–39 Zeichen.")
if db.get(Tenant, tenant_id):
raise ValueError(f"Tenant „{tenant_id}“ existiert bereits.")
t = Tenant(id=tenant_id, name=name.strip() or tenant_id, subdomain=tenant_id, domain_status="none", source="admin",
oidc={"mode": "managed"})
db.add(t)
db.flush()
if clone_from:
src = db.get(Tenant, clone_from)
cfg, files = store.content(db, src, "current")
cfg = deepcopy(cfg)
cfg.update({"id": tenant_id, "name": t.name, "title": f"{t.name} – Gesprächsprotokoll",
"description": f"Gesprächsprotokoll-Tool für {t.name}", "ai_detect_org": t.name, "org_short": t.name})
# Personenbezogene/mandantenspezifische Dateien nicht mitklonen
files = {p: s for p, s in files.items() if not p.startswith("namensliste")}
t.cpu, t.memory = src.cpu, src.memory
else:
cfg, files = defaults.skeleton_config(tenant_id, t.name), {}
db.add(Version(tenant_id=tenant_id, number=1, message="Angelegt" + (f" (Kopie von {clone_from})" if clone_from else ""),
author=actor, config=cfg, files=files))
t.current_version = 1
for key, val in (("SESSION_SECRET", defaults.new_token()), ("ADMIN_BUNDLE_TOKEN", defaults.new_token())):
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=crypto.encrypt(val), source="manual"))
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=None, source="manual"))
store.audit(db, actor, "tenant.create", tenant_id, f"Kopie von {clone_from}" if clone_from else "leer")
db.flush()
return t
def secret_rows(db: Session, tenant_id: str) -> list[dict]:
rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))}
keys = list(dict.fromkeys(defaults.REQUIRED_SECRETS + [k for k in rows if k not in defaults.REQUIRED_SECRETS]))
out = []
for k in keys:
r = rows.get(k)
out.append({
"key": k, "set": bool(r and r.value_enc), "source": r.source if r else "",
"required": k in defaults.REQUIRED_SECRETS, "help": defaults.SECRET_HELP.get(k, ""),
"updated_at": r.updated_at.isoformat() if r and r.updated_at else None,
})
return out
def set_secret(db: Session, tenant_id: str, key: str, value: str | None, actor: str) -> None:
if not re.match(r"^[A-Z][A-Z0-9_]{1,60}$", key):
raise ValueError("Schlüssel: GROSSBUCHSTABEN_MIT_UNTERSTRICH")
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
if r is None:
r = Secret(tenant_id=tenant_id, key=key)
db.add(r)
r.value_enc = crypto.encrypt(value) if value else None
r.source = "manual"
store.audit(db, actor, "secret.set" if value else "secret.clear", tenant_id, key)
def check_bundle_token(db: Session, tenant_id: str, token: str) -> bool:
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "ADMIN_BUNDLE_TOKEN"))
return bool(r and r.value_enc and token and crypto.decrypt(r.value_enc) == token)
def cutover_checks(db: Session, t: Tenant, validation: dict | None) -> list[dict]:
secrets_ok = all(s["set"] for s in secret_rows(db, t.id) if s["required"])
imported = db.scalar(select(Version).where(Version.tenant_id == t.id, Version.author == "import")) is not None
dry = db.scalar(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "dry-run",
Deployment.status == "success").order_by(Deployment.id.desc()))
return [
{"id": "content", "label": "Inhalt aus Git importiert & Hash-gleich verifiziert" if imported else "Inhalt in der Datenbank",
"ok": True},
{"id": "validate", "label": "Validierung (Core-Rendering von Web-UI und PDF)", "ok": bool(validation and validation.get("ok"))},
{"id": "secrets", "label": "Alle Pflicht-Secrets in der Admin-DB gesetzt", "ok": secrets_ok},
{"id": "oidc", "label": "OIDC-Client abgeglichen (Login-API)" if (t.oidc or {}).get("mode", "adopt") != "off" else "OIDC-Verwaltung aus",
"ok": (t.oidc or {}).get("mode", "adopt") == "off" or bool(((t.oidc or {}).get("last_sync") or {}).get("ok"))},
{"id": "domain", "label": "Domain verifiziert", "ok": t.domain_status == "verified"},
{"id": "dryrun", "label": "Dry-Run erfolgreich", "ok": dry is not None},
{"id": "published", "label": "Keine unveröffentlichten Änderungen", "ok": not t.draft},
]

33
app/settings.py Normal file
View File

@@ -0,0 +1,33 @@
"""Zentrale Konfiguration (Env-getrieben)."""
from __future__ import annotations
import base64
import hashlib
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if not os.getenv("ONYSCRIBE_SKIP_DOTENV"): # lokale Entwicklung: .env im Projektordner einlesen (Docker/Coolify setzen die Env direkt)
try:
from dotenv import load_dotenv
load_dotenv(ROOT / ".env", override=False) # echte Umgebungsvariablen haben Vorrang
except ImportError:
pass
DATA = ROOT / ".data"
DATA.mkdir(exist_ok=True)
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA / 'onyscribe-admin.db'}")
# Entwicklungs-Fallback: abgeleiteter Key. In Produktion SECRET_KEY setzen!
_raw = os.getenv("SECRET_KEY", "").strip()
SECRET_KEY = _raw or base64.urlsafe_b64encode(hashlib.sha256(b"onyscribe-admin-dev").digest()).decode()
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true"
# Vendored Snapshot des Plattform-Cores (Preview führt den echten Core aus)
PLATFORM_DIR = Path(os.getenv("PLATFORM_DIR", ROOT / "vendor" / "platform"))
PREVIEW_CACHE = ROOT / ".preview-cache"
PREVIEW_CACHE.mkdir(exist_ok=True)
# Pfad zur bestehenden transkript-platform (nur für den Importer)
LEGACY_PLATFORM = Path(os.getenv("LEGACY_PLATFORM", ROOT.parent / "transkript-platform"))
APP_NAME = "Onyscribe Admin"

170
app/static/admin.css Normal file
View File

@@ -0,0 +1,170 @@
:root{
--creme:#f4efe8; --dark:#1a1a1a; --cyan:#00cccc; --cyan-d:#009e9e; --white:#fff; --stone:#eceae4; --mist:#f7f4f0;
--border:#ddd8d0; --gray:#888; --mid:#555; --ok:#2a8a2a; --warn:#e0a000; --err:#c0392b;
--font:"Liberation Sans",Arial,Helvetica,sans-serif; --mono:ui-monospace,"JetBrains Mono","Liberation Mono",Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{font-family:var(--font);background:var(--creme);color:#2c2c2c;font-size:13px;line-height:1.45;display:flex}
a{color:inherit}
h1,h2,h3{margin:0;color:var(--dark)}
button,input,select,textarea{font:inherit}
.muted{color:var(--gray)} .mono{font-family:var(--mono);font-size:12px} .grow{flex:1;min-width:0}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.nowrap{white-space:nowrap}
/* ── Sidebar ─────────────────────────────────────────────── */
.side{width:196px;flex:none;background:var(--dark);color:#d8d4cc;display:flex;flex-direction:column;height:100vh;position:sticky;top:0;border-right:2px solid var(--cyan)}
.brand{padding:18px 18px 12px}
.brand b{color:#fff;font-size:19px;letter-spacing:-.3px}
.brand span{display:block;color:var(--cyan);font-size:9.5px;font-weight:700;letter-spacing:2px;text-transform:uppercase}
.nav{padding:6px 8px;display:flex;flex-direction:column;gap:1px}
.nav a{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;text-decoration:none;color:#bdb8ae;font-weight:600}
.nav a:hover{background:#262626;color:#fff} .nav a.on{background:#262626;color:#fff;box-shadow:inset 3px 0 0 var(--cyan)}
.nav svg{width:15px;height:15px;flex:none}
.side-foot{margin-top:auto;padding:12px 16px;font-size:11px;color:#8a867e;border-top:1px solid #2c2c2c;display:flex;flex-direction:column;gap:6px}
.pill{display:inline-block;border-radius:4px;padding:1px 7px;font-weight:700;letter-spacing:.8px;font-size:9.5px;text-transform:uppercase;width:fit-content}
.pill.lock{background:#3a2f12;color:#f0c55a} .pill.live{background:#4a1d17;color:#ff9a8a} .pill.dev{background:#2c2c2c;color:var(--cyan)}
.side-foot a{color:#bdb8ae}
/* ── Seiten-Layout ───────────────────────────────────────── */
.main{flex:1;min-width:0;display:flex;flex-direction:column;height:100vh;overflow:auto}
.page{padding:22px 28px 50px;max-width:1500px;width:100%}
.eyebrow{font-size:10.5px;font-weight:700;letter-spacing:1.8px;color:var(--gray);text-transform:uppercase}
.eyebrow::before{content:"— ";color:var(--cyan)}
.title{font-size:24px;font-weight:700;letter-spacing:-.4px;margin:2px 0 0}
.lead{color:var(--mid);margin:4px 0 0;max-width:760px}
.head{display:flex;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap}
/* ── Cards ───────────────────────────────────────────────── */
.card{background:var(--white);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
.card + .card,.card + .grid2,.grid2 + .card{margin-top:12px}
.card h3{font-size:13.5px;margin-bottom:8px}
.card .sub{color:var(--gray);font-size:12px;margin:-4px 0 10px}
.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.grid2 > .card{margin:0!important}
.kpis{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.kpi{background:var(--white);border:1px solid var(--border);border-radius:9px;padding:7px 13px;display:flex;gap:9px;align-items:baseline;cursor:default}
.kpi b{font-size:18px;color:var(--dark)} .kpi span{color:var(--gray);font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
.kpi.click{cursor:pointer} .kpi.click:hover{border-color:var(--cyan)}
/* ── Badges / Buttons ────────────────────────────────────── */
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10.5px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--stone);color:var(--mid);white-space:nowrap;line-height:1.6}
.badge.ok{background:#e3f3e3;color:var(--ok)} .badge.warn{background:#fbf0cf;color:#8a6200}
.badge.err{background:#f8dcd8;color:var(--err)} .badge.cyan{background:#d6f5f5;color:#007878} .badge.dark{background:var(--dark);color:#fff}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);background:var(--white);color:var(--dark);border-radius:8px;padding:6px 12px;font-weight:700;cursor:pointer;text-decoration:none;font-size:12.5px;white-space:nowrap}
.btn:hover{border-color:var(--dark)} .btn.primary{background:var(--dark);color:#fff;border-color:var(--dark)}
.btn.cyan{background:var(--cyan);border-color:var(--cyan);color:#002b2b} .btn.cyan:hover{background:var(--cyan-d);border-color:var(--cyan-d);color:#fff}
.btn.danger{color:var(--err)} .btn.danger:hover{border-color:var(--err)} .btn.sm{padding:3px 9px;font-size:11.5px;border-radius:6px}
.btn[disabled],.btn.disabled{opacity:.45;pointer-events:none}
.seg{display:inline-flex;background:var(--stone);border-radius:8px;padding:2px;gap:1px}
.seg button{border:0;background:none;padding:4px 10px;border-radius:6px;font-weight:700;color:var(--mid);cursor:pointer;font-size:12px}
.seg button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 2px rgba(0,0,0,.14)}
.chipbar{display:flex;gap:5px;flex-wrap:wrap}
.fchip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:3px 11px;font-size:12px;font-weight:700;color:var(--mid);cursor:pointer}
.fchip.on{background:var(--dark);color:#fff;border-color:var(--dark)}
input.search{border:1px solid var(--border);border-radius:8px;padding:6px 10px;min-width:220px;background:#fff}
/* ── Tabellen ────────────────────────────────────────────── */
table.t{width:100%;border-collapse:collapse}
table.t th{text-align:left;font-size:10.5px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--gray);padding:6px 9px;border-bottom:1px solid var(--border);white-space:nowrap}
table.t td{padding:7px 9px;border-bottom:1px solid var(--stone);vertical-align:middle}
table.t tr:last-child td{border-bottom:0}
table.t.hover tbody tr{cursor:pointer} table.t.hover tbody tr:hover td{background:var(--mist)}
.logo-box{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--mist);display:grid;place-items:center;flex:none;overflow:hidden}
.logo-box img{max-width:28px;max-height:28px}
.logo-box.lg{width:120px;height:56px} .logo-box.lg img{max-width:104px;max-height:44px}
.swatches{display:inline-flex;gap:2px;vertical-align:middle}
.swatches i{width:12px;height:12px;border-radius:3px;border:1px solid rgba(0,0,0,.14);display:block}
.dotc{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;background:var(--stone)}
.dotc.ok{background:var(--ok)} .dotc.warn{background:var(--warn)} .dotc.err{background:var(--err)} .dotc.cyan{background:var(--cyan)}
/* ── Tenant-Seite ────────────────────────────────────────── */
.thead{padding:12px 20px 10px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--creme);position:sticky;top:0;z-index:6}
.thead h1{font-size:17px;letter-spacing:-.2px;line-height:1.2}
.crumb{font-size:11px;color:var(--gray)} .crumb a{text-decoration:none} .crumb a:hover{color:var(--dark)}
.dirty{display:inline-flex;gap:6px;align-items:center;font-size:11.5px;font-weight:700;color:#8a6200;background:#fbf0cf;border-radius:999px;padding:2px 10px}
.dirty::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--warn)}
.mid{min-width:0}
.tl{display:grid;grid-template-columns:176px minmax(0,1fr);flex:1;min-height:0;align-items:start}
.tl.open{grid-template-columns:176px minmax(340px,5fr) minmax(360px,6fr)}
.snav{position:sticky;top:53px;padding:12px 8px 20px 14px;display:flex;flex-direction:column;gap:1px;max-height:calc(100vh - 53px);overflow:auto}
.snav h6{margin:12px 8px 3px;font-size:9.5px;letter-spacing:1.6px;text-transform:uppercase;color:var(--gray)}
.snav h6:first-child{margin-top:0}
.snav button{display:flex;align-items:center;gap:7px;border:0;background:none;text-align:left;padding:6px 9px;border-radius:7px;font-weight:600;color:var(--mid);cursor:pointer;width:100%}
.snav button:hover{background:var(--stone);color:var(--dark)} .snav button.on{background:var(--white);color:var(--dark);box-shadow:0 1px 3px rgba(0,0,0,.1),inset 3px 0 0 var(--cyan)}
.snav .n{margin-left:auto;font-size:10.5px}
.pane{padding:14px 18px 60px;min-width:0;container-type:inline-size}
@container (max-width:780px){.grid2{grid-template-columns:1fr}.fgrid.c3{grid-template-columns:1fr 1fr}}
.panel{position:sticky;top:53px;height:calc(100vh - 53px);padding:10px 20px 14px 4px;display:none;flex-direction:column;gap:8px;min-width:0}
.tl.open .panel{display:flex}
.pbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.pv-frame{flex:1;background:var(--white);border:1px solid var(--border);border-radius:10px;position:relative;overflow:hidden;display:flex;justify-content:center;background-image:radial-gradient(#e6e1d8 1px,transparent 1px);background-size:14px 14px}
.pv-frame iframe{border:0;background:#fff;height:100%;width:100%;transition:width .2s;box-shadow:0 0 0 1px var(--border)}
.pv-frame.tablet iframe{width:768px;max-width:100%} .pv-frame.mobile iframe{width:390px;max-width:100%}
.pv-load{position:absolute;inset:0;background:rgba(244,239,232,.72);display:none;place-items:center;font-weight:700;color:var(--mid)} .pv-load.on{display:grid}
.pv-err{position:absolute;inset:0;background:#fff;padding:16px;overflow:auto;display:none;color:var(--err);font-family:var(--mono);font-size:12px;white-space:pre-wrap} .pv-err.on{display:block}
.hide{display:none!important}
/* ── Formulare ───────────────────────────────────────────── */
.fgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px} .fgrid.c3{grid-template-columns:repeat(3,minmax(0,1fr))}
.fgrid .full{grid-column:1/-1}
label.f{display:block;font-size:10.5px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--gray)}
label.f > input,label.f > select,label.f > textarea{display:block;width:100%;margin-top:3px;text-transform:none;letter-spacing:0;font-weight:400;color:#222}
input[type=text],input[type=email],input[type=url],input[type=password],input[type=number],select,textarea{border:1px solid var(--border);border-radius:7px;padding:5px 9px;background:#fff;outline:none;font-size:12.5px}
input:focus,select:focus,textarea:focus{border-color:var(--cyan);box-shadow:0 0 0 3px rgba(0,204,204,.16)}
textarea{width:100%;resize:vertical;line-height:1.5}
textarea.code{font-family:var(--mono);font-size:12px;tab-size:4;min-height:300px;background:#fbfaf8;white-space:pre}
.colors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 14px}
.colorf{display:flex;gap:7px;align-items:center;min-width:0}
.colorf input[type=color]{width:24px;height:24px;border:1px solid var(--border);border-radius:5px;padding:0;background:none;cursor:pointer;flex:none}
.colorf span{font-size:11px;color:var(--mid);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.colorf input[type=text]{width:74px;padding:2px 5px;font-family:var(--mono);font-size:11.5px}
.chk{display:flex;gap:8px;align-items:flex-start;padding:5px 0}.chk input{margin-top:2px;accent-color:var(--dark)}
.hint{font-size:11.5px;color:var(--gray)}
.banner{border-radius:8px;padding:7px 12px;font-size:12.5px;margin-bottom:10px;border:1px solid}
.banner.warn{background:#fdf6df;border-color:#ecd48a;color:#6f5200} .banner.info{background:#e6f8f8;border-color:#a8e6e6;color:#005e5e}
.banner.err{background:#fbe6e3;border-color:#efb5ad;color:#8e2418} .banner.ok{background:#e8f5e8;border-color:#b3dcb3;color:#1e661e}
details.fold{border:1px solid var(--border);border-radius:9px;background:var(--white);margin-top:10px}
details.fold > summary{cursor:pointer;padding:8px 13px;font-weight:700;list-style:none;display:flex;gap:8px;align-items:center}
details.fold > summary::before{content:"▸";color:var(--gray);transition:.15s} details.fold[open] > summary::before{transform:rotate(90deg)}
details.fold > .in{padding:2px 14px 13px}
.kv{display:grid;grid-template-columns:104px 1fr;gap:5px 10px;font-size:12.5px} .kv dt{color:var(--gray);font-weight:700;font-size:10.5px;letter-spacing:.9px;text-transform:uppercase;padding-top:2px} .kv dd{margin:0}
.check{display:flex;gap:8px;padding:4px 0;align-items:center;font-size:12.5px}
.dot{width:17px;height:17px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;flex:none;background:var(--stone);color:var(--gray)}
.dot.ok{background:var(--ok);color:#fff} .dot.no{background:#f2d9d5;color:var(--err)}
.console{background:#141414;color:#d6f5e6;font-family:var(--mono);font-size:12px;border-radius:8px;padding:11px 13px;white-space:pre-wrap;min-height:90px;max-height:340px;overflow:auto}
.console .sim{color:#7fd8d8}
pre.json{background:#141414;color:#e6e6e6;border-radius:8px;padding:11px;font-size:11.5px;overflow:auto;max-height:300px;font-family:var(--mono);margin:6px 0 0}
.diff{font-family:var(--mono);font-size:11.5px;background:#fbfaf8;border:1px solid var(--border);border-radius:7px;overflow:auto;max-height:260px;margin:5px 0 10px}
.diff div{padding:0 9px;white-space:pre} .diff .a{background:#e3f6e3;color:#1c5a1c} .diff .r{background:#fbe3df;color:#8e2418} .diff .h{color:var(--gray);background:var(--stone)}
.empty{padding:22px;text-align:center;color:var(--gray)}
.copy{cursor:pointer;border-bottom:1px dashed var(--gray)}
/* ── Agent-Chat ──────────────────────────────────────────── */
.chat{display:flex;flex-direction:column;flex:1;min-height:0}
.agpane{position:sticky;top:53px;height:calc(100vh - 53px);padding:12px 12px 14px 18px}
.agpane.hide{display:none!important}
.msgs{flex:1;overflow:auto;display:flex;flex-direction:column;gap:9px;padding:2px 2px 10px}
.bub{max-width:94%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;font-size:12.5px}
.bub.user{align-self:flex-end;background:var(--dark);color:#fff;border-bottom-right-radius:3px}
.bub.assistant{align-self:flex-start;background:var(--white);border:1px solid var(--border);border-bottom-left-radius:3px}
.steps{display:flex;flex-direction:column;gap:3px;margin:0 0 6px}
.step{display:flex;gap:6px;align-items:baseline;font-size:11.5px;color:var(--mid);background:var(--mist);border:1px solid var(--stone);border-radius:6px;padding:2px 8px;white-space:normal;overflow-wrap:anywhere}
.step b{font-family:var(--mono);color:#007878;flex:none} .step.err{background:#fbe6e3;border-color:#efb5ad} .step.err b{color:var(--err)}
.thought{color:var(--gray);font-style:italic;font-size:12px;margin:0 0 5px}
.prop{margin-top:8px;border:1px dashed var(--cyan);border-radius:8px;padding:8px 10px;background:#f0fbfb}
.chat-in{display:flex;gap:6px;border-top:1px solid var(--border);padding-top:8px}
.chat-in textarea{min-height:38px;max-height:120px}
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:7px}
.chip{border:1px solid var(--border);background:var(--white);border-radius:999px;padding:2px 10px;font-size:11.5px;cursor:pointer}.chip:hover{border-color:var(--cyan)}
.typing{align-self:flex-start;color:var(--gray);font-size:11.5px}
/* ── Modal / Toast ───────────────────────────────────────── */
.modal-bg{position:fixed;inset:0;background:rgba(20,20,20,.5);display:none;place-items:center;z-index:50;padding:20px}.modal-bg.on{display:grid}
.modal{background:var(--creme);border-radius:12px;width:min(780px,100%);max-height:90vh;overflow:auto;padding:20px 22px;border:1px solid var(--border);box-shadow:0 20px 60px rgba(0,0,0,.35)}
.modal h2{font-size:17px;margin-bottom:10px}.modal .acts{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
#toast{position:fixed;bottom:18px;right:18px;background:var(--dark);color:#fff;padding:9px 15px;border-radius:9px;font-weight:700;z-index:99;display:none;border-left:4px solid var(--cyan);max-width:420px}#toast.err{border-color:var(--err)}
@media (max-width:1250px){.grid2{grid-template-columns:1fr}.tl.open{grid-template-columns:150px minmax(300px,1fr) minmax(320px,1fr)}}
@media (max-width:800px){.side{width:56px}.side .brand span,.nav a span,.side-foot{display:none}.tl,.tl.open{grid-template-columns:1fr}.snav{position:static;flex-direction:row;flex-wrap:wrap;max-height:none}.snav h6{display:none}}

598
app/static/tenant.js Normal file
View File

@@ -0,0 +1,598 @@
/* Tenant-Detailseite: eine Navigation (links) · Formular (Mitte) · Vorschau/Agent (rechts) */
let S = null;
let SCHEMA = null;
const ui = {sec: 'overview', mode: 'edit', open: true, pvKind: 'web', pvSrc: 'draft', pvDev: '', agentSrc: null};
const SECTIONS = [
['overview', 'Übersicht', null, false],
['brand', 'Marke & Farben', 'Gestaltung', true], ['texts', 'Absender & PDF-Texte', 'Gestaltung', true], ['pdf', 'PDF-Layout', 'Gestaltung', true],
['prompt', 'Prompt', 'Inhalt', true], ['files', 'Dateien', 'Inhalt', true],
['func', 'Funktion & STT', 'Verhalten', true], ['code', 'Hook-Code', 'Verhalten', true], ['adv', 'tenant.json', 'Verhalten', true],
['domain', 'Domain & Login', 'Betrieb', false], ['secrets', 'Secrets', 'Betrieb', false], ['deploy', 'Deploy', 'Betrieb', false], ['versions', 'Verlauf', 'Betrieb', false],
];
const A = p => `/api/tenants/${TID}${p}`;
const $ = s => document.querySelector(s);
const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
function setPath(o, p, v){ const ks = p.split('.'); let c = o; ks.slice(0,-1).forEach(k => c = (c[k] ??= {})); c[ks.at(-1)] = v; }
function unsetPath(o, p){ const ks = p.split('.'); const chain = [o]; let c = o; for(const k of ks.slice(0,-1)){ c = c?.[k]; if(c == null) return; chain.push(c); } delete c[ks.at(-1)]; for(let i = ks.length - 2; i >= 0; i--){ if(Object.keys(chain[i+1]).length === 0) delete chain[i][ks[i]]; else break; } }
function debounce(fn, ms){ let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
function shade(hex, f){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.max(0,Math.min(255,Math.round(x*f)))); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
function tint(hex, a){ const n=parseInt(hex.slice(1),16); const c=[n>>16,(n>>8)&255,n&255].map(x=>Math.round(x+(255-x)*a)); return '#'+c.map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase(); }
const copyText = t => navigator.clipboard.writeText(t).then(()=>toast('Kopiert'));
const secDef = k => SECTIONS.find(s => s[0] === k);
const kindLabel = {'dry-run':'Dry-Run','deploy':'Deploy','offboard-dry':'Offboard (Dry-Run)','offboard':'Offboard'};
/* ───────── Laden & Rahmen ───────── */
async function loadSchema(){ try{ const c = await api('GET', '/api/contract'); SCHEMA = c.schema; }catch(e){ SCHEMA = null; } }
async function load(keep){
S = await api('GET', A('/state'));
renderHead(); renderNav();
if(!keep) route();
}
function route(){
const h = (location.hash||'').slice(1);
if(secDef(h)) ui.sec = h;
renderSection();
}
window.addEventListener('hashchange', route);
function renderHead(){
$('#h-name').textContent = S.name;
const st = S.status !== 'active' ? `<span class="badge warn">${S.status}</span>` : '';
$('#h-badges').innerHTML = `${S.has_draft?'<span class="dirty">Entwurf</span>':''}${st}<span class="badge dark">v${S.current_version}</span>
<span class="badge ${S.deployed_version===S.current_version?'ok':'warn'}">deployt v${S.deployed_version??'–'}</span>
<span class="badge ${S.source==='admin'?'cyan':''}">Quelle: ${S.source==='admin'?'Admin':'Git'}</span>`;
$('#h-actions').innerHTML = S.has_draft
? `<button class="btn sm" onclick="discardDraft()">Verwerfen</button><button class="btn sm cyan" onclick="openPublish()">Prüfen &amp; veröffentlichen</button>` : '';
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
}
function renderNav(extra){
let g = null, out = '';
const nfiles = Object.keys(S.files).length;
const badges = {files: nfiles ? `<span class="n muted">${nfiles}</span>` : '', versions: `<span class="n muted">${S.current_version}</span>`,
secrets: extra?.missing ? `<span class="n"><span class="dotc err" style="margin:0"></span></span>` : '', code: S.files['overrides.py'] ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : '',
deploy: S.deployed_version !== S.current_version ? '<span class="n"><span class="dotc warn" style="margin:0"></span></span>' : ''};
for(const [k, l, grp] of SECTIONS){
if(grp !== g){ g = grp; if(grp) out += `<h6>${grp}</h6>`; }
out += `<button class="${ui.sec===k&&ui.mode==='edit'?'on':''}" onclick="gotoSec('${k}')">${l}${badges[k]||''}</button>`;
}
$('#snav').innerHTML = out;
}
function gotoSec(k){ ui.mode = 'edit'; if(location.hash === '#'+k) route(); else location.hash = k; }
/* Layout: Mitte = Formular ODER Agent-Chat; die Live-Vorschau rechts bleibt in beiden Fällen sichtbar (Betriebsbereiche ohne Vorschau). */
function layout(){
ui.open = secDef(ui.sec)[3] || ui.mode === 'agent';
$('#tl').classList.toggle('open', ui.open);
$('#pane').classList.toggle('hide', ui.mode === 'agent');
$('#ag-wrap').classList.toggle('hide', ui.mode !== 'agent');
document.querySelectorAll('#h-panel button').forEach(b => b.classList.toggle('on', b.dataset.p === ui.mode));
if(ui.open) refreshPreview();
}
$('#h-panel').addEventListener('click', e => { const b = e.target.closest('button'); if(!b) return; ui.mode = b.dataset.p; renderNav(); layout(); if(ui.mode === 'agent') $('#chat-text').focus(); });
function renderSection(){
renderNav(); layout();
({overview: sOverview, brand: sBrand, texts: sTexts, pdf: sPdf, prompt: sPrompt, files: sFiles, func: sFunc, code: sCode, adv: sAdv,
domain: sDomain, secrets: sSecrets, deploy: sDeploy, versions: sVersions})[ui.sec]();
}
/* ───────── Speichern (Entwurf) ───────── */
const saveState = txt => { const e = $('#save-ind'); if(e) e.textContent = txt; };
function afterSave(r){
S.has_draft = r.has_draft; renderHead(); saveState(r.has_draft ? 'Entwurf gespeichert' : 'Entspricht der veröffentlichten Version');
refreshPreviewSoon();
}
const saveConfigNow = async () => { try { saveState('Speichere …'); afterSave(await api('PUT', A('/config'), S.config)); } catch(e){ toast(e.message,1); saveState('Fehler'); } };
const saveConfig = debounce(saveConfigNow, 500);
async function saveFileNow(path, content){ try { saveState('Speichere …'); afterSave(await api('PUT', A('/files/'+path), {content})); } catch(e){ toast(e.message,1); saveState(e.message); } }
const saveFile = debounce(saveFileNow, 600);
async function deleteFile(path){ afterSave(await api('DELETE', A('/files/'+path))); await load(true); renderSection(); }
async function uploadFile(input, path){
const f = input.files[0]; if(!f) return;
const fd = new FormData(); fd.append('file', f);
const r = await fetch(A('/upload?path='+encodeURIComponent(path||f.name)), {method:'POST', body: fd});
if(!r.ok){ toast((await r.json()).detail||'Upload fehlgeschlagen',1); return; }
await load(true); renderSection(); refreshPreviewSoon(); toast('Hochgeladen'); headLogo(); renderHead();
}
function headLogo(){ const n = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null; $('#h-logo').innerHTML = n?`<img src="${A('/files/'+n)}?source=draft&t=${Date.now()}" alt="">`:''; }
async function discardDraft(){
if(!confirm('Alle Änderungen im Entwurf verwerfen?')) return;
await api('POST', A('/draft/discard')); await load(true); renderSection(); headLogo(); refreshPreview(true); toast('Entwurf verworfen');
}
const saveInd = () => `<span class="hint" id="save-ind">${S.has_draft?'Entwurf vorhanden':'Entspricht der veröffentlichten Version'}</span>`;
const title = (t, sub) => `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px" class="grow">${t}${sub?` <span class="muted" style="font-weight:400;font-size:12px">${sub}</span>`:''}</h2>${saveInd()}</div>`;
/* ───────── Live-Vorschau ───────── */
let pvSeq = 0;
async function refreshPreview(force){
if(!ui.open) return;
const seq = ++pvSeq, kind = ui.pvKind;
$('#f-web').style.display = kind==='web'?'':'none'; $('#f-pdf').style.display = kind==='pdf'?'':'none';
$('#pv-frame').className = 'pv-frame ' + (kind==='web'?ui.pvDev:'');
$('#pv-dev').style.display = kind==='web'?'':'none';
$('#pv-load').classList.add('on'); $('#pv-err').classList.remove('on');
const t0 = performance.now();
try {
const src = ui.pvSrc === 'agent' ? (ui.agentSrc || 'draft') : ui.pvSrc;
const r = await fetch(`/preview/${TID}/${kind}?source=${encodeURIComponent(src)}&_=${force?Date.now():''}`);
if(seq !== pvSeq) return;
if(!r.ok){ $('#pv-err').textContent = await r.text(); $('#pv-err').classList.add('on'); }
else if(kind==='web'){ $('#f-web').srcdoc = await r.text(); }
else { const b = await r.blob(); $('#f-pdf').src = URL.createObjectURL(b) + '#toolbar=0&navpanes=0'; }
$('#pv-info').textContent = `Core ${S.core_version} · ${Math.round(performance.now()-t0)} ms`;
} catch(e){ $('#pv-err').textContent = String(e); $('#pv-err').classList.add('on'); }
$('#pv-load').classList.remove('on');
}
const refreshPreviewSoon = debounce(() => refreshPreview(), 900);
const refreshPreviewLive = debounce(() => refreshPreview(), 250);
function setPvSrc(src){
ui.pvSrc = src; $('#pv-agent').classList.toggle('hide', !ui.agentSrc);
document.querySelectorAll('#pv-src button').forEach(x => x.classList.toggle('on', x.dataset.s === src));
}
document.addEventListener('click', e => {
const b = e.target.closest('#pv-kind button, #pv-dev button, #pv-src button'); if(!b) return;
const g = b.parentElement.id;
b.parentElement.querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
if(g==='pv-kind') ui.pvKind = b.dataset.k; if(g==='pv-dev') ui.pvDev = b.dataset.d; if(g==='pv-src') ui.pvSrc = b.dataset.s;
refreshPreview();
});
/* ───────── Übersicht ───────── */
async function sOverview(){
const c = S.config, col = c.colors||{};
$('#pane').innerHTML = `
<div class="grid2">
<div class="card"><h3>Status</h3>
<dl class="kv">
<dt>URL</dt><dd><a href="https://${esc(S.host)}" target="_blank">${esc(S.host)}</a> <span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'Domain ok':S.domain_status==='pending'?'DNS offen':'nicht eingerichtet'}</span></dd>
<dt>Version</dt><dd>v${S.current_version} veröffentlicht · v${S.deployed_version??'–'} deployt ${S.deployed_version!==S.current_version?'<span class="badge warn">veraltet</span>':''}</dd>
<dt>Quelle</dt><dd>${S.source==='admin'?'Admin (Bundle zur Laufzeit)':'Git – ins Image gebacken'}</dd>
<dt>Core</dt><dd><span class="mono">:${esc(String(S.core.tag).slice(0,12))}</span> ${S.core.pin?'<span class="badge warn">gepinnt</span>':'<span class="badge">folgt Rollouts</span>'} <span class="hint">· Admin-Vorschau: ${esc(S.core.sha)}</span>
<span id="run-state"></span> <button class="btn sm" onclick="probeMe()">Läuft?</button></dd>
<dt>STT</dt><dd>${esc((c.stt||{}).provider||'mistral')} ${esc((c.stt||{}).model||'')}</dd>
<dt>Dateien</dt><dd>${Object.keys(S.files).map(f=>`<span class="badge">${esc(f)}</span>`).join(' ')||'–'}</dd>
<dt>Marke</dt><dd class="swatches" style="gap:3px">${Object.entries(col).map(([k,v])=>`<i title="${esc(S.color_labels[k]||k)} ${esc(v)}" style="width:16px;height:16px;background:${esc(v)}"></i>`).join('')}</dd>
</dl>
<div class="row" style="margin-top:10px"><a class="btn sm" href="/tenants/${TID}/bundle.tar.gz">Bundle (.tar.gz)</a><button class="btn sm" onclick="runValidate()">Validieren</button></div>
<div id="val" style="margin-top:8px"></div>
</div>
<div class="card"><h3>Cut-over auf die Admin-Plattform</h3>
<p class="sub">Bis alles grün ist, bleibt der Tenant auf <b>Git</b>. Umschalten = <span class="mono">TENANT_SOURCE=admin</span> beim nächsten Deploy.</p>
<div id="cut"><div class="muted">Prüfe …</div></div>
</div>
</div>
<div class="grid2" style="margin-top:12px">
<div class="card"><h3>Betrieb</h3>
<div class="fgrid c3"><label class="f">CPU<input type="number" step="0.25" min="0.1" max="8" id="m-cpu" value="${S.cpu}"></label>
<label class="f">RAM<input type="text" id="m-mem" value="${esc(S.memory)}"></label>
<label class="f">Status<select id="m-status">${['active','paused','archived'].map(x=>`<option ${S.status===x?'selected':''}>${x}</option>`).join('')}</select></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveMeta()">Speichern</button><span class="hint">Ressourcen wirken beim nächsten Deploy. „paused/archived“ nimmt den Tenant aus „Alle neu deployen“.</span></div>
</div>
<div class="card"><h3>Notizen</h3>
<textarea id="m-notes" rows="4" placeholder="Ansprechpartner, Besonderheiten, Vereinbarungen …">${esc(S.notes||'')}</textarea>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveMeta()">Speichern</button></div>
</div>
</div>
<div class="card" style="margin-top:12px"><h3>Core-Version</h3><p class="sub">Neue Core-Releases rollt die Admin-Plattform zentral aus (Menü „Core“). Ein <b>Pin</b> hält diesen Tenant auf einem festen Tag – er nimmt dann an Rollouts nicht teil.</p>
<div class="row"><input type="text" id="m-pin" value="${esc(S.core.pin||'')}" placeholder="kein Pin (folgt dem aktiven Core)" style="width:260px"><button class="btn sm" onclick="savePin()">Pin setzen / aufheben</button></div></div>
<details class="fold"><summary>Gefahrenbereich</summary><div class="in">
<p class="hint">Offboard entfernt Service, Volume und Ingress aus dem Mittwald-Stack (Dry-Run zeigt vorher, was passiert). Löschen entfernt nur die Daten in dieser Datenbank – und nur für archivierte Tenants.</p>
<div class="row"><button class="btn sm" onclick="offboard(true)">Offboard-Dry-Run</button><button class="btn sm danger ${S.live?'':'disabled'}" id="b-off" onclick="offboard(false)">Offboard (live)</button>
<button class="btn sm danger" onclick="deleteOidcApp()">OIDC-Client im Login löschen …</button><button class="btn sm danger" onclick="deleteTenant()">Tenant in der DB löschen …</button></div><div class="console hide" id="con-off" style="margin-top:8px"></div></div></details>`;
const [cut, mode, sec] = await Promise.all([api('GET', A('/cutover')), api('GET', '/api/deploy/mode'), api('GET', A('/secrets'))]);
renderNav({missing: sec.some(s => s.required && !s.set)});
$('#b-off')?.classList.toggle('disabled', !mode.live);
const ok = cut.checks.every(x=>x.ok);
$('#cut').innerHTML = cut.checks.map(x=>`<div class="check"><span class="dot ${x.ok?'ok':'no'}">${x.ok?'✓':'!'}</span><span>${esc(x.label)}</span></div>`).join('') +
`<div class="row" style="margin-top:10px">${cut.source==='admin'
? `<span class="badge cyan">Quelle: Admin</span><button class="btn sm" onclick="setSource('git')">Zurück auf Git</button>`
: `<button class="btn sm cyan" ${ok?'':'disabled'} onclick="setSource('admin')">Auf Admin-Quelle umstellen</button>${ok?'':'<span class="hint">noch nicht alle Bedingungen erfüllt</span>'}`}</div>`;
}
async function savePin(){ try{ await api('PUT', A('/core-pin'), {pin: $('#m-pin').value}); await load(true); toast('Core-Pin gespeichert – wirksam beim nächsten Deploy'); sOverview(); }catch(e){ toast(e.message,1) } }
async function probeMe(){
$('#run-state').innerHTML = '<span class="muted">prüfe …</span>';
try{ const r = (await api('GET', '/api/status?refresh=1'))[TID]; $('#run-state').innerHTML = !r ? '' : r.reachable && !r.drift.length ? `<span class="badge ok">läuft: Core ${esc((r.core?.version||'?').slice(0,10))}${r.bundle?.source==='admin'?' · Bundle v'+r.bundle.version:''}</span>` : `<span class="badge err">${esc(r.drift.join(' · ')||'nicht erreichbar')}</span>`; }
catch(e){ $('#run-state').innerHTML = `<span class="badge err">${esc(e.message)}</span>`; }
}
async function saveMeta(){ try{ await api('PUT', A('/meta'), {cpu:$('#m-cpu').value, memory:$('#m-mem').value, status:$('#m-status').value, notes:$('#m-notes').value}); await load(true); toast('Gespeichert'); sOverview(); }catch(e){ toast(e.message,1) } }
async function setSource(s){ await api('PUT', A('/source'), {source:s}); await load(true); sOverview(); toast('TENANT_SOURCE='+s+' – wirksam beim nächsten Deploy'); }
async function runValidate(){ $('#val').innerHTML='<span class="muted">Validiere mit dem echten Core …</span>'; $('#val').innerHTML = valHtml(await api('POST', A('/validate?source=current'))); }
const valHtml = r => `<div class="banner ${r.ok?'ok':'err'}" style="margin:0">${r.ok?'✔ Validierung bestanden':'✘ Fehler'}${(r.errors||[]).map(e=>`<div class="mono" style="white-space:pre-wrap">${esc(e)}</div>`).join('')}${(r.warnings||[]).map(e=>`<div>⚠ ${esc(e)}</div>`).join('')}</div>`;
async function offboard(dry){
if(!dry && !confirm('Service, Volume und Ingress dieses Tenants wirklich vom Mittwald-Stack entfernen?')) return;
try{ const r = await api('POST', A('/offboard'), {dry_run:dry}); followDeploy(r.id, $('#con-off')); }catch(e){ toast(e.message,1) }
}
async function deleteOidcApp(){
const id = prompt('OIDC-Client dieses Tenants im Login-System LÖSCHEN (nicht umkehrbar, beendet alle Logins der App). Zur Bestätigung die Tenant-ID eingeben:'); if(id===null) return;
try{ await api('DELETE', A(`/oidc/app?confirm=${encodeURIComponent(id)}`)); toast('OIDC-Client gelöscht'); await load(true); sOverview(); }catch(e){ toast(e.message,1) }
}
async function deleteTenant(){
const id = prompt('Alle Daten dieses Tenants in der Datenbank löschen (Versionen, Secrets, Chat). Zur Bestätigung die ID eingeben:'); if(id===null) return;
try{ await api('DELETE', `/api/tenants/${TID}?confirm=${encodeURIComponent(id)}`); location.href='/'; }catch(e){ toast(e.message,1) }
}
/* ───────── Formular-Helfer ───────── */
function bindCfg(root){
root.querySelectorAll('[data-cfg]').forEach(el => {
el.addEventListener('input', () => {
const p = el.dataset.cfg;
const v = el.type==='checkbox' ? el.checked : el.type==='number' ? (el.value === '' ? '' : Number(el.value)) : el.value;
if(v === '' && !['name'].includes(p) && !p.startsWith('office.') && !p.startsWith('colors.')) unsetPath(S.config, p); else setPath(S.config, p, v);
if(el.type==='color'){ const t = root.querySelector(`[data-cfg="${p}"][type=text]`); if(t) t.value = v; }
if(el.type==='text' && el.dataset.color){ const c = root.querySelector(`[data-cfg="${p}"][type=color]`); if(c && /^#[0-9a-f]{6}$/i.test(v)) c.value = v; }
saveConfig();
});
});
}
const fld = (label, path, o={}) => `<label class="f ${o.full?'full':''}">${label}<input type="${o.type||'text'}" data-cfg="${path}" value="${esc(getPath(S.config,path)??'')}" ${o.ph?`placeholder="${esc(o.ph)}"`:''}></label>`;
/* ───────── Gestaltung ───────── */
function sBrand(){
const col = S.config.colors||{};
const logo = S.files['logo.svg']?'logo.svg':S.files['logo.png']?'logo.png':null;
$('#pane').innerHTML = title('Marke & Farben') + `
<div class="card"><h3>Name &amp; Titel</h3><div class="fgrid">${fld('Name','name')}${fld('Seitentitel (Browser-Tab)','title')}${fld('Beschreibung','description',{full:1})}</div></div>
<div class="card"><h3>Logo &amp; Favicon</h3><div class="row" style="align-items:flex-start;gap:20px">
<div><div class="logo-box lg">${logo?`<img src="${A('/files/'+logo)}?source=draft&t=${Date.now()}">`:'<span class="muted">kein Logo</span>'}</div>
<label class="f" style="margin-top:6px">Logo (SVG/PNG)<input type="file" accept=".svg,.png" onchange="uploadFile(this, this.files[0].name.toLowerCase().endsWith('.svg')?'logo.svg':'logo.png')"></label>
<div class="hint">Word-Export braucht zusätzlich <span class="mono">logo.png</span>.</div></div>
<div><div class="logo-box lg" style="width:56px">${S.files['favicon.svg']?`<img src="${A('/files/favicon.svg')}?source=draft&t=${Date.now()}">`:'<span class="muted">–</span>'}</div>
<label class="f" style="margin-top:6px">Favicon (SVG)<input type="file" accept=".svg" onchange="uploadFile(this,'favicon.svg')"></label></div></div></div>
<div class="card"><div class="row" style="margin-bottom:8px"><h3 style="margin:0" class="grow">Farben</h3><button class="btn sm" onclick="deriveFromAccent()" title="dunkel / mittel / hell / extra hell aus dem Akzent berechnen">Töne aus Akzent ableiten</button></div>
<div class="colors">${Object.keys(S.color_labels).map(k=>`<div class="colorf"><input type="color" data-cfg="colors.${k}" value="${esc(col[k]||'#000000')}"><span title="${esc(k)}">${esc(S.color_labels[k])}</span><input type="text" data-color="1" data-cfg="colors.${k}" value="${esc(col[k]||'')}"></div>`).join('')}</div></div>`;
bindCfg($('#pane'));
}
function deriveFromAccent(){
const a = (S.config.colors||{}).accent; if(!/^#[0-9a-f]{6}$/i.test(a)) return toast('Akzent muss #RRGGBB sein',1);
Object.assign(S.config.colors, {accent_dark:shade(a,.72), accent_mid:shade(a,1.12), accent_light:tint(a,.88), accent_xlight:tint(a,.94)});
saveConfigNow(); sBrand();
}
function sTexts(){
$('#pane').innerHTML = title('Absender & PDF-Texte') + `
<div class="card"><h3>Absender / Kopf- &amp; Fußzeile</h3><p class="sub">Erscheint im PDF und im Word-Export.</p><div class="fgrid">
${fld('Name','office.name')}${fld('Zusatz (Rechtsform)','office.subtitle')}${fld('Adresse','office.address',{full:1})}${fld('Telefon','office.tel')}${fld('E-Mail','office.email')}${fld('Website','office.website',{full:1})}</div></div>
<div class="card"><h3>PDF-Beschriftungen</h3><div class="fgrid">${fld('Bezeichnung „Projekt“ (z. B. „Thema“)','pdf_projekt_label',{ph:'Projekt'})}${fld('Fußzeilen-Hinweis','pdf_footer_note',{ph:'Standard des Cores'})}</div></div>`;
bindCfg($('#pane'));
}
/* Text-Datei-Editor mit „Standard des Cores“ */
async function fileEditor(path, o){
const has = !!S.files[path]; let body = '';
try { const r = await fetch(A('/files/'+path)+'?source=draft'); if(r.ok) body = await r.text(); } catch(e){}
const w = document.createElement('div'); w.className = 'card';
w.innerHTML = `<div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">${esc(o.title)}</h3>
${has ? `<span class="badge cyan">eigene Datei</span><button class="btn sm danger" data-act="reset">Auf Core-Standard</button>` : `<span class="badge">Core-Standard</span><button class="btn sm" data-act="custom">Eigene Version anlegen</button>`}</div>
${o.sub?`<p class="sub">${o.sub}</p>`:''}
<textarea class="code" ${has?'':'readonly'} spellcheck="false" style="min-height:${o.h||300}px;${has?'':'opacity:.7'}">${esc(body)}</textarea>`;
const ta = w.querySelector('textarea');
ta.addEventListener('input', () => saveFile(path, ta.value));
ta.addEventListener('keydown', e => { if(e.key==='Tab'){ e.preventDefault(); ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end'); ta.dispatchEvent(new Event('input')); } });
w.querySelector('[data-act=custom]')?.addEventListener('click', async () => { await saveFileNow(path, body); await load(true); renderSection(); });
w.querySelector('[data-act=reset]')?.addEventListener('click', async () => { if(confirm('Eigene Datei löschen und den Core-Standard verwenden?')) await deleteFile(path); });
return w;
}
async function sPdf(){
$('#pane').innerHTML = title('PDF-Layout') + `<div class="banner info">HTML + CSS (WeasyPrint). Datenfelder bleiben gleich, nur das Aussehen ist frei. Die Vorschau nutzt das Beispielprotokoll unten.</div><div id="e1"></div><div id="e2"></div>
<details class="fold"><summary>Beispielprotokoll für die Vorschau</summary><div class="in"><textarea class="code" id="sample" style="min-height:200px">${esc(S.sample)}</textarea>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="saveSample()">Speichern &amp; neu rendern</button><span class="hint">Format wie vom LLM geliefert; gilt nur für die Vorschau.</span></div></div></details>`;
$('#e1').appendChild(await fileEditor('protocol.html', {title:'protocol.html', sub:'Jinja-Template des PDFs.', h:300}));
$('#e2').appendChild(await fileEditor('protocol.css', {title:'protocol.css', sub:'Farben kommen als Jinja-Variablen aus der Marke.', h:300}));
}
async function saveSample(){ await api('PUT', A('/sample'), {sample: $('#sample').value}); S.sample = $('#sample').value; toast('Beispiel gespeichert'); ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); refreshPreview(true); }
/* ───────── Inhalt ───────── */
async function sPrompt(){
$('#pane').innerHTML = title('Prompt') + `
<div class="card"><h3>Selbstbeschreibung</h3><p class="sub">Ersetzt <span class="mono">@@ORG@@ · @@ORG_SHORT@@ · @@ORG_PROFILE@@</span> im Standard-Prompt.</p><div class="fgrid">
${fld('Organisation (voll)','ai_detect_org')}${fld('Kurzname im Protokolltext','org_short')}${fld('Ein Satz: was die Organisation macht','org_profile',{full:1})}</div></div><div id="ed"></div>
<details class="fold" id="ptest"><summary>Prompt testen (echter LLM-Lauf) <span class="badge">kostet Cortecs-Guthaben</span></summary><div class="in">
${S.llm_ready ? '' : '<div class="banner warn">Kein Cortecs-Key hinterlegt – unter Einstellungen → Geteilte Secrets setzen.</div>'}
<div class="fgrid"><label class="f">Termin-Art (optional)<input type="text" id="pt-art" placeholder="aus Gespräch erkennen"></label><label class="f">Projektname (optional)<input type="text" id="pt-proj"></label>
<label class="f full">Beispieltranskript<textarea id="pt-tr" rows="6" class="code" style="min-height:120px">${esc(S.sample_transcript)}</textarea></label></div>
<div class="row" style="margin:8px 0"><button class="btn sm cyan" id="pt-go" onclick="promptTest()" ${S.llm_ready?'':'disabled'}>LLM-Lauf mit Entwurfs-Prompt</button><button class="btn sm" onclick="showFinalPrompt()">Finalen Prompt anzeigen</button><span class="hint" id="pt-info"></span></div>
<div id="pt-out"></div></div></details>`;
bindCfg($('#pane'));
$('#ed').appendChild(await fileEditor('prompt.txt', {title:'prompt.txt', sub:'Ohne eigene Datei gilt der Standard-Prompt des Cores. Hook-Code kann den Prompt zusätzlich erweitern (siehe „Finalen Prompt anzeigen“).', h:420}));
}
async function promptTest(){
$('#pt-go').disabled = true; $('#pt-info').textContent = 'LLM läuft … (bis zu 1–2 Minuten)'; $('#pt-out').innerHTML = '';
try{
const r = await api('POST', A('/prompt-test'), {transcript:$('#pt-tr').value, terminart:$('#pt-art').value, projekt:$('#pt-proj').value});
$('#pt-info').textContent = `${r.model} · ${r.input_tokens}→${r.output_tokens} Tokens · Prompt ${r.prompt_chars} Zeichen${r.truncated?' · ⚠ abgeschnitten (max_tokens)':''}`;
window._ptText = r.text;
$('#pt-out').innerHTML = `<pre class="json" style="white-space:pre-wrap;max-height:340px;background:#fbfaf8;color:#222;border:1px solid var(--border)">${esc(r.text)}</pre><div class="row" style="margin-top:6px"><button class="btn sm" onclick="usePtAsSample()">Als PDF-Beispiel übernehmen &amp; ansehen</button></div>`;
}catch(e){ $('#pt-info').textContent = ''; $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
$('#pt-go').disabled = !S.llm_ready;
}
async function usePtAsSample(){ await api('PUT', A('/sample'), {sample: window._ptText}); S.sample = window._ptText; ui.pvKind='pdf'; document.querySelectorAll('#pv-kind button').forEach(b=>b.classList.toggle('on',b.dataset.k==='pdf')); setPvSrc('draft'); refreshPreview(true); toast('Als Beispiel gesetzt'); }
async function showFinalPrompt(){
try{ const r = await api('GET', A('/final-prompt')); $('#pt-out').innerHTML = `<div class="hint">${r.chars} Zeichen – so geht der Prompt (nach Platzhaltern und Hooks) an das LLM:</div><pre class="json" style="white-space:pre-wrap;max-height:380px">${esc(r.prompt)}</pre>`; }
catch(e){ $('#pt-out').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
function sFiles(){
const rows = Object.entries(S.files).map(([p,m]) => `<tr><td class="mono">${esc(p)} ${m.personal?'<span class="badge warn" title="Für den KI-Agenten gesperrt">personenbezogen</span>':''}</td><td>${(m.size/1024).toFixed(1)} KB</td><td>${m.text?'Text':'Binär'}</td><td style="text-align:right" class="nowrap">${m.text&&!['prompt.txt','protocol.html','protocol.css','overrides.py'].includes(p)?`<button class="btn sm" onclick="openTextFile('${esc(p)}')">Bearbeiten</button> `:''}<button class="btn sm danger" onclick="if(confirm('Datei löschen?'))deleteFile('${esc(p)}')">Löschen</button></td></tr>`).join('');
$('#pane').innerHTML = title('Dateien') + `<div class="card"><table class="t"><thead><tr><th>Pfad</th><th>Größe</th><th>Typ</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=4 class="empty">Keine Dateien</td></tr>'}</tbody></table>
<div class="row" style="margin-top:10px"><label class="btn sm">+ Datei hochladen<input type="file" hidden onchange="uploadFile(this)"></label><span class="hint">z. B. <span class="mono">namensliste.txt</span> – lesbar für Hook-Code im Container. Namenslisten sind für den KI-Agenten automatisch gesperrt.</span></div><div id="tf" style="margin-top:10px"></div></div>`;
}
async function openTextFile(p){ const w = await fileEditor(p, {title:p, h:260}); $('#tf').innerHTML=''; $('#tf').appendChild(w); }
/* ───────── Verhalten ───────── */
/* Felder, die der Core im Schema deklariert, aber keine eigene Maske haben, erscheinen automatisch hier (neue Core-Optionen ohne Admin-Änderung). */
const BESPOKE_PATHS = ['name','title','description','pdf_projekt_label','pdf_footer_note','ai_detect_org','org_short','org_profile'];
const isBespoke = p => BESPOKE_PATHS.includes(p) || p.startsWith('colors.') || p.startsWith('office.');
function schemaField(f){
const raw = getPath(S.config, f.path), cur = raw === undefined ? f.default : raw;
const help = f.help ? ` <span class="hint">${esc(f.help)}</span>` : '';
if(f.type === 'bool') return `<label class="chk"><input type="checkbox" data-cfg="${f.path}" ${cur?'checked':''}><span><b>${esc(f.label)}</b>${help}</span></label>`;
if(f.type === 'enum') return `<label class="f">${esc(f.label)}<select data-cfg="${f.path}"><option value="" ${!raw?'selected':''}>Standard (${esc(f.default ?? 'Core')})</option>${(f.options||[]).map(o=>`<option ${raw===o?'selected':''}>${esc(o)}</option>`).join('')}</select>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
if(f.type === 'color') return `<label class="f">${esc(f.label)}<input type="text" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? '')}"></label>`;
if(f.type === 'text') return `<label class="f full">${esc(f.label)}<textarea rows="3" data-cfg="${f.path}" placeholder="${esc(f.default ?? '')}">${esc(raw ?? '')}</textarea>${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
return `<label class="f">${esc(f.label)}<input type="${f.type==='number'?'number':'text'}" step="any" data-cfg="${f.path}" value="${esc(raw ?? '')}" placeholder="${esc(f.default ?? 'Standard')}">${f.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(f.help)}</span>`:''}</label>`;
}
function sFunc(){
if(!SCHEMA) return sFuncLegacy();
const groups = {};
SCHEMA.fields.filter(f => !isBespoke(f.path)).forEach(f => (groups[f.group] ??= []).push(f));
const order = Object.keys(groups);
const known = ['Funktion', 'Spracherkennung'];
const card = (g) => { const fs = groups[g], basic = fs.filter(f=>!f.advanced), adv = fs.filter(f=>f.advanced);
const grid = list => `<div class="${list.every(f=>f.type==='bool')?'':'fgrid'}">${list.map(schemaField).join('')}</div>`;
return `<div class="card"><h3>${esc(g)}${known.includes(g)?'':' <span class="badge cyan">neu im Core</span>'}</h3>${basic.length?grid(basic):''}${adv.length?`<details class="fold"><summary>Erweitert</summary><div class="in">${grid(adv)}</div></details>`:''}
${g==='Spracherkennung'?'<p class="hint" style="margin-top:6px">Bei „cortecs“ wird der geteilte CORTECS_API_KEY genutzt – ein eigener Mistral-Key ist nicht nötig.</p>':''}</div>`; };
$('#pane').innerHTML = title('Funktion & STT') + `<p class="hint" style="margin:-4px 0 8px">Aus dem Schema des Cores (Version ${esc(S.core?.sha||'')}) erzeugt – neue Optionen erscheinen hier automatisch.</p>` + order.map(card).join('');
bindCfg($('#pane'));
}
function sFuncLegacy(){
const stt = S.config.stt||{};
const chk = (path, label, hint, def) => { const v = getPath(S.config,path); return `<label class="chk"><input type="checkbox" data-cfg="${path}" ${(v===undefined?def:v)?'checked':''}><span><b>${label}</b> <span class="hint">${hint}</span></span></label>`; };
$('#pane').innerHTML = title('Funktion & STT') + `<div class="banner warn">Dieser Core liefert kein Schema (Vertrag) – feste Maske.</div>
<div class="card"><h3>Funktionen (Flags)</h3>
${chk('flags.ui_kontakt_field','Feld „Ansprechpartner Kunde“','Aus: Feld in der Web-UI ausgeblendet (z. B. Vereine).',true)}
${chk('flags.multi_audio','Mehrere Audiodateien (bis 5)','Aus: nur eine Datei pro Protokoll.',true)}</div>
<div class="card"><h3>Spracherkennung (STT)</h3><div class="fgrid">
<label class="f">Provider<select data-cfg="stt.provider"><option value="" ${!stt.provider?'selected':''}>Standard (mistral)</option><option ${stt.provider==='cortecs'?'selected':''}>cortecs</option><option ${stt.provider==='mistral'?'selected':''}>mistral</option></select></label>
${fld('Modell','stt.model',{ph:'voxtral-mini-transcribe-2602'})}</div></div>`;
bindCfg($('#pane'));
}
async function sCode(){
const has = !!S.files['overrides.py'];
$('#pane').innerHTML = title('Hook-Code') + `<div class="banner warn"><b>Code läuft im Tenant-Container</b> mit dessen Secrets. In der Vorschau läuft er nur in einer Sandbox ohne Secrets (CPU-/Datei-Limits); Veröffentlichen verlangt eine ausdrückliche Freigabe. Erst Flags/Dateien nutzen; Wiederkehrendes gehört in den Core.</div>
<div class="card"><h3>Verfügbare Hooks</h3><dl class="kv" style="grid-template-columns:150px 1fr"><dt>build_prompt</dt><dd>Prompt vor dem LLM-Aufruf anpassen</dd><dt>transform_transcript</dt><dd>Transkript nachbearbeiten</dd><dt>pdf_context</dt><dd>Daten fürs PDF-Template ändern</dd><dt>extra_routes</dt><dd>zusätzliche FastAPI-Routen</dd></dl></div><div id="ed"></div>`;
if(has) $('#ed').appendChild(await fileEditor('overrides.py', {title:'overrides.py', sub:'Syntax wird beim Speichern geprüft.', h:460}));
else $('#ed').innerHTML = `<div class="card empty">Dieser Tenant nutzt keinen Hook-Code.<br><br><button class="btn" onclick="createHook()">overrides.py anlegen</button></div>`;
}
async function createHook(){ await saveFileNow('overrides.py', `"""Tenant-Hooks für ${S.id}."""\nfrom core.hooks import TenantHooks\n\n\nclass Hooks(TenantHooks):\n def build_prompt(self, base_prompt: str, ctx: dict) -> str:\n return base_prompt\n`); await load(true); renderSection(); }
function sAdv(){
$('#pane').innerHTML = title('tenant.json') + `<div class="card"><p class="sub">Alle Felder, auch solche ohne Formular. Gültiges JSON wird automatisch übernommen. Der <span class="mono">deploy</span>-Block ist nicht Teil des Bundles (Domain/Ressourcen verwaltet die Plattform).</p>
<textarea class="code" id="rawjson" style="min-height:460px">${esc(JSON.stringify(S.config,null,2))}</textarea><div class="hint" id="rawerr" style="color:var(--err)"></div></div>`;
$('#rawjson').addEventListener('input', e => { try { S.config = JSON.parse(e.target.value); $('#rawerr').textContent=''; saveConfig(); } catch(x){ $('#rawerr').textContent = 'Ungültiges JSON: '+x.message; } });
}
/* ───────── Veröffentlichen ───────── */
async function openPublish(){
modal('m-pub', true); $('#pub-body').innerHTML = '<div class="muted">Validiere (echter Core) und berechne Änderungen …</div>'; $('#pub-go').disabled = true;
const [val, diff] = await Promise.all([api('POST', A('/validate?source=draft')), api('GET', A('/diff?a=current&b=draft'))]);
const code = diff.items.some(i => i.path==='overrides.py');
$('#pub-body').innerHTML = valHtml(val) + `<h3 style="margin:12px 0 4px">${diff.items.length} geänderte Datei(en)</h3>` + diff.items.map(diffHtml).join('') +
(code ? `<label class="chk banner warn"><input type="checkbox" id="codeok"><span><b>Freigabe Hook-Code:</b> Ich habe die Änderungen an <span class="mono">overrides.py</span> geprüft.</span></label>` : '') +
`<label class="f" style="margin-top:10px">Commit-Nachricht<input type="text" id="pubmsg" placeholder="Was wurde geändert?"></label>`;
const upd = () => { $('#pub-go').disabled = !val.ok || (code && !$('#codeok').checked); };
$('#codeok')?.addEventListener('change', upd); upd();
}
const diffHtml = i => `<div style="margin-top:8px"><div class="row"><b class="mono">${esc(i.path)}</b><span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${i.kind}</span>${i.added!=null?`<span class="hint">+${i.added} −${i.removed}</span>`:''}</div>${i.diff?`<div class="diff">${i.diff.map(l=>`<div class="${l.startsWith('+')&&!l.startsWith('+++')?'a':l.startsWith('-')&&!l.startsWith('---')?'r':l.startsWith('@@')?'h':''}">${esc(l)||' '}</div>`).join('')}</div>`:'<div class="hint">Binärdatei</div>'}</div>`;
async function publish(){
try { const r = await api('POST', A('/publish'), {message: $('#pubmsg').value}); modal('m-pub', false); await load(true); renderSection(); toast('Version v'+r.version+' veröffentlicht'); }
catch(e){ toast(e.message, 1); }
}
/* ───────── Agent ───────── */
const CHIPS = ['Setze die Akzentfarbe auf #0B6E4F','Füge dem Prompt hinzu: Nenne Beträge immer in Euro mit Tausenderpunkt.','Lege ein eigenes PDF-CSS an und mache die Überschriften größer.','Prüfe den Tenant auf Probleme.'];
async function initAgent(){
$('#ag-chips').innerHTML = CHIPS.map(s=>`<span class="chip" onclick="sendChat(this.textContent)">${esc(s)}</span>`).join('');
$('#chat-text').addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendChat(); } });
const st = await api('GET', '/api/llm/status?tenant=' + TID);
const src = {shared:'geteiltes Secret', env:'Env/.env', tenant:'Key des Tenants'}[st.source];
$('#ag-mode').textContent = st.configured ? `${st.model} · ${st.api}` : 'Demo-Modus – kein Key gefunden';
$('#ag-mode').className = 'badge ' + (st.configured ? 'cyan' : 'warn');
$('#ag-mode').title = st.configured ? 'Cortecs-Key aus: ' + src : (st.error || 'Key unter Einstellungen → Geteilte Secrets setzen oder Env CORTECS_API_KEY');
if(st.error) $('#ag-usage').textContent = '⚠ ' + st.error;
const h = await api('GET', A('/chat')); const box = $('#msgs');
if(!h.length) box.innerHTML = '<div class="empty">Der Agent arbeitet auf einem Entwurf und schlägt Änderungen nur vor – du gibst sie frei.</div>';
else h.forEach(m => box.appendChild(msgEl(m))); box.scrollTop = 1e9;
}
function stepsHtml(steps){ return (steps||[]).map(s=>`<div class="step ${s.error?'err':''}"><b>${esc(s.tool)}</b><span class="mono">${esc(s.arg)}</span> → ${esc(s.result)}</div>`).join(''); }
function propHtml(m){
if(!m.proposal) return '';
const items = (m.proposal.items||[]).map(i=>`<span class="badge ${i.kind==='neu'?'ok':i.kind==='gelöscht'?'err':'warn'}">${esc(i.path)}</span>`).join(' ');
return `<div class="prop"><b>Vorschlag:</b> ${esc(m.proposal.summary)}<div style="margin:5px 0">${items}</div><div class="row">${m.proposal_status==='open'
? `<button class="btn sm" onclick="showProposalDiff(${m.id})">Diff ansehen</button><button class="btn sm cyan" onclick="decide(${m.id},'apply')">In Entwurf übernehmen</button><button class="btn sm" onclick="decide(${m.id},'reject')">Verwerfen</button>`
: `<span class="badge ${m.proposal_status==='applied'?'ok':''}">${m.proposal_status==='applied'?'übernommen – prüfen & veröffentlichen':'verworfen'}</span>`}</div></div>`;
}
function msgEl(m){
const d = document.createElement('div'); d.className = 'bub '+m.role; d.id = 'm'+(m.id||'');
const u = m.usage && m.usage.in ? `<div class="hint" style="margin-top:5px">${esc(m.usage.model||'')} · ${m.usage.in}→${m.usage.out} Tokens</div>` : '';
d.innerHTML = (m.steps&&m.steps.length?`<div class="steps">${stepsHtml(m.steps)}</div>`:'') + esc(m.content) + propHtml(m) + u; return d;
}
async function sendChat(text){
const ta = $('#chat-text'); text = text || ta.value.trim(); if(!text) return; ta.value = '';
ui.mode = 'agent'; layout();
const box = $('#msgs'); box.querySelector('.empty')?.remove();
box.appendChild(msgEl({role:'user', content:text}));
const live = document.createElement('div'); live.className = 'bub assistant'; live.innerHTML = '<div class="steps"></div><div class="typing">Agent arbeitet …</div>'; box.appendChild(live); box.scrollTop = 1e9;
$('#chat-send').disabled = true;
try {
const r = await fetch(A('/chat'), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({message:text})});
if(!r.ok) throw new Error((await r.json()).detail || 'Fehler '+r.status);
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = '';
while(true){
const {value, done} = await rd.read(); if(done) break; buf += dec.decode(value, {stream:true});
let i; while((i = buf.indexOf('\n\n')) >= 0){
const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2);
if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); }
else if(ev.type==='changed'){ refreshPreviewLive(); }
else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev]));
else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `<div class="thought">${esc(ev.text)}</div>`);
else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text;
else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message));
if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); }
refreshPreview(true); }
box.scrollTop = 1e9;
}
}
} catch(e){ live.remove(); toast(e.message, 1); }
$('#chat-send').disabled = false;
}
async function decide(id, d){
const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m));
ui.agentSrc = null; setPvSrc('draft');
if(d==='apply'){ await load(true); renderNav(); headLogo(); if(ui.sec==='overview') sOverview(); toast('Im Entwurf – Vorschau zeigt jetzt den Entwurf'); }
refreshPreview(true);
}
async function showProposalDiff(id){ const d = await api('GET', A(`/chat/${id}/diff`)); $('#diff-title').textContent = 'Vorschlag des Agenten'; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede zum Entwurf.</div>'; modal('m-diff', true); }
async function clearChat(){ if(!confirm('Chatverlauf dieses Tenants löschen?')) return; await api('DELETE', A('/chat')); $('#msgs').innerHTML = '<div class="empty">Verlauf geleert.</div>'; }
/* ───────── Betrieb ───────── */
async function sDomain(){
const dnsTarget = `${S.subdomain}.${S.base_domain}`;
const o = await api('GET', A('/oidc')); window._oidc = o;
const logout = `https://${S.host}/`;
const modeHelp = {off:'Nichts im Login anfassen (Client wird manuell gepflegt).', adopt:'Bestehenden Client übernehmen: fehlende Redirect-URIs ergänzen, sonst nichts ändern.', managed:'Client vollständig aus dieser Konfiguration verwalten (anlegen, Redirect-URIs, auth/totp, Freigaben).'};
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Domain & Login</h2></div>
<div class="grid2">
<div class="card"><h3>Domain</h3>
<div class="fgrid"><label class="f">Subdomain<input type="text" id="d-sub" value="${esc(S.subdomain)}"></label><label class="f">Basis-Domain<input type="text" value="${esc(S.base_domain)}" disabled></label>
<label class="f full">Eigene Domain (optional)<input type="text" id="d-cust" value="${esc(S.custom_domain)}" placeholder="protokoll.kunde.de"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveDomain()">Speichern</button><button class="btn sm" onclick="checkDomain()">DNS prüfen</button>
<span class="badge ${S.domain_status==='verified'?'ok':'warn'}">${S.domain_status==='verified'?'verifiziert':S.domain_status==='pending'?'DNS ausstehend':'nicht eingerichtet'}</span></div>
<div id="dns-msg" class="hint" style="margin-top:6px"></div>
${S.custom_domain?`<table class="t" style="margin-top:10px"><thead><tr><th>Typ</th><th>Name</th><th>Wert</th></tr></thead><tbody><tr><td>CNAME</td><td class="mono">${esc(S.custom_domain)}</td><td class="mono copy" onclick="copyText('${esc(dnsTarget)}')">${esc(dnsTarget)}</td></tr></tbody></table><p class="hint">Alternativ A-Record auf die Projekt-IP aus mStudio. TLS stellt Mittwald automatisch aus.</p>`:''}
<p class="hint" style="margin-top:8px">Redirect-URIs im Login ergeben sich aus Subdomain und eigener Domain und werden beim Deploy mit abgeglichen.</p></div>
<div class="card"><h3>OIDC-Client <span class="muted" style="font-weight:400">· Abgleich über die Login-Developer-API bei jedem Deploy</span></h3>
<div class="row" style="margin-bottom:8px">
<span class="badge ${o.api_key?'ok':'err'}">API-Key ${o.api_key?'vorhanden':'fehlt'}</span>
<span class="badge ${o.writes_allowed?'warn':''}">Schreiben ${o.writes_allowed?'erlaubt':'gesperrt'}</span>
<span class="badge ${o.client_id?'ok':'warn'} mono">${o.client_id?esc(o.client_id):'noch kein Client'}</span>
<span class="badge ${o.secret_set?'ok':'warn'}">Secret ${o.secret_set?'gesetzt':'fehlt'}</span></div>
<div class="fgrid">
<label class="f full">Verwaltung<select id="oc-mode">${['off','adopt','managed'].map(m=>`<option value="${m}" ${o.mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<div class="hint full" id="oc-modehelp" style="margin-top:-4px">${modeHelp[o.mode]}</div>
<label class="f">Zugriff (auth_mode)<select id="oc-auth">${['required','optional','disabled'].map(m=>`<option ${o.auth_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<label class="f">2FA (totp_mode)<select id="oc-totp">${['disabled','optional','required'].map(m=>`<option ${o.totp_mode===m?'selected':''}>${m}</option>`).join('')}</select></label>
<label class="f full">Backchannel-Logout-URI (optional, öffentlich, https)<input type="text" id="oc-bc" value="${esc(o.backchannel_logout_uri||'')}"></label>
<label class="chk full"><input type="checkbox" id="oc-reg" ${o.allow_registration?'checked':''}><span><b>Selbstregistrierung</b> <span class="hint">Nutzer dürfen sich über diese App selbst registrieren.</span></span></label></div>
<div style="margin:4px 0 8px"><div class="hint">Redirect-URIs (Soll):</div>${o.redirect_uris.map(u=>`<div class="mono copy" onclick="copyText('${esc(u)}')">${esc(u)}</div>`).join('')}
<div class="hint">Logout-Ziel: <span class="mono">${esc(logout)}</span> (Core: OIDC_LOGOUT_REDIRECT_URI)</div></div>
<div class="row"><button class="btn sm" onclick="saveOidc(true)">Speichern</button>
<button class="btn sm" onclick="syncOidc(false)" ${o.api_key?'':'disabled'} title="Liest nur – zeigt, was beim Deploy passieren würde">Gegen Login prüfen</button>
<button class="btn sm cyan" onclick="syncOidc(true)" ${o.api_key&&o.writes_allowed&&o.mode!=='off'?'':'disabled'} title="${o.writes_allowed?'':'Gesperrt: OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv'}">Jetzt abgleichen</button>
<button class="btn sm danger" onclick="syncOidc(true,true)" ${o.api_key&&o.writes_allowed&&o.client_id?'':'disabled'} title="Neues Secret erzeugen; das alte wird sofort ungültig">Secret rotieren</button></div>
${o.last_sync?`<div class="hint" style="margin-top:6px">Letzter Abgleich: ${fmt(o.last_sync.ts)} · <span class="${o.last_sync.ok?'':'muted'}">${o.last_sync.ok?'✔ ok':'✘ '+esc(o.last_sync.error||'')}</span>${o.last_sync.created?' · Client neu angelegt':''}</div>`:''}
<div id="oc-out" style="margin-top:8px"></div></div></div>
<div class="grid2" style="margin-top:12px">
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Freigaben</h3>
<button class="btn sm" onclick="importMembers()" ${o.api_key&&o.client_id?'':'disabled'} title="Aktuelle Freigaben aus dem Login lesen und hier übernehmen">Aus Login übernehmen</button></div>
<p class="sub">Wer darf die App nutzen (bei auth_mode „required“). Nutzer müssen im Login bereits existieren.</p>
<table class="t" id="oc-members"><tbody></tbody></table>
<div class="row" style="margin-top:6px"><button class="btn sm" onclick="addMember()">+ Nutzer</button>
<label class="f" style="display:flex;align-items:center;gap:6px;margin-left:auto">Abgleich<select id="oc-mm" style="width:auto;margin:0"><option value="add" ${o.members_mode==='add'?'selected':''}>nur ergänzen</option><option value="exact" ${o.members_mode==='exact'?'selected':''}>exakt (zusätzliche entziehen)</option></select></label></div></div>
<div class="card"><h3>Manuell eintragen</h3><p class="sub">Falls der Client nicht automatisch verwaltet wird (Verwaltung „off“) oder das Secret aus dem Altbestand übernommen werden soll.</p>
<div class="fgrid"><label class="f">Client-ID<input type="text" id="o-id" placeholder="${o.client_id?esc(o.client_id)+' – neu eintragen zum Ändern':'noch nicht gesetzt'}"></label>
<label class="f">Client-Secret<input type="password" id="o-sec" placeholder="${o.secret_set?'●●●● gesetzt':'noch nicht gesetzt'}"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="saveManual()">Speichern</button></div></div></div>
<div class="card" style="margin-top:12px"><h3>Bundle-Abruf durch den Container</h3><p class="sub">So holt der Container beim Start seine Konfiguration (ohne Secrets); Token = <span class="mono">ADMIN_BUNDLE_TOKEN</span>. Der Core kann das noch nicht – Schnittstelle steht.</p>
<pre class="json">curl -H "Authorization: Bearer $ADMIN_BUNDLE_TOKEN" \\\n ${location.origin}/api/tenants/${TID}/bundle -o bundle.tar.gz</pre></div>`;
renderMembers(o.members);
$('#oc-mode').addEventListener('change', e => { $('#oc-modehelp').textContent = modeHelp[e.target.value]; });
}
function renderMembers(ms){
window._members = ms.map(m => ({...m}));
$('#oc-members tbody').innerHTML = window._members.length ? window._members.map((m,i)=>`<tr><td><input type="text" value="${esc(m.email)}" onchange="_members[${i}].email=this.value.trim().toLowerCase()" style="width:100%"></td>
<td style="width:100px"><select onchange="_members[${i}].role=this.value">${['viewer','editor','admin'].map(r=>`<option ${m.role===r?'selected':''}>${r}</option>`).join('')}</select></td>
<td style="width:30px"><button class="btn sm danger" onclick="_members.splice(${i},1);renderMembers(_members)">✕</button></td></tr>`).join('') : '<tr><td class="empty">Keine Freigaben gepflegt – bestehende bleiben unverändert (Abgleich „nur ergänzen“).</td></tr>';
}
function addMember(){ window._members.push({email:'', role:'viewer'}); renderMembers(window._members); }
async function saveOidc(toastOk){
try{
await api('PUT', A('/oidc'), {mode:$('#oc-mode').value, auth_mode:$('#oc-auth').value, totp_mode:$('#oc-totp').value, allow_registration:$('#oc-reg').checked,
backchannel_logout_uri:$('#oc-bc').value, members:window._members.filter(m=>m.email), members_mode:$('#oc-mm').value});
if(toastOk) { toast('OIDC-Konfiguration gespeichert'); sDomain(); } return true;
}catch(e){ toast(e.message,1); return false; }
}
async function syncOidc(apply, rotate){
if(!await saveOidc(false)) return;
if(rotate && !confirm('Secret rotieren? Das alte Secret ist sofort ungültig – der Container braucht danach einen Deploy.')) return;
if(apply && !rotate && !confirm('Jetzt im Login-System schreiben (Client anlegen/anpassen, Freigaben setzen)?')) return;
$('#oc-out').innerHTML = '<span class="muted">Frage das Login-System …</span>';
try{
const r = await api('POST', A('/oidc/sync'), {apply, rotate});
const acts = r.actions.map(a=>`<div>• ${esc(a.detail)}</div>`).join('') || (r.skipped?`<div>${esc(r.skipped)}</div>`:'<div>✔ Alles abgeglichen – keine Änderungen nötig.</div>');
$('#oc-out').innerHTML = `<div class="banner ${r.ok?(r.actions.length&&!apply?'info':'ok'):'err'}" style="margin:0"><b>${apply?'Abgleich':'Prüfung (nur lesen)'}:</b> ${r.ok?'':'✘ '+esc(r.error)}${acts}${r.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</div>`;
if(apply){ const keep = $('#oc-out').innerHTML; await load(true); await sDomain(); $('#oc-out').innerHTML = keep; }
}catch(e){ $('#oc-out').innerHTML = `<div class="banner err" style="margin:0">${esc(e.message)}</div>`; }
}
async function importMembers(){ try{ const o = await api('POST', A('/oidc/import-members')); renderMembers(o.members); toast(o.members.length+' Freigaben übernommen – speichern nicht vergessen'); }catch(e){ toast(e.message,1) } }
async function saveManual(){ const id=$('#o-id').value, sc=$('#o-sec').value; if(id) await api('PUT',A('/secrets/OIDC_CLIENT_ID'),{value:id}); if(sc) await api('PUT',A('/secrets/OIDC_CLIENT_SECRET'),{value:sc}); toast('Gespeichert'); sDomain(); }
async function saveDomain(){ try{ await api('PUT', A('/domain'), {subdomain:$('#d-sub').value, custom_domain:$('#d-cust').value}); await load(true); sDomain(); toast('Domain gespeichert'); }catch(e){ toast(e.message,1) } }
async function checkDomain(){ $('#dns-msg').textContent='Prüfe …'; const r = await api('POST', A('/domain/check')); await load(true); await sDomain(); $('#dns-msg').textContent = r.message; }
async function sSecrets(){
const sec = await api('GET', A('/secrets')); renderNav({missing: sec.some(s=>s.required&&!s.set)});
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Secrets</h2></div><div class="card"><p class="sub">Verschlüsselt in der Datenbank. Werte werden nie angezeigt und gelangen nie in Agent-Kontext oder Bundle – nur beim Deploy als Env an den Container.</p>
<table class="t"><thead><tr><th>Schlüssel</th><th>Status</th><th>Bedeutung</th><th></th></tr></thead><tbody>${sec.map(s=>`<tr><td class="mono nowrap">${esc(s.key)}${s.required?' <span class="badge">Pflicht</span>':''}</td>
<td>${s.set?'<span class="badge ok">gesetzt</span>':s.source==='sops-import'?'<span class="badge warn">noch in SOPS</span>':'<span class="badge err">fehlt</span>'}</td><td class="muted">${esc(s.help)}</td>
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="setSecret('${s.key}')">Wert setzen</button> ${['SESSION_SECRET','ADMIN_BUNDLE_TOKEN'].includes(s.key)?`<button class="btn sm" onclick="genSecret('${s.key}')">Neu erzeugen</button>`:''}</td></tr>`).join('')}</tbody></table>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="addSecret()">+ Weiteres Secret</button><span class="hint">Bestehende Tenants: <span class="mono">python scripts/import_tenants.py --with-secrets</span> liest SOPS und verschlüsselt neu.</span></div></div>`;
}
async function setSecret(k){ const v = prompt('Neuer Wert für '+k+':'); if(v===null) return; await api('PUT', A('/secrets/'+k), {value:v}); toast('Gespeichert'); sSecrets(); }
async function addSecret(){ const k = prompt('Name (z. B. MISTRAL_API_KEY):'); if(!k) return; await setSecret(k.trim().toUpperCase()); }
async function genSecret(k){ if(!confirm(k+' neu erzeugen? Der Container braucht danach einen Deploy.')) return; await api('PUT', A('/secrets/'+k), {generate:true}); toast('Neu erzeugt'); sSecrets(); }
async function followDeploy(id, con){
con.classList.remove('hide'); con.style.display = 'block';
const tick = async () => { const d = await api('GET','/api/deployments/'+id); con.innerHTML = esc(d.log).replace(/\[DRY-RUN\]/g,'<span class="sim">[DRY-RUN]</span>'); con.scrollTop = 1e9;
if(d.status==='running'||d.status==='queued') setTimeout(tick,500); else { await load(true); if(ui.sec==='deploy') sDeploy(true); } };
tick();
}
async function sDeploy(keepConsole){
const [plan, hist] = await Promise.all([api('GET', A('/plan')), api('GET', '/api/deployments')]);
const mine = hist.filter(d => d.tenant === TID); const m = plan.mode;
const old = keepConsole ? $('#con')?.innerHTML : null;
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Deploy</h2></div>
${m.live?`<div class="banner ${m.tenant_allowed?'err':'warn'}">Live-Modus aktiv · freigegebene Tenants: <b>${esc(m.allowed)}</b> ${m.tenant_allowed?'– dieser Tenant darf live deployen.':'– dieser Tenant ist <b>nicht</b> freigegeben (nur Dry-Run).'}</div>`:'<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (<span class="mono">DEPLOY_LIVE</span> nicht aktiv). Dry-Run läuft mit demselben Code und zeigt, was geschrieben würde.</div>'}
<div class="card"><div class="row" style="margin-bottom:6px"><h3 style="margin:0" class="grow">Vorflug-Check <span class="muted" style="font-weight:400">· ist alles bereit für den Live-Deploy? (liest nur)</span></h3><button class="btn sm" onclick="runPreflight()">Prüfen</button></div><div id="pf"><span class="hint">Noch nicht ausgeführt.</span></div></div>
<div class="card" style="margin-top:12px"><h3>Geplanter Rollout</h3>
<dl class="kv"><dt>Service</dt><dd class="mono">${esc(plan.service)}</dd><dt>Image</dt><dd class="mono">${esc(plan.patch.image)}</dd><dt>Version</dt><dd>v${S.current_version} ${S.has_draft?'<span class="badge warn">Entwurf wird nicht deployt</span>':''}</dd><dt>Ingress</dt><dd class="mono">${esc(plan.ingress.host)} → ${esc(plan.ingress.target)}</dd>
<dt>Voraussetzungen</dt><dd><span class="badge ${m.token?'ok':'warn'}">API-Token</span> <span class="badge ${m.project?'ok':'warn'}">Projekt-ID</span> ${S.source==='admin'?`<span class="badge ${m.core_bundle?'ok':'err'}">Core-Bundle</span>`:''}
<span class="badge ${plan.oidc.mode==='off'?'':plan.oidc.key?'ok':'warn'}" title="Wird bei jedem Deploy vorher mit der Login-API abgeglichen">OIDC: ${plan.oidc.mode}${plan.oidc.mode!=='off'&&!plan.oidc.key?' · API-Key fehlt':''}</span></dd></dl>
${plan.missing_secrets.length?`<div class="banner warn" style="margin-top:8px">Secrets ohne Wert: <span class="mono">${esc(plan.missing_secrets.join(', '))}</span></div>`:''}
<details class="fold"><summary>Service-Definition (PATCH-Body, Secrets maskiert)</summary><div class="in"><pre class="json">${esc(JSON.stringify(plan.patch,null,2))}</pre></div></details>
<div class="row" style="margin-top:10px"><button class="btn" onclick="startDeploy(true)">Dry-Run</button><button class="btn danger ${m.live?'':'disabled'}" onclick="rollbackTenant()" title="Auf den vorherigen erfolgreich deployten Core-Tag zurück">Zurückrollen</button><button class="btn cyan ${m.live&&m.tenant_allowed?'':'disabled'}" onclick="startDeploy(false)" title="${!m.live?'Gesperrt: DEPLOY_LIVE nicht aktiv':!m.tenant_allowed?'Gesperrt: Tenant nicht in DEPLOY_ALLOWED_TENANTS':''}">Deployen</button></div>
<div class="console hide" id="con" style="margin-top:10px"></div></div>
<div class="card" style="margin-top:12px"><h3>Verlauf</h3>${mine.length?`<table class="t"><thead><tr><th>Zeit</th><th>Art</th><th>Version</th><th>Status</th></tr></thead><tbody>${mine.map(d=>`<tr><td>${fmt(d.created_at)}</td><td>${kindLabel[d.kind]||d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version}</td><td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':''}">${d.status}</span></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Noch keine Deployments für diesen Tenant.</div>'}</div>`;
if(old){ $('#con').classList.remove('hide'); $('#con').innerHTML = old; }
}
async function runPreflight(){
$('#pf').innerHTML = '<span class="muted">Prüfe (liest Mittwald-Stack, Login-API, Rendering) …</span>';
try{
const r = await api('GET', A('/preflight'));
const dot = {ok:'ok', fail:'no', warn:'no', info:''}; const ic = {ok:'✓', fail:'✕', warn:'!', info:'i'};
$('#pf').innerHTML = `<div class="banner ${r.ready?'ok':'err'}" style="margin-bottom:6px">${r.ready?'✔ Bereit für den Live-Deploy (keine harten Fehler)':'✘ Noch nicht bereit – bitte die roten Punkte beheben'} · Ziel <span class="mono">${esc(r.host)}</span> · Core <span class="mono">:${esc(String(r.tag).slice(0,12))}</span></div>`+
r.items.map(i=>`<div class="check"><span class="dot ${dot[i.level]}" ${i.level==='warn'?'style="background:#fbf0cf;color:#8a6200"':''}>${ic[i.level]}</span><span><b>${esc(i.label)}</b> <span class="muted">${esc(i.detail)}</span>${i.fix?`<div class="hint">→ ${esc(i.fix)}</div>`:''}</span></div>`).join('');
}catch(e){ $('#pf').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; }
}
async function rollbackTenant(){ if(!confirm('Auf den vorherigen Core-Tag zurückrollen?')) return; try{ const r = await api('POST', A('/rollback')); toast('Rollback auf :'+String(r.tag).slice(0,10)+' gestartet'); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) } }
async function startDeploy(dry){
if(!dry && !confirm('Tenant jetzt auf Mittwald deployen?')) return;
try{ const r = await api('POST', A('/deploy'), {dry_run:dry}); followDeploy(r.id, $('#con')); }catch(e){ toast(e.message,1) }
}
/* ───────── Verlauf ───────── */
async function sVersions(){
const v = await api('GET', A('/versions'));
$('#pane').innerHTML = `<div class="row" style="margin-bottom:8px"><h2 style="font-size:16px">Verlauf</h2></div><div class="card"><p class="sub">Jede Veröffentlichung ist ein unveränderlicher Snapshot. Wiederherstellen legt die alte Version als <b>Entwurf</b> an – nichts wird überschrieben.</p>
<table class="t"><thead><tr><th>Version</th><th>Nachricht</th><th>Autor</th><th>Zeit</th><th>Dateien</th><th></th></tr></thead><tbody>${v.map(x=>`<tr><td class="nowrap"><b>v${x.number}</b> ${x.current?'<span class="badge dark">aktuell</span>':''} ${x.deployed?'<span class="badge ok">deployt</span>':''}</td><td>${esc(x.message)}</td><td>${esc(x.author)}</td><td class="nowrap">${fmt(x.created_at)}</td><td>${x.files}</td>
<td style="text-align:right" class="nowrap">${x.current?'':`<button class="btn sm" onclick="showDiff('v${x.number}','current','v${x.number} → aktuell')">Diff</button> <button class="btn sm" onclick="restoreV(${x.number})">Als Entwurf</button>`}</td></tr>`).join('')}</tbody></table></div>`;
}
async function showDiff(a,b,t){ const d = await api('GET', A(`/diff?a=${a}&b=${b}`)); $('#diff-title').textContent = 'Diff '+t; $('#diff-body').innerHTML = d.items.length ? d.items.map(diffHtml).join('') : '<div class="empty">Keine Unterschiede.</div>'; modal('m-diff', true); }
async function restoreV(n){ if(!confirm('v'+n+' als Entwurf wiederherstellen? Ein bestehender Entwurf wird ersetzt.')) return; await api('POST', A(`/versions/${n}/restore`)); await load(true); location.hash = 'brand'; renderSection(); toast('v'+n+' liegt jetzt im Entwurf'); }
(async () => { await loadSchema(); await load(); initAgent(); })();

13
app/templates/audit.html Normal file
View File

@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Audit-Log · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head"><div><div class="eyebrow">Nachvollziehbarkeit</div><h1 class="title">Audit-Log.</h1>
<p class="lead">Wer hat wann was geändert, veröffentlicht oder deployt – inklusive Freigaben von Agent-Vorschlägen.</p></div></div>
<div class="card">
<table class="t"><thead><tr><th>Zeit</th><th>Wer</th><th>Aktion</th><th>Tenant</th><th>Detail</th></tr></thead><tbody>
{% for r in rows %}<tr><td>{{ r.ts|dt }}</td><td>{{ r.actor }}</td><td><span class="badge">{{ r.action }}</span></td><td>{{ r.tenant_id or '–' }}</td><td class="muted">{{ r.detail }}</td></tr>
{% else %}<tr><td colspan="5" class="empty">Noch keine Einträge.</td></tr>{% endfor %}
</tbody></table></div>
</div>
{% endblock %}

44
app/templates/base.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Onyscribe Admin{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231a1a1a'/%3E%3Cpath d='M20 22l14 10-14 10' fill='none' stroke='%2300cccc' stroke-width='7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/admin.css">
</head>
<body>
<aside class="side">
<div class="brand"><b>onyva</b><span>Scribe Admin</span></div>
<nav class="nav">
<a href="/" class="{{ 'on' if nav=='dashboard' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg><span>Tenants</span></a>
<a href="/deployments" class="{{ 'on' if nav=='deployments' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 19h16"/></svg><span>Deployments</span></a>
<a href="/core" class="{{ 'on' if nav=='core' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 22V12M3 7l9 5 9-5"/></svg><span>Core</span></a>
<a href="/settings" class="{{ 'on' if nav=='settings' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10l2 2M19 5l-2 2M7 17l-2 2"/></svg><span>Einstellungen</span></a>
<a href="/audit" class="{{ 'on' if nav=='audit' }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/></svg><span>Audit-Log</span></a>
</nav>
<div class="side-foot">
<span class="pill {{ 'live' if deploy_live() else 'lock' }}" title="Env DEPLOY_LIVE der Admin-Plattform">Deploy: {{ 'live' if deploy_live() else 'gesperrt · Dry-Run' }}</span>
{% set u = request.state.user if request is defined else None %}
{% if u and u.email and u.email != 'dev@local' %}<span>{{ u.email }}<br><a href="/auth/logout">Abmelden</a></span>{% else %}<span class="pill dev">Login: aus</span>{% endif %}
</div>
</aside>
<div class="main">{% block body %}{% endblock %}</div>
<div id="toast"></div>
<script>
function toast(msg, err){const t=document.getElementById('toast');t.textContent=msg;t.className=err?'err':'';t.style.display='block';clearTimeout(window._tt);window._tt=setTimeout(()=>t.style.display='none',3200)}
async function api(method, url, body){
const o={method,headers:{}};
if(body!==undefined){o.headers['Content-Type']='application/json';o.body=JSON.stringify(body)}
const r=await fetch(url,o);
let d=null;try{d=await r.json()}catch(e){}
if(!r.ok){throw new Error((d&&(d.detail||d.message))||('Fehler '+r.status))}
return d;
}
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function modal(id,on){document.getElementById(id).classList.toggle('on',on)}
function fmt(d){return d?new Date(d).toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}):'–'}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

90
app/templates/core.html Normal file
View File

@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block title %}Core · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head"><div><div class="eyebrow">Zusammenspiel mit dem Core</div><h1 class="title">Core-Releases.</h1>
<p class="lead">Die Core-CI meldet neue Images hierher. Die Admin-Plattform prüft damit <b>alle</b> Tenants, gibt nach deiner Freigabe aus und rollt Canary-zuerst aus – mit Health-Check pro Tenant und Rollback.</p></div>
<button class="btn" onclick="load()">Aktualisieren</button></div>
<div id="warn"></div>
<div class="grid2">
<div class="card"><h3>Core der Admin-Plattform</h3><dl class="kv" id="admin-core"></dl>
<p class="hint" style="margin-top:8px">Vorschau, Sandbox und Formulare nutzen genau diesen Core. Im Container ist das Admin-Image <span class="mono">FROM</span> das Core-Image gebaut – Schriften, Python-Pakete und WeasyPrint sind damit identisch zur Produktion.</p></div>
<div class="card"><h3>Rollout-Regeln</h3><dl class="kv" id="rules"></dl>
<div class="row" style="margin-top:8px"><label class="f" style="flex:1">Canary-Tenants (zuerst, kommagetrennt)<input type="text" id="canary" placeholder="demo"></label><button class="btn sm" onclick="saveCanary()" style="align-self:flex-end">Speichern</button></div>
<p class="hint" style="margin-top:6px">Gepinnte Tenants (Tenant → Übersicht → Core-Version) nehmen nicht teil. Der Rollout bricht beim ersten Fehler ab; Echt-Rollouts brauchen <span class="mono">DEPLOY_LIVE=true</span>.</p></div>
</div>
<div class="card" style="margin-top:12px"><h3>Releases</h3><div id="rel"></div></div>
</div>
<div class="modal-bg" id="m-roll"><div class="modal"><h2 id="roll-title">Rollout</h2><p class="hint" id="roll-hint"></p><table class="t" id="roll-table"><tbody></tbody></table>
<div class="acts"><button class="btn" onclick="modal('m-roll',false);load()">Schließen</button></div></div></div>
{% endblock %}
{% block scripts %}
{% raw %}<script>
let D = null, open_ = {}, timer = null;
const stLabel = {new:'neu', checked:'geprüft', approved:'freigegeben', rolling:'wird ausgerollt', active:'aktiv', failed:'fehlgeschlagen', rejected:'abgelehnt', superseded:'abgelöst', rolled_back:'zurückgerollt'};
const stCls = {active:'ok', approved:'cyan', checked:'', new:'warn', rolling:'cyan', failed:'err', rejected:'err', superseded:'', rolled_back:'warn'};
async function load(){
D = await api('GET','/api/core/releases');
const a = D.admin_core;
document.getElementById('admin-core').innerHTML = `<dt>Core-SHA</dt><dd class="mono">${esc(a.sha.slice(0,12))} <span class="muted">(${esc(a.source)})</span></dd><dt>Gebaut</dt><dd>${a.built_at?fmt(a.built_at):'–'}</dd>
<dt>Vertrag</dt><dd>v${a.contract} ${a.supported?'<span class="badge ok">unterstützt</span>':'<span class="badge err">nicht unterstützt</span>'}</dd><dt>Schema</dt><dd>${a.fields??'–'} Felder · ${a.env??'–'} Env-Einträge <span class="muted mono">${esc((a.schema_sha||'').slice(0,8))}</span></dd>`;
document.getElementById('rules').innerHTML = `<dt>Aktiver Tag</dt><dd class="mono">:${esc(String(D.active_tag).slice(0,12))}</dd><dt>Release-Eingang</dt><dd>${D.intake?'<span class="badge ok">Token gesetzt</span>':'<span class="badge warn">CORE_RELEASE_TOKEN fehlt</span>'} <span class="hint">CI: Job „notify-admin“</span></dd><dt>Rollout</dt><dd>${D.live?'<span class="badge err">live erlaubt</span>':'<span class="badge warn">gesperrt · nur Dry-Run</span>'}</dd>`;
document.getElementById('canary').value = D.canary.join(', ');
document.getElementById('warn').innerHTML = a.supported ? '' : '<div class="banner err">Dieser Core nutzt einen Vertrag, den diese Admin-Version nicht kennt – Admin aktualisieren, bevor etwas ausgerollt wird.</div>';
renderRel();
if(D.releases.some(r=>r.compat&&r.compat.running||r.status==='rolling')){ clearTimeout(timer); timer = setTimeout(load, 2500); }
}
function matrix(r){
const c = r.compat; if(!c) return '<div class="hint">Noch nicht geprüft.</div>';
const rows = Object.entries(c.tenants||{}).map(([t,v])=>`<tr><td><b>${esc(t)}</b></td><td><span class="badge ${v.status==='ok'?'ok':v.status==='error'?'err':v.status==='changed'?'warn':''}">${{ok:'unverändert',changed:'Rendering geändert',new:'neu (keine Baseline)',error:'Fehler'}[v.status]}</span></td>
<td class="muted">${v.changed.length?esc(v.changed.join(', ')):''}${v.errors.map(e=>`<div class="mono" style="color:var(--err)">${esc(e).slice(0,300)}</div>`).join('')}${v.warnings.map(w=>`<div>⚠ ${esc(w)}</div>`).join('')}</td></tr>`).join('');
return `<div class="hint" style="margin:4px 0">${c.running?'Prüfung läuft …':'Geprüft '+fmt(c.checked_at)+' mit Core '+esc((c.core_sha||'').slice(0,10))}${c.forced?' · <b>Abweichender Admin-Core (erzwungen)</b>':''} · ${Object.entries(c.summary||{}).filter(([k,v])=>v).map(([k,v])=>v+'× '+k).join(', ')}</div>
<table class="t"><tbody>${rows}</tbody></table>`;
}
function renderRel(){
document.getElementById('rel').innerHTML = D.releases.length ? `<table class="t"><thead><tr><th>Release</th><th>Nachricht</th><th>Gemeldet</th><th>Status</th><th>Admin</th><th></th></tr></thead><tbody>`+D.releases.map(r=>{
const c = r.compat, sum = c&&c.summary ? c.summary : null;
return `<tr><td class="nowrap"><span class="mono">${esc(r.sha.slice(0,10))}</span> ${r.active?'<span class="badge ok">aktiv</span>':''}<div class="hint">${esc(r.ref)}</div></td><td>${esc(r.message)}</td><td class="nowrap">${fmt(r.announced_at)}</td>
<td><span class="badge ${stCls[r.status]||''}">${stLabel[r.status]||r.status}</span>${sum?`<div class="hint">${sum.ok||0} ok · ${sum.changed||0} geändert · ${sum.new||0} neu · ${sum.error||0} Fehler</div>`:''}</td>
<td>${r.admin_matches?'<span class="badge ok">gleicher Core</span>':'<span class="badge warn" title="Admin muss FROM diesem Core neu gebaut werden">Admin älter/anders</span>'}</td>
<td style="text-align:right" class="nowrap"><button class="btn sm" onclick="tg(${r.id})">Matrix</button>
<button class="btn sm" onclick="act(${r.id},'check')">Prüfen</button>
<button class="btn sm" ${['checked','approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'} onclick="approve(${r.id})">Freigeben</button>
<button class="btn sm" onclick="rollout(${r.id},true)">Dry-Run</button>
<button class="btn sm cyan ${D.live&&['approved','active','failed','rolled_back'].includes(r.status)?'':'disabled'}" onclick="rollout(${r.id},false)">Ausrollen</button>
${r.previous_tag?`<button class="btn sm danger ${D.live?'':'disabled'}" onclick="rollback(${r.id})">Zurückrollen</button>`:''}
${['new','checked'].includes(r.status)?`<button class="btn sm danger" onclick="act(${r.id},'reject')">Ablehnen</button>`:''}</td></tr>
<tr style="display:${open_[r.id]?'table-row':'none'}"><td colspan="6">${matrix(r)}</td></tr>`}).join('')+'</tbody></table>'
: '<div class="empty">Noch kein Release gemeldet. Die Core-CI meldet Images per POST /api/core/releases (Job „notify-admin“).</div>';
}
function tg(id){ open_[id] = !open_[id]; renderRel(); }
async function act(id, what){
try{ await api('POST', `/api/core/releases/${id}/${what}`, what==='check' ? {force: !D.releases.find(r=>r.id===id).admin_matches && confirm('Die Admin-Plattform läuft nicht auf diesem Core. Trotzdem mit dem aktuellen Admin-Core prüfen (nur Entwicklung)?')} : {}); toast(what==='check'?'Prüfung gestartet':'ok'); open_[id]=true; load(); }
catch(e){ toast(e.message,1) }
}
async function approve(id){
const r = D.releases.find(x=>x.id===id); const changed = r.compat&&r.compat.summary&&r.compat.summary.changed;
if(changed && !confirm(changed+' Tenant(s) rendern anders als zuvor (siehe Matrix). Bewusst freigeben?')) return;
try{ await api('POST', `/api/core/releases/${id}/approve`, {acknowledge: !!changed}); toast('Freigegeben'); load(); }catch(e){ toast(e.message,1) }
}
async function rollout(id, dry){
if(!dry && !confirm('Release jetzt Canary-zuerst auf alle (nicht gepinnten) Tenants ausrollen? Bei einem Fehler wird abgebrochen.')) return;
let r; try{ r = await api('POST', `/api/core/releases/${id}/rollout`, {dry_run: dry}); }catch(e){ return toast(e.message,1) }
followBatch(r.batch, dry ? 'Dry-Run des Rollouts' : 'Rollout läuft');
}
async function rollback(id){
if(!confirm('Alle Tenants auf den Tag vor diesem Rollout zurückrollen?')) return;
try{ const r = await api('POST', `/api/core/releases/${id}/rollback`); followBatch(r.batch, 'Rollback läuft'); }catch(e){ toast(e.message,1) }
}
function followBatch(batch, title){
document.getElementById('roll-title').textContent = title; document.getElementById('roll-hint').textContent = 'Reihenfolge: Canary zuerst, dann der Rest; Abbruch beim ersten Fehler.'; modal('m-roll', true);
const tick = async () => {
const rows = await api('GET', '/api/deployments?batch=' + batch);
document.querySelector('#roll-table tbody').innerHTML = rows.map(d=>{ const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10); const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''; return `<tr><td><b>${esc(d.tenant)}</b></td><td class="mono">:${esc(String(d.image).slice(0,10))}</td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`; }).join('');
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick, 800);
}; tick();
}
async function saveCanary(){ try{ await api('PUT','/api/settings',{canary_tenants: document.getElementById('canary').value}); toast('Gespeichert'); }catch(e){ toast(e.message,1) } }
load();
</script>{% endraw %}
{% endblock %}

View File

@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}Tenants · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head">
<div><div class="eyebrow">Übersicht</div><h1 class="title">Tenants.</h1></div>
<div class="row">
<button class="btn" onclick="probe()" title="Fragt /health jedes Tenants ab: läuft der erwartete Core und das erwartete Bundle?">Zustand prüfen</button>
<button class="btn" onclick="deployAll(true)" title="Plan für alle Tenants, ohne Schreibzugriff">Dry-Run aller</button>
<button class="btn {{ '' if deploy_live() else 'disabled' }}" onclick="deployAll(false)" title="{{ '' if deploy_live() else 'Gesperrt: DEPLOY_LIVE ist nicht aktiv' }}">Alle neu deployen</button>
<button class="btn cyan" onclick="modal('m-new',true)">+ Neuer Tenant</button>
</div>
</div>
<div class="kpis">
<div class="kpi click" data-f="all"><b>{{ stats.tenants }}</b><span>Tenants</span></div>
<div class="kpi click" data-f="admin"><b>{{ stats.admin }}</b><span>Quelle Admin</span></div>
<div class="kpi click" data-f="outdated"><b>{{ stats.outdated }}</b><span>Deploy veraltet</span></div>
<div class="kpi click" data-f="draft"><b>{{ stats.drafts }}</b><span>Entwürfe</span></div>
<div class="kpi click" data-f="secrets"><b>{{ stats.secrets }}</b><span>Secrets offen</span></div>
<div class="kpi"><b class="mono" style="font-size:13px">:{{ tag }}</b><span>Core {{ core }}</span></div>
</div>
<div class="card" style="padding:10px 12px">
<div class="row" style="margin:2px 2px 8px">
<input class="search" id="q" placeholder="Tenant suchen …" autofocus>
<div class="chipbar" id="chips">
{% for k,l in [('all','Alle'),('draft','Entwurf'),('outdated','Deploy veraltet'),('secrets','Secrets offen'),('git','Git'),('admin','Admin'),('code','mit Hook-Code'),('paused','pausiert / archiviert')] %}<span class="fchip {{ 'on' if k=='all' }}" data-f="{{ k }}">{{ l }}</span>{% endfor %}
</div>
</div>
<table class="t hover" id="tt"><thead><tr><th>Tenant</th><th>Version</th><th>Quelle</th><th>Marke</th><th>Anpassungen</th><th>Ressourcen</th><th>Läuft</th><th>Hinweise</th></tr></thead><tbody>
{% for r in rows %}
<tr onclick="location.href='/tenants/{{ r.t.id }}'" data-q="{{ (r.t.id ~ ' ' ~ r.t.name ~ ' ' ~ r.host)|lower }}"
data-flags="{{ 'draft ' if r.t.draft }}{{ 'outdated ' if r.outdated }}{{ 'secrets ' if r.missing_secrets }}{{ r.t.source }} {{ 'code ' if r.has_code }}{{ 'paused ' if r.t.status!='active' }}">
<td><div class="row" style="flex-wrap:nowrap"><div class="logo-box">{% if r.logo %}<img src="{{ r.logo }}" alt="">{% endif %}</div>
<div><b>{{ r.t.name }}</b>{% if r.t.status!='active' %} <span class="badge warn">{{ r.t.status }}</span>{% endif %}<br><span class="muted">{{ r.host }}</span></div></div></td>
<td class="nowrap"><span class="badge dark">v{{ r.t.current_version }}</span>
<span class="muted"><span class="dotc {{ 'warn' if r.outdated else 'ok' }}"></span>{{ 'deployt v'~(r.t.deployed_version or '–') if r.outdated else 'deployt' }}</span></td>
<td><span class="badge {{ 'cyan' if r.t.source=='admin' else '' }}">{{ 'Admin' if r.t.source=='admin' else 'Git' }}</span></td>
<td><span class="swatches">{% for k in ['accent','accent_dark','accent_light','bg','red'] %}<i style="background:{{ r.cfg.get('colors',{}).get(k,'#ddd') }}"></i>{% endfor %}</span></td>
<td class="muted">STT {{ r.stt }}{% if r.custom_prompt %} · Prompt{% endif %}{% if r.custom_pdf %} · PDF{% endif %}{% if r.has_code %} · <span style="color:#8a6200;font-weight:700">Code</span>{% endif %}</td>
<td class="muted nowrap">{{ r.t.cpu }} CPU · {{ r.t.memory }}</td>
<td class="run nowrap" data-t="{{ r.t.id }}"><span class="muted">–</span></td>
<td>{% if r.t.draft %}<span class="badge warn">Entwurf</span> {% endif %}{% if r.missing_secrets %}<span class="badge err">{{ r.missing_secrets }} Secret(s)</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody></table>
<div class="empty hide" id="none">Keine Tenants für diesen Filter.</div>
</div>
<div class="card" style="margin-top:12px"><h3>Letzte Deployments <a href="/deployments" class="muted" style="font-weight:400;font-size:12px">· alle ansehen</a></h3>
{% if last %}<table class="t"><tbody>{% for d in last %}<tr><td class="nowrap">{{ d.created_at|dt }}</td><td><b>{{ d.tenant_id or 'alle' }}</b></td><td>{{ d.kind }}</td><td>v{{ d.version }}</td>
<td><span class="badge {{ 'ok' if d.status=='success' else 'err' if d.status in ('failed',) else '' }}">{{ d.status }}</span></td></tr>{% endfor %}</tbody></table>
{% else %}<div class="empty">Noch keine Deployments.</div>{% endif %}
</div>
</div>
<div class="modal-bg" id="m-new"><div class="modal" style="max-width:520px">
<h2>Neuer Tenant</h2>
<div class="fgrid">
<label class="f">Name<input type="text" id="n-name" placeholder="z. B. Musterklinik GmbH"></label>
<label class="f">ID (Slug)<input type="text" id="n-id" placeholder="musterklinik"></label>
<label class="f full">Ausgangspunkt
<select id="n-clone"><option value="">Leer – Core-Standard (Prompt, PDF-Layout, Farben)</option>
{% for r in rows %}<option value="{{ r.t.id }}">Kopie von {{ r.t.name }}</option>{% endfor %}</select></label>
</div>
<p class="hint" style="margin-top:8px">SESSION_SECRET und Bundle-Token werden erzeugt. OIDC-Client und Cost-Key folgen unter „Domain &amp; Login“ / „Secrets“.</p>
<div class="acts"><button class="btn" onclick="modal('m-new',false)">Abbrechen</button><button class="btn cyan" onclick="createTenant()">Anlegen</button></div>
</div></div>
<div class="modal-bg" id="m-all"><div class="modal">
<h2 id="all-title">Rollout</h2><p class="hint" id="all-hint"></p>
<table class="t" id="all-table"><tbody></tbody></table>
<div class="acts"><button class="btn" onclick="modal('m-all',false);location.reload()">Schließen</button></div>
</div></div>
{% endblock %}
{% block scripts %}
<script>
let flt='all';
function apply(){
const q=document.getElementById('q').value.toLowerCase().trim(); let n=0;
document.querySelectorAll('#tt tbody tr').forEach(tr=>{
const ok=(!q||tr.dataset.q.includes(q))&&(flt==='all'||tr.dataset.flags.split(' ').includes(flt)); tr.style.display=ok?'':'none'; n+=ok;});
document.getElementById('none').classList.toggle('hide',n>0);
document.querySelectorAll('#chips .fchip').forEach(c=>c.classList.toggle('on',c.dataset.f===flt));
}
document.getElementById('q').addEventListener('input',apply);
document.querySelectorAll('[data-f]').forEach(e=>e.addEventListener('click',()=>{flt=e.dataset.f;apply()}));
document.getElementById('n-name').addEventListener('input',e=>{
const idEl=document.getElementById('n-id'); if(idEl.dataset.touched) return;
idEl.value=e.target.value.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,38);
});
document.getElementById('n-id').addEventListener('input',e=>e.target.dataset.touched=1);
async function probe(){
document.querySelectorAll('td.run').forEach(td=>td.innerHTML='<span class="muted">…</span>');
try{
const r = await api('GET','/api/status?refresh=1');
document.querySelectorAll('td.run').forEach(td=>{ const x=r[td.dataset.t]; if(!x){ td.innerHTML='<span class="muted">–</span>'; return; }
td.innerHTML = x.reachable && !x.drift.length ? `<span class="badge ok" title="Core ${esc(x.core?.version||'')}">${esc((x.core?.version||'?').slice(0,7))}${x.bundle?.source==='admin'?' · b'+x.bundle.version:''}</span>` : `<span class="badge err" title="${esc(x.drift.join(' · '))}">${esc(x.drift[0]||'Abweichung')}</span>`; });
toast('Zustand aktualisiert');
}catch(e){ toast(e.message,1) }
}
async function createTenant(){
try{const r=await api('POST','/api/tenants',{id:document.getElementById('n-id').value,name:document.getElementById('n-name').value,clone_from:document.getElementById('n-clone').value||null});location.href='/tenants/'+r.id+'#brand'}
catch(e){toast(e.message,1)}
}
async function deployAll(dry){
if(!dry && !confirm('Alle aktiven Tenants nacheinander neu deployen? Beim ersten Fehler wird abgebrochen.')) return;
let r; try{r=await api('POST','/api/deploy-all',{dry_run:dry})}catch(e){return toast(e.message,1)}
document.getElementById('all-title').textContent=dry?'Dry-Run aller Tenants':'Rollout aller Tenants';
document.getElementById('all-hint').textContent=dry?'Es wird nichts geschrieben – nur der Plan geprüft.':'Sequenziell mit Health-Check; Abbruch beim ersten Fehler.';
modal('m-all',true);
const tick=async()=>{
const rows=await api('GET','/api/deployments?batch='+r.batch);
document.querySelector('#all-table tbody').innerHTML=rows.map(d=>{
const last=(d.log.trim().split('\n').filter(l=>!l.includes('[DRY-RUN]')&&!l.includes('body:')).pop()||'').slice(10);
const cls=d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':'';
return `<tr><td><b>${esc(d.tenant)}</b></td><td><span class="badge ${cls}">${d.status}</span></td><td class="muted mono">${esc(last)}</td></tr>`}).join('');
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(tick,700);
};tick();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Deployments · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head">
<div><div class="eyebrow">Rollout</div><h1 class="title">Deployments.</h1>
<p class="lead">Differenzielles PATCH auf den Mittwald-Stack (nie PUT), Namensraum <span class="mono">app-</span>, Guard für fremde Container, Abbruch beim ersten Fehler.</p></div>
<div class="row"><button class="btn" onclick="run(true)">Dry-Run aller</button><button class="btn cyan {{ '' if deploy_live() else 'disabled' }}" onclick="run(false)">Alle neu deployen</button></div>
</div>
{% if not deploy_live() %}<div class="banner warn">Echte Deployments sind <b>gesperrt</b> (Env <span class="mono">DEPLOY_LIVE</span> der Admin-Plattform ist nicht <span class="mono">true</span>). Dry-Runs laufen mit demselben Code und zeigen exakt, was geschrieben würde.</div>{% endif %}
<div class="card" style="margin-bottom:12px"><div class="row"><b>Core-Image</b><span class="badge dark mono">:{{ tag }}</span>
<span class="muted">Image-Tag und Registry unter Einstellungen. Nach dem Core-Umbau holen Tenants ihr Bundle zur Laufzeit – dann genügt ein Neustart statt Image-Build.</span></div></div>
<div class="card"><h3>Verlauf</h3><div id="list"></div></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let open_={};
async function load(){
const rows=await api('GET','/api/deployments');
document.getElementById('list').innerHTML=rows.length?`<table class="t"><thead><tr><th>#</th><th>Zeit</th><th>Tenant</th><th>Art</th><th>Version</th><th>Image</th><th>Status</th><th></th></tr></thead><tbody>`+rows.map(d=>`
<tr><td>${d.id}</td><td class="nowrap">${fmt(d.created_at)}</td><td><b>${esc(d.tenant||'alle')}</b></td><td>${d.kind} ${d.simulated?'<span class="badge">Dry-Run</span>':'<span class="badge err">live</span>'}</td><td>v${d.version??'–'}</td><td class="mono">:${esc(d.image)}</td>
<td><span class="badge ${d.status==='success'?'ok':d.status==='failed'?'err':d.status==='running'?'cyan':''}">${d.status}</span></td><td><button class="btn sm" onclick="tg(${d.id})">Log</button></td></tr>
<tr style="display:${open_[d.id]?'table-row':'none'}"><td colspan="8"><div class="console">${esc(d.log||'(leer)')}</div></td></tr>`).join('')+'</tbody></table>':'<div class="empty">Noch keine Deployments.</div>';
if(rows.some(d=>d.status==='running'||d.status==='queued')) setTimeout(load,900);
}
function tg(id){open_[id]=!open_[id];load()}
async function run(dry){ if(!dry&&!confirm('Alle aktiven Tenants nacheinander neu deployen?'))return; try{await api('POST','/api/deploy-all',{dry_run:dry});toast('Rollout gestartet');load()}catch(e){toast(e.message,1)} }
load();
</script>
{% endblock %}

133
app/templates/settings.html Normal file
View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}Einstellungen · Onyscribe Admin{% endblock %}
{% block body %}
<div class="page">
<div class="head"><div><div class="eyebrow">Plattform</div><h1 class="title">Einstellungen.</h1>
<p class="lead">Geteilte Werte für alle Tenants – bisher Gitea-„Variables“ und <span class="mono">secrets/shared.enc.yaml</span>.</p></div>
<button class="btn cyan" onclick="save()">Alles speichern</button></div>
<div class="grid2">
<div class="card"><h3>Hosting &amp; Deploy</h3>
<div class="fgrid">
<label class="f">Basis-Domain<input type="text" data-k="base_domain" value="{{ v.base_domain }}"></label>
<label class="f">Core-Image-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}"></label>
<label class="f">Mittwald PROJECT_ID<input type="text" data-k="mittwald_project_id" value="{{ v.mittwald_project_id }}" placeholder="p-xxxxxx"></label>
<label class="f">Mittwald STACK_ID<input type="text" data-k="mittwald_stack_id" value="{{ v.mittwald_stack_id }}" placeholder="s-xxxxxx (optional)"></label>
<label class="f">Registry<input type="text" data-k="registry" value="{{ v.registry }}" placeholder="git.example.de/owner"></label>
<label class="f">Image-Name<input type="text" data-k="image_name" value="{{ v.image_name }}"></label>
<label class="f full">Öffentliche URL der Admin-Plattform (für ADMIN_URL im Container)<input type="text" data-k="admin_public_url" value="{{ v.admin_public_url }}" placeholder="https://admin.example.de"></label>
</div>
<div style="margin-top:10px">
<div class="check"><span class="dot {{ 'ok' if mode.token else 'no' }}">{{ '✓' if mode.token else '!' }}</span><span><span class="mono">MITTWALD_API_TOKEN</span> {{ 'gesetzt (lesende Dry-Run-Aufrufe möglich)' if mode.token else 'nicht gesetzt (Dry-Run ohne Lesezugriff)' }}</span></div>
<div class="check"><span class="dot {{ 'ok' if mode.live else 'no' }}">{{ '✓' if mode.live else '–' }}</span><span><span class="mono">DEPLOY_LIVE</span> {{ 'true – echte Deployments erlaubt' if mode.live else 'nicht aktiv – Schreibzugriffe gesperrt' }}</span></div>
<div class="check"><span class="dot {{ 'ok' if mode.allowed != 'keiner' else 'no' }}">{{ '✓' if mode.allowed != 'keiner' else '–' }}</span><span><span class="mono">DEPLOY_ALLOWED_TENANTS</span> = <b>{{ mode.allowed }}</b> <span class="hint">– nur diese Tenants werden live angefasst (Mittwald, Login-API)</span></span></div>
<label class="chk"><input type="checkbox" data-kb="core_supports_bundle" {{ 'checked' if v.core_supports_bundle=='true' }}><span><b>Core kann Bundle laden</b> <span class="hint">(<span class="mono">TENANT_SOURCE=admin</span>) – erst nach dem Core-Umbau aktivieren. Solange aus, sind Deploys von Tenants mit Quelle „Admin“ gesperrt.</span></span></label>
</div>
</div>
<div class="card"><h3>Login-API (OIDC-Clients automatisch verwalten)</h3>
<div class="check"><span class="dot {{ 'ok' if oidc.api_key else 'no' }}">{{ '✓' if oidc.api_key else '!' }}</span><span><span class="mono">ONYVA_LOGIN_API_KEY</span> {{ 'gesetzt' if oidc.api_key else 'nicht gesetzt – Abgleich beim Deploy nur als Hinweis (Dry-Run) bzw. Fehler (live)' }}</span></div>
<div class="check"><span class="dot {{ 'ok' if oidc.writes_allowed else 'no' }}">{{ '✓' if oidc.writes_allowed else '–' }}</span><span><span class="mono">OIDC_SYNC_LIVE</span> / <span class="mono">DEPLOY_LIVE</span> {{ 'aktiv – Schreiben im Login erlaubt' if oidc.writes_allowed else 'nicht aktiv – nur Lesen/Plan' }}</span></div>
<div class="fgrid" style="margin-top:6px"><label class="f full">API-Basis (leer = Issuer + /api/v1)<input type="text" data-k="onyva_login_api_base" value="{{ v.onyva_login_api_base }}" placeholder="{{ oidc.api_base }}"></label></div>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="testOidc()" {{ '' if oidc.api_key else 'disabled' }}>Verbindung testen</button><span class="hint" id="oidc-info">Liest GET /apps mit dem Key. Der Key liegt als Env der Admin-Plattform (wie ein Admin-Passwort), nie in der DB.</span></div>
</div>
<div class="card"><h3>Login (OIDC) &amp; Limits</h3>
<div class="fgrid">
<label class="f">Issuer (Tenant-Container)<input type="text" data-k="oidc_issuer" value="{{ v.oidc_issuer }}"></label>
<label class="f">Auth in Tenant-Containern<select data-k="auth_enabled"><option {{ 'selected' if v.auth_enabled=='true' }}>true</option><option {{ 'selected' if v.auth_enabled=='false' }}>false</option></select></label>
<label class="f">Cookie secure<select data-k="app_cookie_secure"><option {{ 'selected' if v.app_cookie_secure=='true' }}>true</option><option {{ 'selected' if v.app_cookie_secure=='false' }}>false</option></select></label>
<label class="f">Max. Dateigröße (MB)<input type="number" data-k="max_file_size_mb" value="{{ v.max_file_size_mb }}"></label>
</div>
<div class="banner info" style="margin:10px 0 0">Der Login der Admin-Plattform selbst wird per Env konfiguriert (<span class="mono">AUTH_ENABLED, OIDC_CLIENT_ID/SECRET, OIDC_REDIRECT_URI, ADMIN_ALLOWED_EMAILS</span>) – siehe README.</div>
</div>
<div class="card"><h3>LLM &amp; Transkription (Cortecs)</h3>
<div class="fgrid">
<label class="f full">Cortecs Base-URL<input type="text" data-k="cortecs_base_url" value="{{ v.cortecs_base_url }}"></label>
<label class="f">Protokoll-Modell (LLM_MODEL)<input type="text" data-k="llm_model" value="{{ v.llm_model }}"></label>
<label class="f">LLM_MAX_TOKENS / TEMPERATURE<div class="row" style="flex-wrap:nowrap"><input type="number" data-k="llm_max_tokens" value="{{ v.llm_max_tokens }}"><input type="text" data-k="llm_temperature" value="{{ v.llm_temperature }}" style="width:70px"></div></label>
<label class="f">Mistral-Modell<input type="text" data-k="mistral_transcribe_model" value="{{ v.mistral_transcribe_model }}"></label>
<label class="f">Mistral-Timeout (s)<input type="number" data-k="mistral_timeout" value="{{ v.mistral_timeout }}"></label>
</div>
<h3 style="margin-top:12px">Geteilte Secrets</h3>
{% for k in ['CORTECS_API_KEY','SMTP_PASSWORD'] %}
<div class="row" style="padding:3px 0"><span class="mono" style="width:150px">{{ k }}</span>
<span class="badge {{ 'ok' if shared.get(k) else 'warn' }}">{{ 'gesetzt' if shared.get(k) else 'extern (SOPS) – noch nicht übernommen' }}</span>
<button class="btn sm" onclick="setShared('{{ k }}')">Wert setzen</button></div>
{% endfor %}
</div>
<div class="card"><h3>KI-Agent (Admin-Chat)</h3>
<div class="row" style="margin-bottom:8px"><span class="badge {{ 'ok' if llm_ready else 'warn' }}">{{ 'Cortecs-Key vorhanden – echter Agent' if llm_ready else 'kein Key – Demo-Modus' }}</span></div>
<div class="fgrid">
<label class="f">Agent-Modell<input type="text" data-k="agent_model" id="agent_model" value="{{ v.agent_model }}" list="models"></label>
<label class="f">Protokoll<select data-k="agent_api"><option value="messages" {{ 'selected' if v.agent_api=='messages' }}>Anthropic Messages (/v1/messages)</option><option value="chat" {{ 'selected' if v.agent_api=='chat' }}>OpenAI Chat Completions</option></select></label>
</div>
<datalist id="models"></datalist>
<div class="row" style="margin-top:8px"><button class="btn sm" onclick="loadModels()">Modelle von Cortecs laden</button><span class="hint" id="models-info">Liest GET /v1/models mit dem geteilten Key und markiert Modelle mit Tool-Unterstützung.</span></div>
<div id="models-list" style="margin-top:8px"></div>
</div>
<div class="card"><h3>E-Mail (SMTP)</h3>
<div class="fgrid"><label class="f">SMTP_HOST<input type="text" data-k="smtp_host" value="{{ v.smtp_host }}"></label><label class="f">SMTP_PORT<input type="number" data-k="smtp_port" value="{{ v.smtp_port }}"></label>
<label class="f">SMTP_USER<input type="text" data-k="smtp_user" value="{{ v.smtp_user }}"></label><label class="f">SMTP_FROM<input type="text" data-k="smtp_from" value="{{ v.smtp_from }}"></label></div>
<h3 style="margin-top:12px">Kostenerfassung</h3>
<div class="fgrid"><label class="f">COST_API_BASE<input type="text" data-k="cost_api_base" value="{{ v.cost_api_base }}"></label><label class="f">COST_TRACKING_ENABLED<input type="text" data-k="cost_tracking_enabled" value="{{ v.cost_tracking_enabled }}" placeholder="true"></label></div>
</div>
<div class="card" style="grid-column:1/-1"><h3>Weitere Core-Einstellungen <span class="muted" style="font-weight:400">· aus dem Schema des Cores erzeugt, gelten für alle Tenants</span></h3>
<p class="sub">Alles, was der Core als geteilte Einstellung deklariert und oben noch keine eigene Maske hat. Neue Core-Variablen erscheinen hier automatisch und gehen beim Deploy ins Container-Env. Leer = Standard des Cores.</p>
<div id="dyn"><span class="muted">Lade …</span></div></div>
<div class="card"><h3>Rollout</h3>
<div class="fgrid"><label class="f">Canary-Tenants (kommagetrennt)<input type="text" data-k="canary_tenants" value="{{ v.canary_tenants }}" placeholder="demo"></label><label class="f">Aktiver Core-Tag<input type="text" data-k="core_image_tag" value="{{ v.core_image_tag }}" disabled></label></div>
<p class="hint" style="margin-top:6px">Der aktive Tag ändert sich durch Core-Releases (Menü „Core“).</p></div>
<div class="card"><h3>Core</h3>
<dl class="kv"><dt>Vendored Core</dt><dd class="mono">{{ core }}</dd><dt>Bedeutung</dt><dd>Live-Vorschau und Validierung führen genau diesen Stand aus (<span class="mono">vendor/platform</span>, aktualisieren mit <span class="mono">scripts/sync_core.sh</span>).</dd></dl>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function save(){
const body={};document.querySelectorAll('[data-k]').forEach(e=>body[e.dataset.k]=e.value);
document.querySelectorAll('[data-kb]').forEach(e=>body[e.dataset.kb]=e.checked?'true':'false');
try{await api('PUT','/api/settings',body);toast('Gespeichert')}catch(e){toast(e.message,1)}
}
async function setShared(k){
const v=prompt('Neuer Wert für '+k+' (wird verschlüsselt gespeichert):'); if(v===null)return;
try{await api('PUT','/api/shared-secrets/'+k,{value:v});toast('Gespeichert');location.reload()}catch(e){toast(e.message,1)}
}
async function testOidc(){
const i=document.getElementById('oidc-info'); i.textContent='Teste …';
try{const r=await api('POST','/api/oidc/test'); i.textContent='✔ Verbindung ok – '+r.apps+' App(s) im Login'}catch(e){i.textContent='✘ '+e.message}
}
async function loadDyn(){
const box=document.getElementById('dyn');
try{
const [c, cur] = await Promise.all([api('GET','/api/contract'), api('GET','/api/settings')]);
if(!c.schema){ box.innerHTML='<div class="hint">Dieser Core liefert kein Schema – nur die festen Einstellungen oben.</div>'; return; }
const have=new Set([...document.querySelectorAll('[data-k]')].map(e=>e.dataset.k));
const rest=c.schema.env.filter(e=>e.kind==='setting'&&e.scope==='shared'&&!have.has(e.setting));
const groups={}; rest.forEach(e=>(groups[e.group]??=[]).push(e));
const field=e=>{const val=cur[e.setting]??''; const ph=e.default??''; const inp=e.type==='bool'?`<select data-k="${esc(e.setting)}"><option value="" ${val===''?'selected':''}>Standard (${esc(ph||'Core')})</option><option ${val==='true'?'selected':''}>true</option><option ${val==='false'?'selected':''}>false</option></select>`:`<input type="text" data-k="${esc(e.setting)}" value="${esc(val)}" placeholder="${esc(ph||'Standard')}">`;
return `<label class="f">${esc(e.label)} <span class="mono" style="text-transform:none;letter-spacing:0">${esc(e.name)}</span>${inp}${e.help?`<span class="hint" style="text-transform:none;letter-spacing:0;font-weight:400">${esc(e.help)}</span>`:''}</label>`;};
box.innerHTML=Object.entries(groups).map(([g,list])=>{const adv=list.filter(e=>e.advanced), basic=list.filter(e=>!e.advanced);
return `<h3 style="margin:10px 0 4px;font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:1.2px">${esc(g)}</h3>${basic.length?`<div class="fgrid">${basic.map(field).join('')}</div>`:''}${adv.length?`<details class="fold"><summary>Erweitert (${adv.length})</summary><div class="in"><div class="fgrid">${adv.map(field).join('')}</div></div></details>`:''}`}).join('')||'<div class="hint">Alle Core-Einstellungen haben oben eine eigene Maske.</div>';
}catch(e){ box.innerHTML='<div class="hint">Schema nicht ladbar: '+esc(e.message)+'</div>'; }
}
loadDyn();
async function loadModels(){
const info=document.getElementById('models-info'); info.textContent='Lade …';
try{
const m=await api('GET','/api/llm/models');
document.getElementById('models').innerHTML=m.map(x=>`<option value="${esc(x.id)}">`).join('');
document.getElementById('models-list').innerHTML=`<div class="chipbar">${m.map(x=>`<span class="fchip" onclick="document.getElementById('agent_model').value='${esc(x.id)}'" title="${x.tools===true?'Tool-Calling: ja':x.tools===false?'Tool-Calling: nein':'Tool-Calling: unbekannt – testen'}">${esc(x.id)} ${x.tools===true?'🔧':x.tools===false?'✖':'?'}</span>`).join('')}</div>`;
info.textContent=m.length+' Modelle · 🔧 = Tools laut Cortecs, ? = unbekannt. Klick übernimmt die ID.';
}catch(e){info.textContent=e.message}
}
</script>
{% endblock %}

51
app/templates/tenant.html Normal file
View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ t.name }} · Onyscribe Admin{% endblock %}
{% block body %}
<div class="thead">
<div class="logo-box" id="h-logo">{% if logo %}<img src="{{ logo }}" alt="">{% endif %}</div>
<div class="grow" style="min-width:160px">
<div class="crumb"><a href="/">Tenants</a> / <span class="mono">{{ t.id }}</span></div>
<h1 id="h-name">{{ t.name }}</h1>
</div>
<div class="row" id="h-badges"></div>
<div class="row" id="h-actions"></div>
<div class="seg" id="h-panel" title="Mitte: Formular oder KI-Agent – die Vorschau rechts bleibt sichtbar">
<button data-p="edit">Bearbeiten</button><button data-p="agent">KI-Agent</button>
</div>
</div>
<div class="tl open" id="tl">
<nav class="snav" id="snav"></nav>
<div class="mid">
<main class="pane" id="pane"><div class="empty">Lade …</div></main>
<section class="chat agpane hide" id="ag-wrap">
<div class="pbar" style="margin-bottom:6px"><span class="badge" id="ag-mode">Agent</span><span class="hint grow" id="ag-usage"></span><button class="btn sm" title="Verlauf löschen" onclick="clearChat()">Verlauf leeren</button></div>
<div class="msgs" id="msgs"></div>
<div class="chips" id="ag-chips"></div>
<div class="chat-in"><textarea id="chat-text" rows="2" placeholder="Was soll angepasst werden? (Farben, Prompt, PDF-Layout, Flags …) – die Vorschau rechts zeigt jede Änderung sofort."></textarea><button class="btn cyan" id="chat-send" onclick="sendChat()">Senden</button></div>
</section>
</div>
<aside class="panel" id="panel">
<div class="pbar">
<div class="seg" id="pv-kind"><button data-k="web" class="on">Web-App</button><button data-k="pdf">PDF</button></div>
<div class="seg" id="pv-dev"><button data-d="" class="on">Desktop</button><button data-d="tablet">Tablet</button><button data-d="mobile">Mobil</button></div>
<div class="seg" id="pv-src"><button data-s="draft" class="on">Entwurf</button><button data-s="current">Veröffentlicht</button><button data-s="agent" id="pv-agent" class="hide">Agent-Stand</button></div>
<span class="grow"></span><span class="hint" id="pv-info"></span><button class="btn sm" onclick="refreshPreview(true)" title="Neu rendern">↻</button>
</div>
<div class="pv-frame" id="pv-frame">
<iframe id="f-web" sandbox="allow-scripts allow-popups" title="Web-Vorschau"></iframe>
<iframe id="f-pdf" style="display:none" title="PDF-Vorschau"></iframe>
<div class="pv-load" id="pv-load">Rendert mit dem echten Core …</div><div class="pv-err" id="pv-err"></div>
</div>
</aside>
</div>
<div class="modal-bg" id="m-pub"><div class="modal">
<h2>Änderungen prüfen &amp; veröffentlichen</h2><div id="pub-body"></div>
<div class="acts"><button class="btn" onclick="modal('m-pub',false)">Abbrechen</button><button class="btn cyan" id="pub-go" onclick="publish()">Als neue Version veröffentlichen</button></div>
</div></div>
<div class="modal-bg" id="m-diff"><div class="modal"><h2 id="diff-title">Diff</h2><div id="diff-body"></div><div class="acts"><button class="btn" onclick="modal('m-diff',false)">Schließen</button></div></div></div>
{% endblock %}
{% block scripts %}
<script>const TID={{ t.id|tojson }};</script>
<script src="/static/tenant.js"></script>
{% endblock %}

32
app/templating.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi.templating import Jinja2Templates
from app.settings import APP_NAME, ROOT
templates = Jinja2Templates(directory=str(ROOT / "app" / "templates"))
BERLIN = ZoneInfo("Europe/Berlin")
def dt(value: datetime | None, fmt: str = "%d.%m.%Y %H:%M") -> str:
if not value:
return "–"
if value.tzinfo is None:
from datetime import timezone
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(BERLIN).strftime(fmt)
templates.env.filters["dt"] = dt
templates.env.globals["APP_NAME"] = APP_NAME
def _live() -> bool:
from app.services import deploy
return deploy.live_enabled()
templates.env.globals["deploy_live"] = _live

View File

@@ -0,0 +1,6 @@
# Nur lokal (docker compose lädt diese Datei automatisch dazu): Port auf dem Host veröffentlichen.
# Coolify nutzt sie nicht – dort läuft der Zugriff über den Proxy (Domain auf Service „admin“, Port 8300).
services:
admin:
ports:
- "${ADMIN_PORT:-8300}:8300"

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
# Onyscribe Admin – Admin-Oberfläche + Postgres. Für Coolify (Docker-Compose-Ressource) und lokal.
# Pflicht-Env: POSTGRES_PASSWORD, SECRET_KEY
# SECRET_KEY erzeugen: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Backups: Volume `pgdata` (Postgres) sichern – darin liegt ALLES (Tenants, Versionen, verschlüsselte Secrets).
# ACHTUNG: SECRET_KEY ebenfalls sichern – ohne ihn sind die Secrets in der DB nicht entschlüsselbar.
services:
admin:
build:
context: .
# Standard: vendored Core (lokal). Produktion: ADMIN_DOCKERFILE=Dockerfile.fromcore + CORE_IMAGE=<registry>/transkript-platform:<tag>
dockerfile: ${ADMIN_DOCKERFILE:-Dockerfile}
args:
CORE_IMAGE: ${CORE_IMAGE:-scratch}
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://onyscribe:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD setzen}@db:5432/onyscribe
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY setzen}
PORT: "8300"
# Login der Admin-Plattform (login.onyva.dev). Mit AUTH_ENABLED=true sind Client + Zugriffsregel Pflicht.
AUTH_ENABLED: ${AUTH_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-https://login.onyva.dev}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
OIDC_LOGOUT_REDIRECT_URI: ${OIDC_LOGOUT_REDIRECT_URI:-}
APP_COOKIE_SECURE: ${APP_COOKIE_SECURE:-true}
ADMIN_ALLOWED_EMAILS: ${ADMIN_ALLOWED_EMAILS:-}
ADMIN_REQUIRED_ROLE: ${ADMIN_REQUIRED_ROLE:-}
# Deploys: ohne DEPLOY_LIVE=true nur Dry-Run. Token nur setzen, wenn lesende Dry-Run-Aufrufe gewünscht sind.
DEPLOY_LIVE: ${DEPLOY_LIVE:-false}
# Welche Tenants dürfen LIVE angefasst werden (Mittwald + Login-API)? leer = keiner · demo-admin,x = diese · * = alle
DEPLOY_ALLOWED_TENANTS: ${DEPLOY_ALLOWED_TENANTS:-}
MITTWALD_API_TOKEN: ${MITTWALD_API_TOKEN:-}
# Core-CI meldet neue Images hierher (POST /api/core/releases, Bearer-Token)
CORE_RELEASE_TOKEN: ${CORE_RELEASE_TOKEN:-}
# OIDC-Clients automatisch verwalten (Developer-API von login.onyva.dev). Key = Admin-Passwort-Äquivalent!
ONYVA_LOGIN_API_KEY: ${ONYVA_LOGIN_API_KEY:-}
OIDC_SYNC_LIVE: ${OIDC_SYNC_LIVE:-false} # true erlaubt Schreiben im Login-System (auch ohne DEPLOY_LIVE)
# Optional statt Eintrag unter Einstellungen → Geteilte Secrets
CORTECS_API_KEY: ${CORTECS_API_KEY:-}
expose:
- "8300" # Coolify: Domain auf Service „admin“ legen, Port 8300 (https://<host>:8300). Lokal veröffentlicht docker-compose.override.yml den Port.
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: onyscribe
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD setzen}
POSTGRES_DB: onyscribe
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U onyscribe -d onyscribe"]
interval: 5s
timeout: 5s
retries: 10
volumes:
pgdata:

View File

@@ -0,0 +1,289 @@
# Runbook: Admin-Plattform parallel zum Altbetrieb testen (ohne Merge, nur mit Demo-Tenant)
Ziel: Die neue Admin-Plattform und den neuen Core **parallel** zu den laufenden Tenants betreiben – mit **einem** neuen Test-Tenant
`demo-admin`. Nichts Bestehendes wird angefasst, es wird **nichts gemergt**, und jeder Schritt lässt sich einzeln zurücknehmen.
---
## 0. So läuft das Ganze parallel (und warum der Altbetrieb sicher ist)
```
ALTBETRIEB (unverändert) NEUBETRIEB (Test)
──────────────────────── ─────────────────
Gitea: main / cortecs-llm-migration Gitea: Branch admin-platform-integration
└ build-deploy.yml → :latest + deploy-all └ build-branch-image.yml → NUR Image :<sha> (kein :latest, kein Deploy)
Mittwald: app-demo, app-rotary, app-sprinz, … Mittwald: app-demo-admin ← neuer, eigener Service + Volume + Ingress
Domain: demo.<DOMAIN> … Domain: demo-admin.<DOMAIN>
Daten: ins Image gebacken (tenants/) Daten: Bundle von der Admin-Plattform (TENANT_SOURCE=admin)
OIDC-Client: von Hand angelegt OIDC-Client: automatisch über die Login-API
Admin-Plattform auf Coolify (eigene Domain, eigene Postgres)
```
Schutzmechanismen (alle eingebaut und getestet):
| Schutz | Wirkung |
|---|---|
| Branch-Trennung | `build-deploy.yml` läuft nur auf `main`/`cortecs-llm-migration` (auch nicht per „Run workflow“ vom Test-Branch). Der Test-Branch baut nur ein Image mit Tag = Git-SHA. |
| Eigener Tenant | `demo-admin` hat eigenen Service `app-demo-admin`, eigenes Volume `vol-demo-admin`, eigenen Host und eigenen OIDC-Client. `app-demo` bleibt unberührt. |
| `DEPLOY_ALLOWED_TENANTS=demo-admin` | Die Admin-Plattform darf **nur** diesen Tenant live anfassen (Mittwald **und** Login-API). Auch „Alle neu deployen“ trifft nur ihn. |
| `DEPLOY_LIVE=false` zum Start | Erst nur Dry-Run (liest den Stack, schreibt nichts). |
| Differenzielles PATCH + Guard | Es wird nur `app-demo-admin` geschrieben; danach prüft der Admin, dass alle fremden Services noch da sind. |
| Default `TENANT_SOURCE=git` im Core | Der neue Core verhält sich ohne Env wie der alte. |
Rückgängig machen: siehe **Abschnitt 11**.
---
## 1. Werte zusammentragen (einmalig)
| Wert | Woher | Verwendung |
|---|---|---|
| `DOMAIN` | Gitea → Repo `transkriptipt-platform` → Einstellungen → Actions → **Variablen** | Basis-Domain in der Admin-Plattform |
| `PROJECT_ID`, `STACK_ID` | dito | Mittwald-Ziel |
| `REGISTRY` (z. B. `gitea.onyva.dev/onyva`) | dito | Registry in der Admin-Plattform |
| `MITTWALD_API_TOKEN` | Gitea → **Secrets** (du kennst den Wert; derselbe Token) | Env der Admin-Plattform |
| Weitere Variablen: `OIDC_ISSUER`, `AUTH_ENABLED`, `APP_COOKIE_SECURE`, `MAX_FILE_SIZE_MB`, `CORTECS_BASE_URL`, `LLM_MODEL`, `LLM_MAX_TOKENS`, `LLM_TEMPERATURE`, `SMTP_HOST/PORT/USER/FROM`, `MISTRAL_*`, `COST_*` | dito | in **Einstellungen** der Admin-Plattform (Abschnitt 6) |
| `CORTECS_API_KEY`, `SMTP_PASSWORD` | `cd ~/PycharmProjects/transkript-platform && sops -d --extract '["CORTECS_API_KEY"]' secrets/shared.enc.yaml` (analog `SMTP_PASSWORD`) | Einstellungen → Geteilte Secrets |
| `COST_INGEST_KEY` von `demo` | `sops -d --extract '["COST_INGEST_KEY"]' tenants/demo/secrets.enc.yaml` | Secret des Test-Tenants (Abschnitt 7, Punkt 7.4) |
| **`<ADMIN_HOST>`** | du wählst: Domain, unter der Coolify die Admin-Plattform ausliefert, z. B. `admin.deine-domain.de` | muss **öffentlich per HTTPS** erreichbar sein (der Tenant-Container holt von dort sein Bundle) |
| **`<DEINE_MAIL>`** | deine Login-E-Mail bei login.onyva.dev (Konto muss dort existieren) | Admin-Zugriff + Freigabe im Test-Tenant |
Werte **nicht** in Chats oder Tickets kopieren (Keys, Secrets).
---
## 2. Core-Branch pushen (Image bauen lassen)
```bash
cd ~/PycharmProjects/transkript-platform
git branch --show-current # → admin-platform-integration
git log --oneline -3 # 13055b8 CI: build-deploy nur von Produktions-Branches …
git rev-parse HEAD # → voller SHA; z. B. 13055b811ca0030841f41ce1f10e4f30cba1a7db ← MERKEN (= CORE_SHA)
git push -u origin admin-platform-integration
```
Was jetzt in Gitea passiert (Repo → **Actions**):
| Workflow | Erwartung |
|---|---|
| `test` | grün (Schema ↔ Code ↔ Tenants, Bundle-Loader) |
| `build-branch-image` | grün; Log-Zeile „Image gebaut: `<registry>/transkript-platform:<SHA>`“ |
| `build-deploy` | **läuft nicht** (Branch nicht gelistet) – wenn doch: sofort abbrechen und melden |
Prüfen: Gitea → Repo → **Packages** → `transkript-platform` hat einen neuen Tag = voller SHA. Der Tag `latest` ist **unverändert** (Zeitstempel!).
> ✅ Ergebnis: `CORE_SHA` (voller 40-stelliger SHA). Lege ihn in deine Notizen.
---
## 3. Admin-Plattform ins eigene Repo
1. Gitea → **Neues Repository**: Organisation `Onyva`, Name `onyscribe-admin`, **privat**, leer (ohne README/.gitignore/Lizenz).
2. Lokal:
```bash
cd ~/PycharmProjects/onyscribe-admin
git status --short | head # .env und .data dürfen NICHT erscheinen (sind ignoriert)
git ls-files | grep -E '(^|/)\.env$|\.db$' || echo "ok: keine Secrets/DB im Index"
git add -A
git commit -m "Onyscribe Admin: Initialer Stand"
git remote add origin https://gitea.onyva.dev/Onyva/onyscribe-admin.git
git push -u origin main
```
Hinweis: `seed/tenants.seed.json.gz` enthält die Tenant-Konfigurationen inkl. Zusatzdateien (z. B. `namensliste.txt` – personenbezogen). Das liegt heute schon im Altrepo; Repo privat halten.
---
## 4. Login-System: Client + API-Key für die Admin-Plattform
Im Login-Dashboard (login.onyva.dev), als Admin:
1. **API-Keys → neuer Key** („onyscribe-admin“, ggf. Ablaufdatum). Klartext `onyva_…` **einmalig** kopieren → später `ONYVA_LOGIN_API_KEY`.
2. Client für die Admin-Plattform selbst anlegen (Dashboard **oder** per API):
```bash
KEY=onyva_… # der Key aus 1.
curl -sX POST https://login.onyva.dev/api/v1/apps -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name":"Onyscribe Admin","redirect_uris":["https://<ADMIN_HOST>/auth/callback"],"auth_mode":"required","totp_mode":"disabled"}'
# → client_id + client_secret SOFORT sichern (nur einmal sichtbar)
curl -sX PUT https://login.onyva.dev/api/v1/apps/<client_id>/memberships -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"user_email":"<DEINE_MAIL>","role":"admin"}'
```
Ergebnis: `client_id`, `client_secret` (Admin-Login), `ONYVA_LOGIN_API_KEY`.
---
## 5. Coolify: Admin-Plattform deployen
**Neue Ressource → Docker Compose (aus Git)**: Repo `Onyva/onyscribe-admin`, Branch `main`, Compose-Datei `docker-compose.yml`
(Zugriff auf das private Repo per Deploy-Key/Token einrichten).
**Umgebungsvariablen** (Coolify → Ressource → Environment):
| Variable | Wert | Phase |
|---|---|---|
| `POSTGRES_PASSWORD` | langes Zufallspasswort | immer |
| `SECRET_KEY` | `python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` – **sichern! ohne ihn sind die Secrets in der DB unlesbar** | immer |
| `AUTH_ENABLED` | `true` | immer |
| `OIDC_ISSUER` | `https://login.onyva.dev` | immer |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | aus Abschnitt 4 (Punkt 2) | immer |
| `OIDC_REDIRECT_URI` | `https://<ADMIN_HOST>/auth/callback` | immer |
| `OIDC_LOGOUT_REDIRECT_URI` | `https://<ADMIN_HOST>/` | immer |
| `APP_COOKIE_SECURE` | `true` | immer |
| `ADMIN_ALLOWED_EMAILS` | `<DEINE_MAIL>` (weitere kommagetrennt) | immer – ohne Regel wird jeder Login abgelehnt |
| `MITTWALD_API_TOKEN` | derselbe Token wie in Gitea | immer |
| `ONYVA_LOGIN_API_KEY` | aus Abschnitt 4 (Punkt 1) | immer |
| `DEPLOY_ALLOWED_TENANTS` | `demo-admin` | immer |
| `DEPLOY_LIVE` | **`false`** | Phase 1 (Dry-Run) → in Abschnitt 8 auf `true` |
| `OIDC_SYNC_LIVE` | `false` | optional `true`, um den Login-Abgleich separat vor dem Deploy zu testen |
| `CORE_RELEASE_TOKEN` | leer | erst in Abschnitt 10 |
| `CORTECS_API_KEY` | leer (wird in der UI als Secret gesetzt) | optional |
**Domain**: Service `admin` → Domain `https://<ADMIN_HOST>:8300` (Port 8300 = Container-Port). **Persistente Daten**: Volume `pgdata` (Postgres) – Backup einrichten.
Deploy starten. Der erste Build nutzt das normale `Dockerfile` (mitgelieferter Core-Snapshot, **keine Registry nötig**).
Prüfen:
- [ ] `https://<ADMIN_HOST>/healthz` → `{"ok":true}`
- [ ] `https://<ADMIN_HOST>/` → Weiterleitung zu login.onyva.dev → Login → Dashboard mit **7 Tenants** (Seed).
- [ ] Seitenleiste unten: „Deploy: gesperrt · Dry-Run“, „<DEINE_MAIL>“.
- Probleme: „Kein Zugriff“ → `ADMIN_ALLOWED_EMAILS`; `redirect_uri`-Fehler → Redirect-URI im Login-Client **exakt** wie in `OIDC_REDIRECT_URI`.
---
## 6. Admin-Plattform konfigurieren (Menü „Einstellungen“)
Felder (Spalte „Gitea“ = dieselbe Variable wie bisher), dann oben **„Alles speichern“**:
| Feld in der Admin-Plattform | Wert / Gitea |
|---|---|
| Basis-Domain | `DOMAIN` |
| Mittwald PROJECT_ID / STACK_ID | `PROJECT_ID` / `STACK_ID` |
| Registry | `REGISTRY` |
| Image-Name | `transkript-platform` |
| **Öffentliche URL der Admin-Plattform** | `https://<ADMIN_HOST>` |
| **Core kann Bundle laden** | ✔ aktivieren (betrifft nur Tenants mit Quelle „Admin“, also nur `demo-admin`) |
| Login (OIDC) & Limits | `OIDC_ISSUER`, `AUTH_ENABLED` (true), `APP_COOKIE_SECURE` (true), `MAX_FILE_SIZE_MB` |
| LLM & Transkription | `CORTECS_BASE_URL`, `LLM_MODEL`, `LLM_MAX_TOKENS`, `LLM_TEMPERATURE`, `MISTRAL_*` |
| E-Mail (SMTP) | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_FROM` |
| Kostenerfassung | `COST_API_BASE`, `COST_TRACKING_ENABLED` |
| „Weitere Core-Einstellungen“ (aus dem Schema) | nur ausfüllen, was du in Gitea auch gesetzt hast (`COST_LLM_*`, `COST_STT_PER_MINUTE`, `STT_*` …); leer = Standard des Cores |
| Rollout → Canary-Tenants | `demo-admin` |
| Geteilte Secrets → `CORTECS_API_KEY` | **Wert setzen** (aus `sops -d …`) |
| Geteilte Secrets → `SMTP_PASSWORD` | **Wert setzen** (nur nötig, wenn der Test E-Mail-Versand prüfen soll) |
Dann: Karte „Login-API“ → **„Verbindung testen“** → `✔ Verbindung ok – N App(s) im Login`.
---
## 7. Test-Tenant `demo-admin` anlegen und konfigurieren
**7.1 Anlegen**: Dashboard → **+ Neuer Tenant** → Name `Onyva Demo (Admin-Test)`, ID `demo-admin`, Ausgangspunkt „Kopie von Onyva Demo“ → Anlegen.
(Quelle ist automatisch „Admin“, OIDC-Verwaltung „managed“.)
**7.2 Spracherkennung**: Tenant → *Funktion & STT* → Provider `cortecs`, Modell `voxtral-mini-transcribe-2602`
(dann reicht der geteilte Cortecs-Key; `demo` nutzt sonst Mistral und bräuchte einen eigenen `MISTRAL_API_KEY`).
**7.3 Optisch unterscheidbar machen**: *Marke & Farben* → Akzent z. B. `#E8590C` → „Töne aus Akzent ableiten“. Oben rechts **Prüfen & veröffentlichen** → Nachricht „Test-Tenant“ → **v2**.
**7.4 Secrets**: Tenant → *Secrets* → `COST_INGEST_KEY` **Wert setzen** (der Wert von `demo`; Kosten laufen dann unter `demo`, für den Test okay).
`SESSION_SECRET` und `ADMIN_BUNDLE_TOKEN` sind schon erzeugt. `OIDC_CLIENT_ID/SECRET` legt der Deploy automatisch an.
**7.5 Login**: Tenant → *Domain & Login*: Subdomain `demo-admin`; OIDC-Verwaltung `managed`, Zugriff `required`; **Freigaben**: `<DEINE_MAIL>` als `editor` (Nutzer muss im Login existieren) → *Speichern*.
**„Gegen Login prüfen“** (nur lesend) → erwartet: „würde App ‚Onyscribe – Onyva Demo (Admin-Test)‘ anlegen (Redirect: https://demo-admin.<DOMAIN>/auth/callback …)“ und „würde <DEINE_MAIL> als editor freigeben“.
**7.6 Core pinnen**: Tenant → *Übersicht* → Karte „Core-Version“ → Pin = **voller SHA aus Abschnitt 2** → *Pin setzen*.
(Der Pin sorgt dafür, dass genau das Branch-Image läuft und der Health-Check die Core-Version prüfen kann.)
**7.7 Vorflug-Check + Dry-Run**: Tenant → *Deploy* → **Vorflug-Check → Prüfen**.
Erwartet in Phase 1: alles grün/gelb, **gelb** nur „DEPLOY_LIVE nicht aktiv“; keine roten Punkte. Typische rote Punkte und Fix stehen direkt dabei
(z. B. Registry/PROJECT_ID/Token fehlen, `CORTECS_API_KEY`/`COST_INGEST_KEY` fehlen, „Core kann Bundle laden“ aus, Admin-URL nicht erreichbar).
Dann **Dry-Run** → Log lesen: „geschützte bestehende Services (bleiben unangetastet): [app-demo, app-rotary, …]“, `[DRY-RUN] PATCH …` für Volume und Service, kein Schreibzugriff.
---
## 8. Live-Deploy des Test-Tenants
1. Coolify → Admin-Ressource → `DEPLOY_LIVE=true` setzen (`DEPLOY_ALLOWED_TENANTS=demo-admin` bleibt) → neu deployen (Daten bleiben).
2. Admin → `demo-admin` → *Deploy* → Vorflug-Check → muss **„Bereit“** zeigen → **Deployen**.
Was passiert (Log): OIDC-Client im Login anlegen und Secret speichern → Pflicht-Secrets prüfen → Volume + Service per PATCH → Service neu erzeugen → Ingress
`demo-admin.<DOMAIN>` → Health-Check (bis ~90 s Wartezeit für Start + Zertifikat).
Abnahme-Checkliste:
- [ ] Deploy-Log endet mit `Health-Check … ✔ · Core 13055b811c · Bundle v2` und `✔ Fertig`
- [ ] mStudio: Service `app-demo-admin` läuft; **alle anderen Services unverändert** (Image-Tags wie vorher)
- [ ] `https://demo-admin.<DOMAIN>/health` → `core.version` = dein SHA, `bundle` = `{"source":"admin","version":2,"stale":false}`
- [ ] Seite öffnen → Login → freigegebener Nutzer kommt rein → Oberfläche in der **neuen Farbe**
- [ ] Protokoll erzeugen (kleines Transkript hochladen) → Zusammenfassung → PDF/Word → Aussehen = Vorschau in der Admin-Plattform
- [ ] Admin → Dashboard → **Zustand prüfen**: `demo-admin` grün. Die Altbetrieb-Tenants zeigen „Core ohne Vertrag (alte Version)“ – **erwartet und harmlos** (sie laufen noch auf dem alten Image)
- [ ] Altbetrieb: `demo.<DOMAIN>` und die anderen Kunden funktionieren unverändert
---
## 9. Änderung ohne Core-Änderung testen
1. Admin → `demo-admin` → *Marke & Farben*: Akzent ändern → **Prüfen & veröffentlichen** (v3) → *Deploy* → **Deployen** (nur Neustart, kein Image-Build).
2. Erwartung: Health-Check `Bundle v3 ✔`; Seite neu laden → neue Farbe.
3. Agent: *KI-Agent* → „Setze die Akzentfarbe auf #0B6E4F“ → Live-Vorschau beobachten → „In Entwurf übernehmen“ → veröffentlichen → deployen.
4. Ausfallsicherheit (nur `demo-admin`!): Coolify → Admin-Ressource **stoppen**; in mStudio den Container `app-demo-admin` **neu starten** → er startet mit gecachtem Bundle:
`https://demo-admin.<DOMAIN>/health` → `bundle.source = "cache"`, `stale = true`. Admin wieder starten → nächster Container-Neustart holt wieder `admin`.
---
## 10. (Optional) Den kompletten Core-Release-Weg testen
1. Coolify: `CORE_RELEASE_TOKEN` = langes Zufallstoken, Admin neu deployen. Gitea → Repo → Einstellungen → Actions: **Variable** `ADMIN_URL` = `https://<ADMIN_HOST>`, **Secret** `ADMIN_RELEASE_TOKEN` = dasselbe Token.
(Wirkt nur auf Workflows des Test-Branches; `main` kennt den `notify-admin`-Job nicht.)
2. Im Core-Repo eine Kleinigkeit committen und auf den Branch pushen → `build-branch-image` baut und meldet das Release.
3. Admin → Menü **Core**: neues Release, Badge **„Admin älter/anders“** (die Admin-Plattform enthält noch den alten Core-Snapshot).
4. Angleichen: `cd ~/PycharmProjects/onyscribe-admin && scripts/sync_core.sh && git add -A && git commit -m "Core-Snapshot aktualisiert" && git push` → Coolify deployt → Badge **„gleicher Core“**.
*(Später ersetzt `Dockerfile.fromcore` diesen Schritt: Admin wird direkt auf dem Core-Image gebaut.)*
5. **Prüfen** → Matrix mit allen 7 Tenants (beim ersten Mal „neu (keine Baseline)“) → **Freigeben** → **Dry-Run** → *Tenant `demo-admin`: Pin aufheben* → **Ausrollen** → nur `demo-admin` wird live ausgerollt (Allowlist), Health-Check prüft die neue Core-SHA.
6. Rollback testen: **Zurückrollen** (stellt den vorherigen Tag wieder her).
---
## 11. Abbruch / Rückbau (jederzeit)
| Was | Wie |
|---|---|
| Test-Tenant live entfernen | Admin → `demo-admin` → Übersicht → Gefahrenbereich → **Offboard-Dry-Run** prüfen → **Offboard (live)** (entfernt Service, Volume, Ingress) |
| OIDC-Client des Test-Tenants | Gefahrenbereich → **OIDC-Client im Login löschen** |
| Admin-Plattform | Coolify-Ressource stoppen/löschen; Login-Client „Onyscribe Admin“ und API-Key im Login löschen |
| Branch/Image | Gitea: Branch `admin-platform-integration` löschen, Package-Tag (SHA) löschen |
| Alles Live sperren | `DEPLOY_LIVE=false` (und/oder `DEPLOY_ALLOWED_TENANTS` leeren) → Admin neu deployen |
Der Altbetrieb braucht in keinem dieser Fälle eine Änderung.
---
## 12. Fehlersuche
| Symptom | Ursache / Fix |
|---|---|
| Vorflug: „Mittwald-Stack lesbar“ rot | Token-Rechte, `PROJECT_ID`/`STACK_ID` in Einstellungen prüfen (dieselben Werte wie Gitea) |
| Deploy: „Pflicht-Secrets ohne Wert: …“ | `CORTECS_API_KEY` (Einstellungen → Geteilte Secrets), `COST_INGEST_KEY` (Tenant → Secrets), ggf. `MISTRAL_API_KEY` (nur bei STT-Provider mistral) |
| Deploy: „… nicht für Live-Schreibzugriffe freigegeben“ | `DEPLOY_ALLOWED_TENANTS` enthält die Tenant-ID nicht |
| Deploy: „Quelle ‚Admin‘ ist noch gesperrt“ | Einstellungen → „Core kann Bundle laden“ aktivieren |
| Deploy: „OIDC-Abgleich fehlgeschlagen: API-Key ungültig (401)“ | `ONYVA_LOGIN_API_KEY` falsch/abgelaufen; Key im Login neu erzeugen |
| Deploy: „… Freigaben: <mail> existiert im Login nicht“ (Warnung) | Nutzer im Login anlegen, dann *Domain & Login → Jetzt abgleichen* |
| Health-Check: „Core X statt Y“ | Pin/Tag stimmt nicht mit dem Image überein oder Image fehlt in der Registry (mStudio-Container-Log) |
| Health-Check: „Bundle cache v… (veraltet)“ | Container erreicht die Admin-URL nicht: Domain/HTTPS/Firewall, `admin_public_url`, Bundle-Token |
| Container-Log: „Tenant-Bundle … nicht verfügbar“ | wie oben; zusätzlich `ADMIN_URL`/`ADMIN_BUNDLE_TOKEN` im Plan (Deploy → Service-Definition) prüfen |
| Login der Tenant-Seite: `redirect_uri`-Fehler | *Domain & Login → Gegen Login prüfen → Jetzt abgleichen* (ergänzt die Redirect-URI) |
| Login: `invalid_client` | `OIDC_CLIENT_SECRET` fehlt/ist veraltet → *Jetzt abgleichen* bzw. neu deployen |
| Zertifikat/Ingress fehlt | Token braucht Domain/Ingress-Recht (Log: „Ingress … NICHT angelegt“); Service läuft trotzdem, nach Rechteänderung erneut deployen |
| Admin „Zustand prüfen“: „nicht erreichbar“ | Tenant noch nicht deployt bzw. DNS/Zertifikat noch nicht fertig |
---
## 13. Danach (nicht Teil dieses Tests)
Erst wenn der Test-Tenant mehrere Tage sauber läuft: Core-Branch reviewen und mergen (→ `build-deploy` baut dann den neuen Core für alle),
Secrets der echten Tenants importieren (`python scripts/import_tenants.py --with-secrets`), pro Tenant die **Cut-over-Checkliste** abarbeiten und
`DEPLOY_ALLOWED_TENANTS` schrittweise erweitern. Die echten Tenants stellst du **einzeln** um – Rückweg: Quelle zurück auf „Git“.

8
requirements-extra.txt Normal file
View File

@@ -0,0 +1,8 @@
# Nur die Pakete, die der Core NICHT schon mitbringt (Dockerfile.fromcore baut auf dem Core-Image auf)
sqlalchemy>=2.0
psycopg[binary]>=3.2
pyyaml>=6.0
cryptography>=43
httpx>=0.27
anthropic>=0.40
pypdf>=4.0

18
requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
# Admin-Plattform
fastapi==0.135.1
uvicorn[standard]==0.41.0
jinja2>=3.1
sqlalchemy>=2.0
psycopg[binary]>=3.2
python-multipart>=0.0.22
pyyaml>=6.0
cryptography>=43
httpx>=0.27
anthropic>=0.40
pypdf>=4.0
# Abhängigkeiten des vendored Cores (Live-Vorschau führt den echten Core aus)
openai==2.46.0
python-dotenv>=1.0
requests>=2.31
weasyprint>=60.0
python-docx>=1.1.0

14
scripts/export_seed.py Normal file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Schreibt den aktuellen DB-Stand (Tenants, Versionen, Dateien; ohne Secret-Werte) nach seed/tenants.seed.json.gz."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import session # noqa: E402
from app.services import seed # noqa: E402
out = Path(__file__).resolve().parent.parent / "seed" / "tenants.seed.json.gz"
with session() as db:
seed.export(db, out)
print(f"geschrieben: {out} ({out.stat().st_size/1024:.0f} KB)")

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Lokaler Fake von login.onyva.dev /api/v1 – zum Ausprobieren des OIDC-Abgleichs ohne echtes Login-System.
python scripts/fake_login_server.py # http://127.0.0.1:8399/api/v1, Key: onyva_testkey, Nutzer: anna@x.de, bob@x.de
ONYVA_LOGIN_API_KEY=onyva_testkey OIDC_SYNC_LIVE=true uvicorn app.main:app --port 8300
→ Einstellungen → „API-Basis“ = http://127.0.0.1:8399/api/v1
"""
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tests.fakelogin import FakeLogin # noqa: E402
fake = FakeLogin()
class H(BaseHTTPRequestHandler):
def _do(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n)) if n else None
status, data, hdrs = fake(self.command, f"http://x{self.path}", {"Authorization": self.headers.get("Authorization", "")}, body)
self.send_response(status)
for k, v in hdrs.items():
self.send_header(k, v)
self.send_header("Content-Type", "application/json")
out = json.dumps(data).encode() if data is not None else b""
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
print(f"{self.command} {self.path} → {status}", flush=True)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _do
def log_message(self, *a):
pass
if __name__ == "__main__":
print("Fake-Login auf http://127.0.0.1:8399/api/v1 (Key onyva_testkey)", flush=True)
HTTPServer(("127.0.0.1", 8399), H).serve_forever()

27
scripts/import_tenants.py Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Importiert die Tenants aus ../transkript-platform in die Admin-DB.
python scripts/import_tenants.py # nur Schlüsselnamen der Secrets
python scripts/import_tenants.py --with-secrets # Secrets via `sops -d` entschlüsseln und verschlüsselt speichern
python scripts/import_tenants.py --force # neue Version auch bei identischem Inhalt
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import init_db, session # noqa: E402
from app.services.importer import import_all # noqa: E402
from app.settings import LEGACY_PLATFORM # noqa: E402
ap = argparse.ArgumentParser()
ap.add_argument("--root", default=str(LEGACY_PLATFORM))
ap.add_argument("--with-secrets", action="store_true")
ap.add_argument("--force", action="store_true")
a = ap.parse_args()
init_db()
with session() as db:
for line in import_all(db, Path(a.root), a.with_secrets, a.force):
print(line)

13
scripts/sync_core.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Aktualisiert den vendored Core-Snapshot (nur für lokale Entwicklung ohne Core-Image) aus der transkript-platform.
# Standard: aktueller Stand des Quell-Repos. Im Container/Coolify wird stattdessen FROM <core-image> gebaut (Dockerfile.fromcore).
set -euo pipefail
SRC="${1:-$(cd "$(dirname "$0")/../.." && pwd)/transkript-platform}"
DST="$(cd "$(dirname "$0")/.." && pwd)/vendor/platform"
rm -rf "$DST/core" && mkdir -p "$DST"
cp -r "$SRC/core" "$DST/core" && cp "$SRC/main.py" "$SRC/requirements.txt" "$DST/"
find "$DST" -name __pycache__ -prune -exec rm -rf {} +
SHA="$(git -C "$SRC" rev-parse HEAD)"; BRANCH="$(git -C "$SRC" rev-parse --abbrev-ref HEAD)"
echo "$SHA $BRANCH" > "$DST/CORE_VERSION"
printf '{"sha": "%s", "built_at": "%s"}\n' "$SHA" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DST/core/BUILD_INFO.json"
echo "Core synchronisiert: ${SHA:0:10} ($BRANCH)"

39
scripts/verify_import.py Normal file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Beweis für den Cut-over: Bundle aus der DB == Git-Ordner (Hash-Vergleich, ohne deploy-Block)."""
import hashlib
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import session # noqa: E402
from app.models import Tenant # noqa: E402
from app.services import store # noqa: E402
from app.settings import LEGACY_PLATFORM # noqa: E402
from sqlalchemy import select # noqa: E402
root = Path(sys.argv[1]) if len(sys.argv) > 1 else LEGACY_PLATFORM
bad = 0
with session() as db:
for t in db.scalars(select(Tenant).order_by(Tenant.id)):
tdir = root / "tenants" / t.id
cfg, files = store.content(db, t, "current")
git_cfg = json.loads((tdir / "tenant.json").read_text(encoding="utf-8"))
git_cfg.pop("deploy", None)
problems = []
if json.dumps(git_cfg, sort_keys=True) != json.dumps(cfg, sort_keys=True):
problems.append("tenant.json weicht ab")
for path, sha in files.items():
p = tdir / path
if not p.exists():
problems.append(f"{path}: fehlt in Git")
elif hashlib.sha256(p.read_bytes()).hexdigest() != sha:
problems.append(f"{path}: Hash weicht ab")
for p in tdir.rglob("*"):
rel = p.relative_to(tdir).as_posix()
if p.is_file() and rel not in files and rel not in ("tenant.json", "secrets.enc.yaml") and "__pycache__" not in rel:
problems.append(f"{rel}: fehlt in DB")
print(("✔" if not problems else "✘"), t.id, "; ".join(problems))
bad += bool(problems)
sys.exit(1 if bad else 0)

BIN
seed/tenants.seed.json.gz Normal file

Binary file not shown.

0
tests/__init__.py Normal file
View File

22
tests/conftest.py Normal file
View File

@@ -0,0 +1,22 @@
"""Test-Isolation: Tests dürfen NIE echte Keys/Dienste aus der lokalen .env oder der Shell erreichen."""
import os
import tempfile
os.environ["ONYSCRIBE_SKIP_DOTENV"] = "1" # .env nicht einlesen
for var in ("CORTECS_API_KEY", "ONYVA_LOGIN_API_KEY", "MITTWALD_API_TOKEN", "MITTWALD_API_BASE", "DEPLOY_LIVE", "OIDC_SYNC_LIVE",
"AUTH_ENABLED", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_LOGOUT_REDIRECT_URI", "OIDC_ISSUER",
"ADMIN_ALLOWED_EMAILS", "DEPLOY_ALLOWED_TENANTS", "CORE_RELEASE_TOKEN", "ADMIN_REQUIRED_ROLE", "SECRET_KEY", "APP_COOKIE_SECURE"):
os.environ.pop(var, None)
os.environ["DATABASE_URL"] = f"sqlite:///{tempfile.mkdtemp()}/test.db"
import pytest # noqa: E402
@pytest.fixture(scope="module", autouse=True)
def fresh_db():
"""Jedes Testmodul startet mit leerer Datenbank (die Module teilen sich sonst Zustand über die Temp-DB)."""
from app.db import Base, engine, init_db
from app import models # noqa: F401
Base.metadata.drop_all(engine)
init_db()
yield

59
tests/fakelogin.py Normal file
View File

@@ -0,0 +1,59 @@
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md) für Tests und lokale Entwicklung."""
class FakeLogin:
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md)."""
def __init__(self, users=("anna@x.de", "bob@x.de")):
self.apps, self.members, self.users, self.calls, self.n, self.rate_limit_once = {}, {}, set(users), [], 0, False
def __call__(self, method, url, headers, body):
path = url.split("/api/v1")[1]
self.calls.append((method, path, body))
if headers.get("Authorization") != "Bearer onyva_testkey":
return 401, {"detail": "invalid"}, {}
if self.rate_limit_once:
self.rate_limit_once = False
return 429, {"detail": "slow down"}, {"Retry-After": "2"}
parts = path.strip("/").split("/")
if parts == ["apps"]:
if method == "GET":
return 200, {"apps": list(self.apps.values())}, {}
self.n += 1
cid = f"app_{self.n:04d}"
app = {"client_id": cid, "name": body["name"], "slug": body["name"].lower(), "redirect_uris": body["redirect_uris"],
"auth_mode": body.get("auth_mode", "required"), "totp_mode": body.get("totp_mode", "disabled"),
"allow_registration": body.get("allow_registration", False), "backchannel_logout_uri": body.get("backchannel_logout_uri")}
self.apps[cid], self.members[cid] = app, {}
return 201, {**app, "client_secret": f"secret-{self.n}"}, {}
cid = parts[1]
if cid not in self.apps:
return 404, {"detail": "not found"}, {}
if len(parts) == 2:
if method == "GET":
return 200, self.apps[cid], {}
if method == "PATCH":
self.apps[cid].update(body)
return 200, self.apps[cid], {}
if method == "DELETE":
del self.apps[cid]
return 204, None, {}
if parts[2] == "rotate-secret":
self.n += 1
return 200, {"client_id": cid, "client_secret": f"rotated-{self.n}"}, {}
if parts[2] == "memberships":
if method == "GET":
return 200, {"memberships": [{"email": e, "display_name": e, "role": r} for e, r in self.members[cid].items()]}, {}
if method == "PUT":
if body["user_email"] not in self.users:
return 404, {"detail": "user"}, {}
self.members[cid][body["user_email"]] = body.get("role", "viewer")
return 200, body, {}
if method == "DELETE":
self.members[cid].pop(parts[3], None)
return 204, None, {}
return 404, {}, {}
@property
def writes(self):
return [c for c in self.calls if c[0] != "GET"]

443
tests/test_core_contract.py Normal file
View File

@@ -0,0 +1,443 @@
"""Zusammenspiel Admin ↔ Core: Vertrag/Schema, schema-getriebene Env, Release-Eingang, Kompatibilitätsprüfung, Rollout, Rollback, Zustand."""
import json
import os
import re
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.db import session
from app.main import app
from app.models import CoreRelease, Tenant
from app.services import contract, deploy, releases, status as status_svc
from app.services.importer import import_all
from app.services.mittwald import DeployError
from app.settings import LEGACY_PLATFORM, ROOT
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
with session() as db:
import_all(db, Path(LEGACY_PLATFORM))
yield c
# ── Vertrag / Schema ────────────────────────────────────────────────────────
def test_contract_is_loaded_and_supported():
assert contract.available() and contract.supported() and contract.contract_version() == 1
assert len(contract.schema()["fields"]) >= 30 and contract.build_info()["sha"] not in ("", "unbekannt")
def test_all_imported_tenants_conform_to_schema(client):
for tid in ("demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"):
cfg = client.get(f"/api/tenants/{tid}/state").json()["config"]
assert contract.check_tenant_json(cfg) == [], tid
def test_admin_knows_every_color_the_core_declares():
from app.services.defaults import COLOR_LABELS, DEFAULT_COLORS
core_colors = {p.split(".", 1)[1] for p in contract.field_paths() if p.startswith("colors.")}
assert core_colors == set(COLOR_LABELS) == set(DEFAULT_COLORS), "Farben im Core-Schema weichen von den Admin-Defaults ab"
def test_bespoke_form_paths_exist_in_schema():
js = (ROOT / "app" / "static" / "tenant.js").read_text()
bespoke = re.search(r"BESPOKE_PATHS = \[(.*?)\]", js, re.S).group(1)
for p in re.findall(r"'([a-z_.]+)'", bespoke):
assert p in contract.field_paths(), f"{p} wird von einer festen Admin-Maske genutzt, fehlt aber im Core-Schema"
def test_schema_warnings_flow_into_validate(client):
cfg = client.get("/api/tenants/demo/state").json()["config"]
client.put("/api/tenants/demo/config", json={**cfg, "flags": {"ui_kontakt_feld": False}}) # Tippfehler
v = client.post("/api/tenants/demo/validate").json()
client.post("/api/tenants/demo/draft/discard")
assert any("flags.ui_kontakt_feld" in w for w in v["warnings"])
def test_contract_endpoint_and_settings_get(client):
c = client.get("/api/contract").json()
assert c["available"] and c["supported"] and c["schema"]["env"]
assert client.get("/api/settings").json()["base_domain"] == "onyscribe.de"
assert client.get("/core").status_code == 200
# ── schema-getriebene Container-Env ─────────────────────────────────────────
def test_env_is_built_from_core_schema_and_picks_up_new_variables(client, monkeypatch):
client.put("/api/settings", json={"llm_model": "claude-x", "smtp_host": "smtp.test"})
with session() as db:
env = deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)
assert env["LLM_MODEL"] == "claude-x" and env["SMTP_HOST"] == "smtp.test" and env["TENANT"] == "sprinz" and "STT_PROVIDER" not in env
# Der Core deklariert eine neue geteilte Variable → sie landet ohne Admin-Änderung im Env
spec = contract.env_spec() + [{"name": "NEW_FEATURE_LEVEL", "kind": "setting", "scope": "shared", "setting": "new_feature_level", "group": "X", "label": "X"}]
monkeypatch.setattr(contract, "env_spec", lambda: spec)
client.put("/api/settings", json={"new_feature_level": "3"})
with session() as db:
assert deploy.build_env(db, db.get(Tenant, "sprinz"), reveal=False)["NEW_FEATURE_LEVEL"] == "3"
def test_required_secrets_follow_schema_conditions():
assert "MISTRAL_API_KEY" not in contract.required_secrets({"stt": {"provider": "cortecs"}})
assert "MISTRAL_API_KEY" in contract.required_secrets({"stt": {"provider": "mistral"}})
assert "MISTRAL_API_KEY" in contract.required_secrets({})
assert {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET", "COST_INGEST_KEY", "CORTECS_API_KEY"} <= set(contract.required_secrets({}))
# ── Health-Verifikation & Zustandsabgleich ──────────────────────────────────
def health_body(sha="abcdef1234567", bundle=None, status="ok"):
return {"status": status, "tenant": "t", "core": {"version": sha, "contract": 1, "hook_api": 1}, "bundle": bundle or {"source": "git", "version": None, "stale": False, "error": None}}
def test_verify_health_accepts_correct_and_rejects_wrong_versions():
logs = []
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body()), tries=1)
assert any("✔" in l for l in logs)
with pytest.raises(DeployError, match="Core 1234567890 statt abcdef1"):
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, health_body("1234567890abc")), tries=2, delay=0, sleep=lambda s: None)
with pytest.raises(DeployError, match="Bundle cache v2 \\(veraltet\\) statt admin v3"):
deploy.verify_health("h", None, 3, logs.append, get=lambda u: (200, health_body(bundle={"source": "cache", "version": 2, "stale": True})), tries=1)
with pytest.raises(DeployError, match="nicht erreichbar|Connect"):
def boom(u):
raise OSError("Connect refused")
deploy.verify_health("h", None, None, logs.append, get=boom, tries=2, delay=0, sleep=lambda s: None)
deploy.verify_health("h", "abcdef1", None, logs.append, get=lambda u: (200, {"status": "ok"}), tries=1) # Image vor dem Vertrag → nur Hinweis
assert any("Image vor dem Vertrag" in l for l in logs)
def test_status_probe_detects_drift():
ok = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("abcdef12349999", {"source": "admin", "version": 5, "stale": False})))
assert ok["reachable"] and ok["drift"] == []
bad = status_svc.probe("h", "abcdef1234", 5, get=lambda u: (200, health_body("0000000aaaa", {"source": "baked", "version": None, "stale": True})))
assert len(bad["drift"]) == 2
old = status_svc.probe("h", "abcdef1234", None, get=lambda u: (200, {"status": "ok"}))
assert "Core ohne Vertrag" in old["drift"][0]
down = status_svc.probe("h", "x", None, get=lambda u: (_ for _ in ()).throw(OSError("timeout")))
assert not down["reachable"] and down["drift"] == ["nicht erreichbar"]
# ── Release-Eingang ─────────────────────────────────────────────────────────
SHA_ADMIN = None
def _admin_sha():
return contract.sha()
def test_release_intake_requires_token_and_is_idempotent(client, monkeypatch):
body = {"sha": _admin_sha(), "tag": _admin_sha(), "ref": "main", "image": "reg/x", "message": "Neuer Core"}
monkeypatch.delenv("CORE_RELEASE_TOKEN", raising=False)
assert client.post("/api/core/releases", json=body).status_code == 503
monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim")
assert client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer falsch"}).status_code == 401
assert client.post("/api/core/releases", json={**body, "sha": "zzz"}, headers={"Authorization": "Bearer geheim"}).status_code == 400
r = client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()
assert r["created"] and r["admin_matches"] and r["status"] == "new"
assert not client.post("/api/core/releases", json=body, headers={"Authorization": "Bearer geheim"}).json()["created"]
other = client.post("/api/core/releases", json={**body, "sha": "f" * 40, "tag": "f" * 40, "message": "Fremder Core"}, headers={"Authorization": "Bearer geheim"}).json()
assert not other["admin_matches"]
def test_release_list_needs_session_when_auth_enabled(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "AUTH_ENABLED", True)
with TestClient(app) as c:
assert c.get("/api/core/releases").status_code == 401
monkeypatch.setenv("CORE_RELEASE_TOKEN", "geheim")
assert c.post("/api/core/releases", json={"sha": "a" * 40}, headers={"Authorization": "Bearer geheim"}).status_code == 200 # CI braucht keine Session
assert c.get("/api/tenants/demo/state").status_code == 401
monkeypatch.setattr(auth, "AUTH_ENABLED", False)
# ── Kompatibilitätsprüfung & Freigabe ───────────────────────────────────────
def _rid(sha):
with session() as db:
return db.query(CoreRelease).filter_by(sha=sha).one().id
def test_check_requires_matching_admin_core(client):
rid = _rid("f" * 40)
r = client.post(f"/api/core/releases/{rid}/check", json={})
assert r.status_code == 400 and "Admin" in r.json()["detail"] and "neu bauen" in r.json()["detail"]
def test_check_matrix_baseline_changes_and_approval_rules(client):
rid = _rid(_admin_sha())
releases.check(rid, wait=True)
with session() as db:
c = db.get(CoreRelease, rid).compat
assert set(c["tenants"]) >= {"demo", "rotary-wil", "sprinz"} and c["summary"]["error"] == 0
assert all(v["status"] == "new" for v in c["tenants"].values()) # noch keine Baseline
assert c["tenants"]["rotary-wil"]["fp"]["pdf_pages"] >= 1
# Freigabe ohne Änderungen möglich
assert client.post(f"/api/core/releases/{rid}/approve", json={}).status_code == 200
# Baseline setzen (wie nach einem erfolgreichen Rollout) und Core-Änderung simulieren
with session() as db:
for tid, v in c["tenants"].items():
t = db.get(Tenant, tid)
t.render_baseline = {"version": t.current_version, "core": "alt", "fp": {**v["fp"], "pdf_text": "0" * 64}}
releases.check(rid, wait=True)
with session() as db:
c2 = db.get(CoreRelease, rid).compat
assert c2["tenants"]["sprinz"]["status"] == "changed" and "PDF-Text" in c2["tenants"]["sprinz"]["changed"]
r = client.post(f"/api/core/releases/{rid}/approve", json={})
assert r.status_code == 400 and "bestätigen" in r.json()["detail"]
assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200
# unveränderte Baseline → ok
with session() as db:
for tid, v in c2["tenants"].items():
t = db.get(Tenant, tid)
t.render_baseline = {"version": t.current_version, "core": "x", "fp": v["fp"]}
releases.check(rid, wait=True)
with session() as db:
assert db.get(CoreRelease, rid).compat["summary"]["ok"] >= 7
def test_broken_tenant_blocks_approval(client):
rid = _rid(_admin_sha())
client.put("/api/tenants/revier/files/protocol.html", json={"content": "{% for %}"})
client.post("/api/tenants/revier/publish", json={"message": "kaputt"})
releases.check(rid, wait=True)
with session() as db:
c = db.get(CoreRelease, rid).compat
assert c["tenants"]["revier"]["status"] == "error" and c["summary"]["error"] == 1
r = client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True})
assert r.status_code == 400 and "revier" in r.json()["detail"]
client.post("/api/tenants/revier/versions/1/restore") # reparieren
client.post("/api/tenants/revier/publish", json={"message": "repariert"})
releases.check(rid, wait=True)
assert client.post(f"/api/core/releases/{rid}/approve", json={"acknowledge": True}).status_code == 200
# ── Rollout / Rollback ──────────────────────────────────────────────────────
def _ready(client, tid):
"""Tenant mit allen Pflicht-Secrets, Quelle git, OIDC-Verwaltung aus – bereit für einen (gefakten) Live-Deploy."""
from app.services import tenants as tsvc
client.post("/api/tenants", json={"id": tid, "name": f"Tenant {tid}"}) # existiert er schon, bleibt er und wird nur reaktiviert
client.put(f"/api/tenants/{tid}/meta", json={"status": "active"})
client.put(f"/api/tenants/{tid}/core-pin", json={"pin": ""})
client.put(f"/api/tenants/{tid}/source", json={"source": "git"})
client.put(f"/api/tenants/{tid}/oidc", json={"mode": "off"})
with session() as db:
for k in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
tsvc.set_secret(db, tid, k, "x", "t")
class FakeMW:
def __init__(self):
self.calls, self.images = [], {}
def __call__(self, method, url, headers, body):
path = url.split("/v2")[1]
self.calls.append((method, path))
if method == "GET" and path.endswith("/stacks/s-1/"):
return 200, {"services": {"legacy": {"id": "svc-0"}, **{n: {"id": f"id-{n}"} for n in self.images}}}, ""
if method == "GET" and "ingresses" in path:
return 200, [], ""
if method == "GET" and "/projects/p-1/" in path:
return 200, {"id": "uuid"}, ""
if method == "PATCH":
for name, svc in ((body or {}).get("services") or {}).items():
if svc:
self.images[name] = svc["image"]
return 200, {}, ""
@pytest.fixture()
def live_env(client, monkeypatch):
monkeypatch.setenv("DEPLOY_LIVE", "true")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
monkeypatch.setenv("MITTWALD_API_TOKEN", "tok")
client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o", "core_image_tag": "alt1234", "canary_tenants": "ro2"})
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
with session() as db: # alle bestehenden Tenants aus dem Rollout nehmen (andere Tests laufen weiter gegen sie)
paused = [t.id for t in db.query(Tenant).filter(Tenant.status == "active")]
for t in db.query(Tenant):
t.status = "paused"
yield
with session() as db:
for t in db.query(Tenant):
t.status = "active" if t.id in paused else t.status
for t in db.query(Tenant).filter(Tenant.id.like("ro%")):
t.status = "archived"
def _approved_release(client, sha="c" * 40):
with session() as db:
r, _ = releases.announce(db, sha, sha, "main", "reg.test/o", "Rollout-Test")
r.status = "approved"
r.compat = {"tenants": {}, "summary": {}, "core_sha": sha, "running": False}
rid = r.id
return rid
def test_dry_run_rollout_changes_nothing(client, live_env, monkeypatch):
monkeypatch.delenv("DEPLOY_LIVE")
_ready(client, "ro1")
rid = _approved_release(client, "d" * 40)
mw = FakeMW()
batch, ids = releases.rollout(rid, "t", dry_run=True, http=mw, wait=True)
assert not [c for c in mw.calls if c[0] != "GET"] # Dry-Run liest höchstens
rows = client.get(f"/api/deployments?batch={batch}").json()
assert rows and all(r["status"] == "success" and r["kind"] == "dry-run" and r["image"] == "d" * 40 for r in rows)
with session() as db:
assert db.get(CoreRelease, rid).status == "approved"
assert deploy.setting(db, "core_image_tag") == "alt1234"
with pytest.raises(releases.ReleaseError, match="gesperrt"):
releases.rollout(rid, "t", dry_run=False)
def test_live_rollout_canary_first_pin_baseline_and_rollback(client, live_env):
for t in ("ro1", "ro2", "ro3"):
_ready(client, t)
client.put("/api/tenants/ro3/core-pin", json={"pin": "fest99"})
rid = _approved_release(client, "e" * 40)
with session() as db:
db.get(CoreRelease, rid).compat = {"tenants": {"ro1": {"fp": {"x": 1}}, "ro2": {"fp": {"x": 2}}}, "summary": {}, "core_sha": "e" * 40, "running": False}
mw, seen = FakeMW(), []
health = lambda host, tag, bundle, log: seen.append((host, tag)) # noqa: E731
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True)
rows = client.get(f"/api/deployments?batch={batch}").json()
assert [r["tenant"] for r in rows][0] == "ro2" and {r["tenant"] for r in rows} == {"ro1", "ro2"} # Canary zuerst, ro3 gepinnt → nicht dabei
assert all(r["status"] == "success" and not r["simulated"] for r in rows)
assert mw.images == {"app-ro1": f"reg.test/o/transkript-platform:{'e' * 40}", "app-ro2": f"reg.test/o/transkript-platform:{'e' * 40}"}
assert [t for _, t in seen] == ["e" * 40, "e" * 40]
with session() as db:
r = db.get(CoreRelease, rid)
assert r.status == "active" and r.previous_tag == "alt1234" and deploy.setting(db, "core_image_tag") == "e" * 40
assert db.get(Tenant, "ro1").render_baseline["fp"] == {"x": 1} and db.get(Tenant, "ro1").deployed_image == "e" * 40
assert deploy.image_ref(db, db.get(Tenant, "ro3")).endswith(":fest99") # Pin gewinnt
# Rollback aller Tenants auf den vorherigen Tag
mw2 = FakeMW()
releases.rollback_release(rid, "t", http=mw2, health=health, wait=True)
assert set(mw2.images.values()) == {"reg.test/o/transkript-platform:alt1234"}
with session() as db:
assert db.get(CoreRelease, rid).status == "rolled_back" and deploy.setting(db, "core_image_tag") == "alt1234"
def test_failed_canary_aborts_rollout_and_marks_release_failed(client, live_env):
for t in ("ro1", "ro2"):
_ready(client, t)
rid = _approved_release(client, "9" * 40)
mw = FakeMW()
def health(host, tag, bundle, log):
if host.startswith("ro2."):
raise DeployError("Health-Check fehlgeschlagen: Core falsch")
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=health, wait=True)
rows = {r["tenant"]: r for r in client.get(f"/api/deployments?batch={batch}").json()}
assert rows["ro2"]["status"] == "failed" and rows["ro1"]["status"] == "cancelled" and "übersprungen" in rows["ro1"]["log"]
assert "app-ro1" not in mw.images # nichts ausgerollt
with session() as db:
assert db.get(CoreRelease, rid).status == "failed" and deploy.setting(db, "core_image_tag") == "alt1234"
def test_tenant_rollback_uses_previous_successful_live_tag(client, live_env, monkeypatch):
from app.services.mittwald import Mittwald
_ready(client, "ro4")
mw, health = FakeMW(), (lambda *a: None)
monkeypatch.setattr(deploy, "_client", lambda live, log, http=None: Mittwald("tok", "https://api.test/v2", live, log, mw))
monkeypatch.setattr(deploy, "verify_health", lambda *a, **k: None)
deploy.start("ro4", "deploy", "t", http=mw, core_tag="1111111", health=health, wait=True)
deploy.start("ro4", "deploy", "t", http=mw, core_tag="2222222", health=health, wait=True)
with session() as db:
assert db.get(Tenant, "ro4").deployed_image == "2222222" and releases.tenant_rollback_tag(db, db.get(Tenant, "ro4")) == "1111111"
r = client.post("/api/tenants/ro4/rollback").json()
assert r["tag"] == "1111111"
time.sleep(1.5)
assert mw.images["app-ro4"].endswith(":1111111")
def test_rollout_blocked_for_unapproved_and_unsupported(client, live_env):
with session() as db:
r, _ = releases.announce(db, "8" * 40, "8" * 40, "main", "", "neu")
rid = r.id
with pytest.raises(releases.ReleaseError, match="erst prüfen und freigeben"):
releases.rollout(rid, "t", dry_run=False)
with pytest.raises(releases.ReleaseError):
releases.approve(session().__enter__(), rid, "t")
# ── Schreib-Freigabe pro Tenant (DEPLOY_ALLOWED_TENANTS) ───────────────────
def test_allowlist_parsing(monkeypatch):
from app.services import guard
monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS", raising=False)
assert guard.allowed_tenants() == set() and not guard.live_allowed("demo") and guard.describe() == "keiner"
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", " demo-admin , x ")
assert guard.live_allowed("demo-admin") and guard.live_allowed("x") and not guard.live_allowed("demo")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
assert guard.allowed_tenants() is None and guard.live_allowed("irgendwer")
def test_live_deploy_oidc_and_rollout_respect_allowlist(client, live_env, monkeypatch):
for t in ("ro1", "ro2"):
_ready(client, t)
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "ro2")
# API: nicht freigegebener Tenant → 403, freigegebener → ok
assert client.post("/api/tenants/ro1/deploy", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/ro1/offboard", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/ro1/rollback").status_code == 403
assert client.post("/api/tenants/ro1/oidc/sync", json={"apply": True}).status_code == 403
dry = client.post("/api/tenants/ro1/deploy", json={"dry_run": True}) # Dry-Run immer erlaubt
assert dry.status_code == 200
for _ in range(40):
if client.get(f"/api/deployments/{dry.json()['id']}").json()["status"] != "running":
break
time.sleep(0.25)
assert client.get("/api/deploy/mode").json()["allowed"] == "ro2"
# Engine blockiert auch direkt
d = client.get(f"/api/deployments/{deploy.start('ro1', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
assert d["status"] == "failed" and "nicht freigegeben" in d["log"]
# Rollout: nur ro2 wird live ausgerollt, ro1 bleibt unberührt
rid = _approved_release(client, "7" * 40)
mw = FakeMW()
batch, _ = releases.rollout(rid, "t", dry_run=False, http=mw, health=lambda *a: None, wait=True)
assert [r["tenant"] for r in client.get(f"/api/deployments?batch={batch}").json()] == ["ro2"] and list(mw.images) == ["app-ro2"]
# niemand freigegeben → Rollout wird abgelehnt
monkeypatch.delenv("DEPLOY_ALLOWED_TENANTS")
with pytest.raises(releases.ReleaseError, match="Kein Tenant für Live-Rollouts freigegeben"):
releases.rollout(rid, "t", dry_run=False)
# ── Vorflug-Check ───────────────────────────────────────────────────────────
def test_preflight_reports_problems_and_readiness(client, live_env, monkeypatch):
from tests.test_flow import FakeLogin # noqa: F401
_ready(client, "pf1")
client.put("/api/settings", json={"admin_public_url": "", "core_supports_bundle": "false", "base_domain": "onyscribe.de"})
client.put("/api/tenants/pf1/source", json={"source": "admin"})
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "")
mw = FakeMW()
from app.services import preflight
r = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200)
lv = {i["id"]: i["level"] for i in r["items"]}
assert not r["ready"] and lv["bundle"] == "fail" and lv["adminurl"] == "fail" and lv["allow"] == "warn" and lv["stack"] == "ok" and lv["tag"] == "warn"
assert "legacy" in next(i for i in r["items"] if i["id"] == "stack")["detail"] # bestehende Services werden als „bleiben unangetastet“ gezeigt
assert [c for c in mw.calls if c[0] != "GET"] == [] # schreibt nie
# alles in Ordnung bringen
client.put("/api/settings", json={"admin_public_url": "https://admin.example.de", "core_supports_bundle": "true"})
client.put("/api/tenants/pf1/core-pin", json={"pin": "a" * 40})
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "pf1")
r2 = preflight.run("pf1", mw_http=mw, self_get=lambda u: 200)
assert r2["ready"], [i for i in r2["items"] if i["level"] == "fail"]
bad = preflight.run("pf1", mw_http=mw, self_get=lambda u: 502)
assert not bad["ready"] and any(i["id"] == "adminreach" and i["level"] == "fail" for i in bad["items"])
def test_live_deploy_requires_admin_public_url_for_admin_source(client, live_env):
_ready(client, "pf2")
client.put("/api/tenants/pf2/source", json={"source": "admin"})
client.put("/api/settings", json={"core_supports_bundle": "true", "admin_public_url": ""})
d = client.get(f"/api/deployments/{deploy.start('pf2', 'deploy', 't', http=FakeMW(), health=lambda *a: None, wait=True)}").json()
assert d["status"] == "failed" and "Öffentliche URL der Admin-Plattform" in d["log"]
assert client.get("/api/tenants/pf2/preflight").status_code == 200

529
tests/test_flow.py Normal file
View File

@@ -0,0 +1,529 @@
"""End-to-End-Test des Kernablaufs gegen eine temporäre SQLite-DB (echter Core-Render in der Sandbox)."""
import json
import os
import tarfile
import io
import tempfile
import time
from pathlib import Path
_tmp = tempfile.mkdtemp()
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp}/t.db"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.db import session # noqa: E402
from app.main import app # noqa: E402
from app.services.importer import import_all # noqa: E402
from app.settings import LEGACY_PLATFORM # noqa: E402
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
with session() as db:
import_all(db, Path(LEGACY_PLATFORM))
yield c
def test_import_all_tenants(client):
for tid in ["demo", "onyva", "revier", "rotary", "rotary-wil", "sportklinik", "sprinz"]:
s = client.get(f"/api/tenants/{tid}/state").json()
assert s["current_version"] == 1 and s["config"]["id"] == tid and "deploy" not in s["config"]
def test_previews_render_with_real_core(client):
for tid in ("rotary-wil", "sprinz"):
assert client.get(f"/preview/{tid}/pdf").content[:5] == b"%PDF-"
html = client.get(f"/preview/{tid}/web").text
assert "@@" not in html and "<html" in html.lower()
def test_draft_publish_cycle(client):
assert client.put("/api/tenants/demo/config", json={**client.get("/api/tenants/demo/state").json()["config"],
"colors": {**client.get("/api/tenants/demo/state").json()["config"]["colors"], "accent": "#112233"}}).json()["has_draft"]
d = client.get("/api/tenants/demo/diff").json()
assert d["changed"] and d["items"][0]["path"] == "tenant.json"
assert "#112233" in client.get("/preview/demo/web").text
assert "#112233" not in client.get("/preview/demo/web?source=current").text # veröffentlicht unverändert
assert client.post("/api/tenants/demo/validate").json()["ok"]
v = client.post("/api/tenants/demo/publish", json={"message": "Akzent"}).json()["version"]
assert v == 2
assert not client.get("/api/tenants/demo/state").json()["has_draft"]
def test_invalid_python_is_rejected(client):
r = client.put("/api/tenants/demo/files/overrides.py", json={"content": "def x(:\n"})
assert r.status_code == 400 and "Syntax" in r.json()["detail"]
def test_broken_template_fails_validation(client):
client.put("/api/tenants/sprinz/files/protocol.html", json={"content": "{% for %}"})
v = client.post("/api/tenants/sprinz/validate").json()
assert not v["ok"]
client.post("/api/tenants/sprinz/draft/discard")
def test_restore_and_discard(client):
client.post("/api/tenants/demo/versions/1/restore")
assert client.get("/api/tenants/demo/state").json()["has_draft"]
client.post("/api/tenants/demo/draft/discard")
assert not client.get("/api/tenants/demo/state").json()["has_draft"]
def test_new_tenant_clone_and_bundle(client):
r = client.post("/api/tenants", json={"id": "musterklinik", "name": "Musterklinik", "clone_from": "sportklinik"})
assert r.status_code == 200
assert client.post("/api/tenants", json={"id": "musterklinik", "name": "x"}).status_code == 400
# Bundle nur mit Token
assert client.get("/api/tenants/musterklinik/bundle").status_code == 401
from app.models import Secret
from app.services import crypto
from sqlalchemy import select
with session() as db:
tok = crypto.decrypt(db.scalar(select(Secret).where(Secret.tenant_id == "musterklinik", Secret.key == "ADMIN_BUNDLE_TOKEN")).value_enc)
b = client.get("/api/tenants/musterklinik/bundle", headers={"Authorization": f"Bearer {tok}"})
assert b.status_code == 200
names = tarfile.open(fileobj=io.BytesIO(b.content), mode="r:gz").getnames()
assert "tenant.json" in names and "MANIFEST.json" in names and "logo.png" in names
def _wait(client, dep, tries=60):
for _ in range(tries):
d = client.get(f"/api/deployments/{dep}").json()
if d["status"] not in ("running", "queued"):
return d
time.sleep(0.4)
return d
def test_dry_run_deploy_and_cutover(client):
dep = client.post("/api/tenants/sprinz/deploy", json={"dry_run": True}).json()["id"]
d = _wait(client, dep)
assert d["status"] == "success" and "Dry-Run beendet" in d["log"] and "[DRY-RUN] PATCH" in d["log"]
assert "••••" in d["log"] or "SESSION_SECRET" not in d["log"] # Secrets maskiert
checks = {c["id"]: c["ok"] for c in client.get("/api/tenants/sprinz/cutover").json()["checks"]}
assert checks["dryrun"] and checks["validate"] and not checks["secrets"]
def test_real_deploy_is_locked(client):
r = client.post("/api/tenants/sprinz/deploy", json={"dry_run": False})
assert r.status_code == 403 and "DEPLOY_LIVE" in r.json()["detail"]
assert client.post("/api/deploy-all", json={"dry_run": False}).status_code == 403
assert client.post("/api/tenants/sprinz/offboard", json={"dry_run": False}).status_code == 403
def test_deploy_all_dry_run(client):
r = client.post("/api/deploy-all", json={"dry_run": True}).json()
for _ in range(120):
rows = client.get(f"/api/deployments?batch={r['batch']}").json()
if all(x["status"] not in ("running", "queued") for x in rows):
break
time.sleep(0.5)
assert len(rows) >= 7 and all(x["status"] == "success" for x in rows)
def test_mittwald_client_live_sequence_with_guard():
"""Gegen einen Fake-HTTP: richtige Reihenfolge, PATCH statt PUT, Guard gegen verschwundene Services."""
from app.services.mittwald import DeployError, Mittwald
calls, lines = [], []
stack = {"services": {"legacy": {"id": "svc-0"}, "app-x": {"id": "svc-1"}}}
def http(method, url, headers, body):
calls.append((method, url.split("/v2")[1], body))
if method == "GET" and url.endswith("/stacks/s-1/"):
return 200, stack, ""
if method == "GET" and "ingresses" in url:
return 200, [], ""
if method == "GET" and "/projects/p-1/" in url:
return 200, {"id": "uuid-1"}, ""
return 200, {}, ""
mw = Mittwald("tok", "https://api.test/v2", True, lines.append, http)
mw.deploy_service(stack_id="s-1", service="app-x", volume="vol-x", definition={"image": "i"}, container_port="8276",
project_id="p-1", hostname="x.example.de")
verbs = [(m, p) for m, p, _ in calls]
assert ("PUT", "/stacks/s-1/") not in verbs
assert verbs.index(("PATCH", "/stacks/s-1/")) < verbs.index(("POST", "/stacks/s-1/services/svc-1/actions/recreate/"))
assert any(p == "/ingresses" and m == "POST" for m, p, _ in calls)
assert any("Guard" in l for l in lines)
# Guard schlägt an, wenn ein fremder Service verschwindet
state = {"n": 0}
def http2(method, url, headers, body):
if method == "GET" and url.endswith("/stacks/s-1/"):
state["n"] += 1
return 200, ({"services": {"legacy": {}}} if state["n"] == 1 else {"services": {"app-x": {"id": "svc-1"}}}), ""
return 200, {}, ""
try:
Mittwald("tok", "https://api.test/v2", True, lambda m: None, http2).deploy_service(
stack_id="s-1", service="app-x", volume="vol-x", definition={}, container_port="8276", project_id="p-1", hostname=None)
raise AssertionError("Guard hat nicht angeschlagen")
except DeployError as exc:
assert "verschwunden" in str(exc)
def test_dry_run_client_never_writes():
from app.services.mittwald import Mittwald
calls = []
mw = Mittwald("tok", "https://api.test/v2", False, lambda m: None, lambda *a: calls.append(a) or (200, {}, ""))
mw.deploy_service(stack_id="s", service="app-x", volume="v", definition={"environment": {"X_SECRET": "geheim"}}, container_port="1", project_id="p", hostname="h.de")
assert all(c[0] == "GET" for c in calls)
def _sse(resp):
return [json.loads(l[6:]) for l in resp.text.split("\n\n") if l.startswith("data: ")]
def test_agent_mock_stream_and_apply(client):
r = client.post("/api/tenants/revier/chat", json={"message": "Setze die Akzentfarbe auf #0B6E4F"})
ev = _sse(r)
assert [e["type"] for e in ev][0] == "mode" and ev[0]["backend"] == "mock"
assert any(e["type"] == "step" and e["tool"] == "validate" for e in ev)
msg = ev[-1]["message"]
assert msg["proposal_status"] == "open"
assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] != "#0B6E4F" # Entwurf unberührt
d = client.get(f"/api/tenants/revier/chat/{msg['id']}/diff").json()
assert d["changed"] and "#0B6E4F" in json.dumps(d)
client.post(f"/api/tenants/revier/chat/{msg['id']}/apply")
assert client.get("/api/tenants/revier/state").json()["config"]["colors"]["accent"] == "#0B6E4F"
client.post("/api/tenants/revier/draft/discard")
def test_agent_tools_with_scripted_backend(client):
"""Echter Loop + echte Tools + Core-Rendering, nur das LLM ist geskriptet."""
from app.agent import loop
from app.agent.backends import Turn
class Scripted:
name, model = "scripted", "x"
def __init__(self):
self.i = 0
def step(self, system, messages, tools):
self.i += 1
if self.i == 1:
return Turn([{"type": "tool_use", "id": "1", "name": "read_file", "input": {"path": "namensliste.txt"}},
{"type": "tool_use", "id": "2", "name": "write_file", "input": {"path": "namensliste.txt", "content": "x"}},
{"type": "tool_use", "id": "3", "name": "write_file", "input": {"path": "protocol.css", "content": "body{color:red}"}},
{"type": "tool_use", "id": "4", "name": "write_file", "input": {"path": "overrides.py", "content": "def x(:"}},
{"type": "tool_use", "id": "5", "name": "render_pdf", "input": {}}])
return Turn([{"type": "text", "text": "Fertig."}])
with session() as db:
from app.models import Tenant
t = db.get(Tenant, "rotary-wil")
ev = list(loop.run(db, t, "teste", backend=Scripted()))
steps = [e for e in ev if e["type"] == "step"]
assert "gesperrt" in steps[0]["result"] # Namensliste für den Agenten unlesbar
assert steps[1]["error"] and "personenbezogen" in steps[1]["result"] # … und unveränderbar
assert not steps[2]["error"]
assert steps[3]["error"] # Syntaxfehler in overrides.py abgefangen
assert "Seite" in steps[4]["result"]
prop = [e for e in ev if e["type"] == "proposal"][0]["proposal"]
assert [i["path"] for i in prop["items"]] == ["protocol.css"]
with session() as db: # Entwurf bleibt unberührt, bis freigegeben
assert not db.get(Tenant, "rotary-wil").draft
def test_prompt_test_endpoint(client, monkeypatch):
from app.services import llm
monkeypatch.setattr(llm, "summarize", lambda db, system, user, model=None, tenant_id=None: {"text": "===TERMINART===\nKick-off\n===/TERMINART===", "model": "m", "input_tokens": 10, "output_tokens": 5, "truncated": False})
r = client.post("/api/tenants/rotary-wil/prompt-test", json={}).json()
assert r["text"].startswith("===TERMINART") and r["prompt_chars"] > 1000
# Namensregister landet über den build_prompt-Hook im finalen Prompt
from app.services import preview
with session() as db:
from app.models import Tenant
fin = preview.run(db, db.get(Tenant, "rotary-wil"), "draft", "prompt", args={"title": "x"})["prompt"]
assert "Namensregister des Clubs" in fin
def test_prompt_test_without_key_is_clean_error(client, monkeypatch):
monkeypatch.delenv("CORTECS_API_KEY", raising=False)
r = client.post("/api/tenants/sprinz/prompt-test", json={})
assert r.status_code == 400 and "CORTECS_API_KEY" in r.json()["detail"]
def test_meta_archive_delete(client):
client.post("/api/tenants", json={"id": "wegdamit", "name": "Weg damit"})
assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 99}).status_code == 400
assert client.put("/api/tenants/wegdamit/meta", json={"cpu": 1, "memory": "1G", "notes": "n"}).status_code == 200
assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 400 # erst archivieren
client.put("/api/tenants/wegdamit/meta", json={"status": "archived"})
assert client.delete("/api/tenants/wegdamit?confirm=falsch").status_code == 400
assert client.delete("/api/tenants/wegdamit?confirm=wegdamit").status_code == 200
assert client.get("/api/tenants/wegdamit/state").status_code == 404
def test_oidc_login_flow_and_access_rules(monkeypatch):
from app import auth
monkeypatch.setattr(auth, "AUTH_ENABLED", True)
monkeypatch.setenv("OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("OIDC_CLIENT_SECRET", "sec")
import base64 as b64
idt = lambda claims: "h." + b64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + ".s" # noqa: E731
with TestClient(app, follow_redirects=False) as c:
assert c.get("/").status_code == 302 and c.get("/").headers["location"] == "/auth/login"
assert c.get("/api/tenants/demo/state").status_code == 401
assert c.get("/healthz").status_code == 200
r = c.get("/auth/login")
assert r.status_code == 302 and "code_challenge_method=S256" in r.headers["location"]
state = dict(p.split("=") for p in r.headers["location"].split("?")[1].split("&"))["state"]
# Default-Deny: keine Zugriffsregel konfiguriert
monkeypatch.setattr(auth, "exchange_code", lambda code, cv: {"id_token": idt({"sub": "1", "email": "chef@onyva.de", "app_roles": ["admin"]})})
assert c.get(f"/auth/callback?code=x&state={state}").status_code == 403
# Mit Allowlist
monkeypatch.setenv("ADMIN_ALLOWED_EMAILS", "chef@onyva.de")
state2 = dict(p.split("=") for p in c.get("/auth/login").headers["location"].split("?")[1].split("&"))["state"]
ok = c.get(f"/auth/callback?code=x&state={state2}")
assert ok.status_code == 303
assert c.get("/api/tenants/demo/state").status_code == 200
assert c.get("/auth/me").json()["user"]["email"] == "chef@onyva.de"
# falscher State → neu starten, keine Session
with TestClient(app, follow_redirects=False) as c2:
c2.get("/auth/login")
assert c2.get("/auth/callback?code=x&state=falsch").headers["location"] == "/auth/login"
# Bundle bleibt tokenbasiert erreichbar (nicht über Cookie)
assert c2.get("/api/tenants/demo/bundle").status_code == 401
monkeypatch.setattr(auth, "AUTH_ENABLED", False)
def test_messages_backend_talks_to_cortecs_endpoint():
"""Anthropic-SDK gegen Cortecs: richtige URL (/v1/messages), Bearer-Auth, Tool-Schema, Antwort-Parsing."""
import anthropic
try:
import httpx2 as httpx # neuere Anthropic-SDKs bringen einen eigenen HTTP-Client mit
except ImportError:
import httpx
from app.agent.backends import MessagesBackend
from app.agent.tools import TOOLS
seen = {}
def handler(req: httpx.Request):
seen["url"], seen["auth"], seen["body"] = str(req.url), req.headers.get("authorization"), json.loads(req.content)
return httpx.Response(200, json={"id": "m", "type": "message", "role": "assistant", "model": "x", "stop_reason": "tool_use",
"content": [{"type": "text", "text": "ok"}, {"type": "tool_use", "id": "t1", "name": "validate", "input": {}}],
"usage": {"input_tokens": 11, "output_tokens": 7}})
b = MessagesBackend("KEY", "https://api.cortecs.ai/v1", "claude-4-6-sonnet")
b.client = anthropic.Anthropic(api_key="KEY", auth_token="KEY", base_url="https://api.cortecs.ai", http_client=httpx.Client(transport=httpx.MockTransport(handler)))
turn = b.step("sys", [{"role": "user", "content": "hi"}], TOOLS)
assert seen["url"] == "https://api.cortecs.ai/v1/messages" and seen["auth"] == "Bearer KEY"
assert seen["body"]["model"] == "claude-4-6-sonnet" and {t["name"] for t in seen["body"]["tools"]} >= {"edit_file", "validate"}
assert turn.blocks[1] == {"type": "tool_use", "id": "t1", "name": "validate", "input": {}} and turn.usage == {"in": 11, "out": 7}
def test_chat_backend_message_conversion():
from app.agent.backends import ChatBackend
conv = ChatBackend._convert("sys", [
{"role": "user", "content": "los"},
{"role": "assistant", "content": [{"type": "text", "text": "ich lese"}, {"type": "tool_use", "id": "a", "name": "read_file", "input": {"path": "x"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "inhalt"}]}])
assert [m["role"] for m in conv] == ["system", "user", "assistant", "tool"]
assert conv[2]["tool_calls"][0]["function"]["name"] == "read_file" and conv[3]["tool_call_id"] == "a"
def test_plan_masks_secrets_but_not_tokens_limit(client):
p = client.get("/api/tenants/sprinz/plan").json()["patch"]["environment"]
assert p["LLM_MAX_TOKENS"] == "16000" and p["TENANT"] == "sprinz" and "TENANT_SOURCE" not in p
def test_final_prompt_and_files_privacy(client):
r = client.get("/api/tenants/rotary-wil/final-prompt").json()
assert "Namensregister des Clubs" in r["prompt"]
st = client.get("/api/tenants/rotary-wil/state").json()
assert st["files"]["namensliste.txt"]["personal"] is True
# ── OIDC-Abgleich gegen die Developer-API (Fake-Login-Server) ───────────────
from tests.fakelogin import FakeLogin # noqa: E402
@pytest.fixture()
def login(monkeypatch):
from app.services import onyva_login
fake = FakeLogin()
monkeypatch.setenv("ONYVA_LOGIN_API_KEY", "onyva_testkey")
monkeypatch.setenv("OIDC_SYNC_LIVE", "true")
monkeypatch.setenv("DEPLOY_ALLOWED_TENANTS", "*")
monkeypatch.setattr(onyva_login, "client_from_env",
lambda db, http=None: onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=lambda s: None))
return fake
def _secret_value(tenant, key):
from app.models import Secret
from app.services import crypto
from sqlalchemy import select
with session() as db:
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant, Secret.key == key))
return crypto.decrypt(r.value_enc) if r and r.value_enc else None
def test_oidc_creates_app_stores_secret_and_is_idempotent(client, login):
client.post("/api/tenants", json={"id": "klinik1", "name": "Klinik Eins"})
client.put("/api/tenants/klinik1/oidc", json={"members": [{"email": "anna@x.de", "role": "editor"}, {"email": "ghost@x.de", "role": "viewer"}]})
plan = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": False}).json()
assert plan["ok"] and [a["kind"] for a in plan["actions"]][0] == "create" and not login.writes # Dry-Run schreibt nichts
rep = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json()
assert rep["created"] and rep["client_id"] == "app_0001"
app = login.apps["app_0001"]
assert app["redirect_uris"] == ["https://klinik1.onyscribe.de/auth/callback"] and app["auth_mode"] == "required"
assert _secret_value("klinik1", "OIDC_CLIENT_ID") == "app_0001" and _secret_value("klinik1", "OIDC_CLIENT_SECRET") == "secret-1"
assert login.members["app_0001"] == {"anna@x.de": "editor"} and any("ghost@x.de" in w for w in rep["warnings"])
login.calls.clear()
again = client.post("/api/tenants/klinik1/oidc/sync", json={"apply": True}).json()
assert [a["kind"] for a in again["actions"]] == ["member_put"] and not any(c[0] in ("POST",) for c in login.writes) # nur der (weiter fehlende) Geist-Nutzer
assert client.get("/api/tenants/klinik1/oidc").json()["last_sync"]["ok"]
def test_oidc_adopt_only_adds_missing_redirect(client, login):
from app.services import tenants as tsvc
login.apps["app_legacy"] = {"client_id": "app_legacy", "name": "Alt", "redirect_uris": ["https://wil-rotary.onyscribe.de/auth/callback", "https://alt.example/cb"],
"auth_mode": "optional", "totp_mode": "required", "allow_registration": True, "backchannel_logout_uri": None}
with session() as db:
tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_ID", "app_legacy", "t")
tsvc.set_secret(db, "rotary-wil", "OIDC_CLIENT_SECRET", "legacy-secret", "t")
client.put("/api/tenants/rotary-wil/meta", json={})
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"})
rep = client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True}).json()
assert rep["ok"] and not rep["created"] and not rep["warnings"]
assert login.apps["app_legacy"]["redirect_uris"][-1:] == ["https://alt.example/cb"] and login.apps["app_legacy"]["auth_mode"] == "optional" # nichts Fremdes verändert
assert _secret_value("rotary-wil", "OIDC_CLIENT_SECRET") == "legacy-secret"
# Subdomain ändern → adopt ergänzt nur die neue URI
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "neu"})
client.post("/api/tenants/rotary-wil/oidc/sync", json={"apply": True})
assert "https://neu.onyscribe.de/auth/callback" in login.apps["app_legacy"]["redirect_uris"] and len(login.apps["app_legacy"]["redirect_uris"]) == 3
client.put("/api/tenants/rotary-wil/domain", json={"subdomain": "wil-rotary"})
def test_oidc_managed_corrects_drift_and_exact_members(client, login):
client.post("/api/tenants", json={"id": "klinik2", "name": "Klinik Zwei"})
client.put("/api/tenants/klinik2/oidc", json={"members": [{"email": "anna@x.de", "role": "viewer"}], "members_mode": "exact", "totp_mode": "optional"})
client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True})
cid = _secret_value("klinik2", "OIDC_CLIENT_ID")
assert login.apps[cid]["totp_mode"] == "optional"
login.apps[cid].update(auth_mode="disabled", redirect_uris=["https://evil.example/cb"]) # Drift
login.members[cid]["bob@x.de"] = "admin" # fremde Freigabe
rep = client.post("/api/tenants/klinik2/oidc/sync", json={"apply": True}).json()
assert login.apps[cid]["auth_mode"] == "required" and login.apps[cid]["redirect_uris"] == ["https://klinik2.onyscribe.de/auth/callback"]
assert "bob@x.de" not in login.members[cid] and {a["kind"] for a in rep["actions"]} >= {"patch", "member_delete"}
def test_oidc_stale_client_id_recreates_and_rotate(client, login):
from app.services import tenants as tsvc
client.post("/api/tenants", json={"id": "klinik3", "name": "Klinik Drei"})
with session() as db:
tsvc.set_secret(db, "klinik3", "OIDC_CLIENT_ID", "app_gone", "t")
rep = client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True}).json()
assert rep["created"] and any("existiert im Login nicht mehr" in w for w in rep["warnings"])
assert _secret_value("klinik3", "OIDC_CLIENT_ID") != "app_gone"
old = _secret_value("klinik3", "OIDC_CLIENT_SECRET")
client.post("/api/tenants/klinik3/oidc/sync", json={"apply": True, "rotate": True})
assert _secret_value("klinik3", "OIDC_CLIENT_SECRET") not in (None, old)
def test_oidc_write_gate_and_missing_key(client, monkeypatch):
monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False)
monkeypatch.delenv("DEPLOY_LIVE", raising=False)
assert client.post("/api/tenants/sprinz/oidc/sync", json={"apply": True}).status_code == 403
monkeypatch.delenv("ONYVA_LOGIN_API_KEY", raising=False)
r = client.post("/api/tenants/sprinz/oidc/sync", json={"apply": False}).json()
assert r["ok"] and "ONYVA_LOGIN_API_KEY" in r["skipped"]
assert client.delete("/api/tenants/sprinz/oidc/app?confirm=sprinz").status_code == 403
assert client.put("/api/tenants/sprinz/oidc", json={"mode": "kaputt"}).status_code == 400
assert client.put("/api/tenants/sprinz/oidc", json={"members": [{"email": "keine-mail"}]}).status_code == 400
def test_oidc_rate_limit_retry_and_bad_key():
from app.services import onyva_login
fake = FakeLogin()
waits = []
c = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", fake, sleep=waits.append)
fake.rate_limit_once = True
assert c.list_apps() == [] and waits == [2]
try:
onyva_login.OnyvaLogin("https://login.test/api/v1", "falsch", fake, sleep=lambda s: None).list_apps()
raise AssertionError
except onyva_login.OnyvaError as exc:
assert "401" in str(exc)
def test_oidc_import_members_and_delete_app(client, login):
client.post("/api/tenants", json={"id": "klinik4", "name": "Klinik Vier"})
client.post("/api/tenants/klinik4/oidc/sync", json={"apply": True})
cid = _secret_value("klinik4", "OIDC_CLIENT_ID")
login.members[cid]["bob@x.de"] = "admin"
got = client.post("/api/tenants/klinik4/oidc/import-members").json()
assert got["members"] == [{"email": "bob@x.de", "role": "admin"}]
assert client.delete("/api/tenants/klinik4/oidc/app?confirm=nein").status_code == 400
assert client.delete("/api/tenants/klinik4/oidc/app?confirm=klinik4").status_code == 200
assert cid not in login.apps and _secret_value("klinik4", "OIDC_CLIENT_ID") is None
def test_deploy_runs_oidc_sync_before_service_patch(client, login, monkeypatch):
"""Live-Deploy (alles gefakt): OIDC-Client wird VOR dem Service-PATCH angelegt und landet in dessen Env; Fehler bricht ab."""
from app.services import deploy
client.post("/api/tenants", json={"id": "klinik5", "name": "Klinik Fünf"})
client.put("/api/tenants/klinik5/source", json={"source": "git"})
client.put("/api/settings", json={"mittwald_project_id": "p-1", "mittwald_stack_id": "s-1", "registry": "reg.test/o"})
monkeypatch.setenv("DEPLOY_LIVE", "true")
monkeypatch.setenv("MITTWALD_API_TOKEN", "tok")
# Pflicht-Secrets laut Core-Schema: erst fehlen sie → Deploy wird VOR dem Service-Eingriff blockiert
blocked = deploy.start("klinik5", "deploy", "test", http=lambda *a: (200, {}, ""), oidc_client=None, wait=True, health=lambda *a: None)
d0 = client.get(f"/api/deployments/{blocked}").json()
assert d0["status"] == "failed" and ("COST_INGEST_KEY" in d0["log"] or "OIDC-Abgleich" in d0["log"] or "ONYVA_LOGIN_API_KEY" in d0["log"])
client.put("/api/shared-secrets/CORTECS_API_KEY", json={"value": "ck"})
client.put("/api/tenants/klinik5/secrets/COST_INGEST_KEY", json={"value": "ik"})
order = []
stack = {"services": {"legacy": {"id": "svc-0"}, "app-klinik5": {"id": "svc-5"}}}
def mw_http(method, url, headers, body):
order.append(("mittwald", method, url.split("/v2")[1], body))
if method == "GET" and url.endswith("/stacks/s-1/"):
return 200, stack, ""
if method == "GET" and "ingresses" in url:
return 200, [], ""
if method == "GET" and "/projects/p-1/" in url:
return 200, {"id": "uuid-1"}, ""
return 200, {}, ""
class Spy(FakeLogin):
def __call__(self, method, url, headers, body):
order.append(("login", method, url.split("/api/v1")[1], body))
return super().__call__(method, url, headers, body)
spy = Spy()
from app.services import onyva_login
oc = onyva_login.OnyvaLogin("https://login.test/api/v1", "onyva_testkey", spy, sleep=lambda s: None)
dep = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=oc, wait=True, health=lambda *a: None)
d = client.get(f"/api/deployments/{dep}").json()
assert d["status"] == "success", d["log"]
first_login_write = next(i for i, o in enumerate(order) if o[0] == "login" and o[1] == "POST")
first_patch = next(i for i, o in enumerate(order) if o[0] == "mittwald" and o[1] == "PATCH")
assert first_login_write < first_patch
env = next(o[3]["services"]["app-klinik5"]["environment"] for o in order if o[0] == "mittwald" and o[1] == "PATCH" and o[3].get("services"))
assert env["OIDC_CLIENT_ID"] == "app_0001" and env["OIDC_CLIENT_SECRET"] == "secret-1" and env["OIDC_REDIRECT_URI"].startswith("https://klinik5.")
# Login-API kaputt → Deploy bricht VOR dem Service-PATCH ab
order.clear()
client.put("/api/tenants/klinik5/oidc", json={"auth_mode": "optional"})
bad = onyva_login.OnyvaLogin("https://login.test/api/v1", "falscher-key", spy, sleep=lambda s: None)
dep2 = deploy.start("klinik5", "deploy", "test", http=mw_http, oidc_client=bad, wait=True, health=lambda *a: None)
d2 = client.get(f"/api/deployments/{dep2}").json()
assert d2["status"] == "failed" and "OIDC-Abgleich fehlgeschlagen" in d2["log"]
assert not any(o[0] == "mittwald" and o[1] == "PATCH" for o in order)
def test_dry_run_deploy_shows_oidc_plan_without_writes(client, login, monkeypatch):
monkeypatch.delenv("DEPLOY_LIVE", raising=False)
monkeypatch.delenv("OIDC_SYNC_LIVE", raising=False)
from app.services import deploy
client.post("/api/tenants", json={"id": "klinik6", "name": "Klinik Sechs"})
dep = deploy.start("klinik6", "dry-run", "test", wait=True)
d = client.get(f"/api/deployments/{dep}").json()
assert d["status"] == "success" and "würde App" in d["log"] and not login.writes

1
vendor/platform/CORE_VERSION vendored Normal file
View File

@@ -0,0 +1 @@
13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration

1
vendor/platform/core/BUILD_INFO.json vendored Normal file
View File

@@ -0,0 +1 @@
{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"}

0
vendor/platform/core/__init__.py vendored Normal file
View File

1637
vendor/platform/core/app.py vendored Normal file

File diff suppressed because it is too large Load Diff

178
vendor/platform/core/bundle.py vendored Normal file
View File

@@ -0,0 +1,178 @@
"""Tenant-Bundle-Loader: holt die Tenant-Daten zur Laufzeit von der Admin-Plattform (TENANT_SOURCE=admin).
git (Standard, wie bisher) Tenant-Ordner aus dem Image: tenants/<id>/
admin Bundle von {ADMIN_URL}/api/tenants/<id>/bundle (Bearer ADMIN_BUNDLE_TOKEN), geprüft und lokal gecacht
Ausfallsicherheit bei admin: Admin nicht erreichbar → letzter gecachter Stand (/data/bundle) → eingebackener Tenant
(nur solange BUNDLE_FALLBACK_BAKED nicht „false“ ist) → sonst Fehler beim Start. Die Admin-Plattform ist damit nur
Kontrollebene, nicht Teil des Laufzeitpfads.
Sicherheit: Hashes aller Dateien werden gegen MANIFEST.json geprüft, nur normale Dateien, keine Pfad-Tricks, Größenlimits.
Format siehe docs/CONTRACT.md. Nur Standardbibliothek – wird beim Import von core.config genutzt.
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import shutil
import tarfile
import time
import urllib.error
import urllib.request
from pathlib import Path, PurePosixPath
SUPPORTED_BUNDLE_FORMAT = 1
MAX_FILES = 200
MAX_TOTAL_BYTES = 64 * 1024 * 1024
KEEP_VERSIONS = 2
class BundleError(RuntimeError):
pass
def _truthy(v: str | None, default: bool) -> bool:
return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
def _http_fetch(url: str, token: str, etag: str | None, timeout: float = 20.0):
"""→ (status, headers, body). 304 bei unverändertem ETag. Wirft OSError bei Netzwerkfehlern."""
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", **({"If-None-Match": etag} if etag else {})})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, dict(r.headers), r.read(MAX_TOTAL_BYTES + 1)
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers or {}), b""
def parse_bundle(data: bytes, tenant_id: str) -> tuple[dict, dict[str, bytes]]:
"""Prüft ein Bundle und gibt (manifest, {pfad: inhalt}) zurück. Wirft BundleError bei jeder Auffälligkeit."""
if len(data) > MAX_TOTAL_BYTES:
raise BundleError("Bundle zu groß")
files: dict[str, bytes] = {}
total = 0
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
members = tar.getmembers()
if len(members) > MAX_FILES:
raise BundleError(f"Bundle enthält mehr als {MAX_FILES} Dateien")
for m in members:
if not m.isreg():
raise BundleError(f"Nicht erlaubter Eintrag im Bundle: {m.name!r}")
p = PurePosixPath(m.name)
if p.is_absolute() or ".." in p.parts or not p.parts or "\\" in m.name:
raise BundleError(f"Unzulässiger Pfad im Bundle: {m.name!r}")
total += m.size
if total > MAX_TOTAL_BYTES:
raise BundleError("Bundle entpackt zu groß")
files[str(p)] = tar.extractfile(m).read()
except (tarfile.TarError, EOFError, OSError) as exc:
raise BundleError(f"Bundle nicht lesbar: {exc}") from exc
if "MANIFEST.json" not in files or "tenant.json" not in files:
raise BundleError("MANIFEST.json oder tenant.json fehlt im Bundle")
try:
manifest = json.loads(files.pop("MANIFEST.json"))
cfg = json.loads(files["tenant.json"])
except ValueError as exc:
raise BundleError(f"MANIFEST.json/tenant.json ungültig: {exc}") from exc
if int(manifest.get("bundle_format", 1)) > SUPPORTED_BUNDLE_FORMAT:
raise BundleError(f"Bundle-Format {manifest.get('bundle_format')} wird von diesem Core nicht unterstützt")
if manifest.get("tenant") != tenant_id or cfg.get("id") != tenant_id:
raise BundleError(f"Bundle gehört nicht zu Tenant {tenant_id!r}")
expected = manifest.get("files") or {}
if set(expected) != set(files):
raise BundleError("Dateiliste weicht vom MANIFEST ab")
for path, sha in expected.items():
if hashlib.sha256(files[path]).hexdigest() != sha:
raise BundleError(f"Hash-Abweichung: {path}")
return manifest, files
def _install(cache: Path, version: int, manifest: dict, files: dict[str, bytes], etag: str | None) -> Path:
"""Schreibt das Bundle in ein neues Versionsverzeichnis und schaltet `current` atomar um."""
cache.mkdir(parents=True, exist_ok=True)
target = cache / f"v{version}-{hashlib.sha256(json.dumps(manifest.get('files', {}), sort_keys=True).encode()).hexdigest()[:8]}"
if not target.exists():
tmp = cache / f".tmp-{os.getpid()}-{int(time.time() * 1000)}"
for path, content in files.items():
dest = tmp / path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(content)
(tmp / ".bundle.json").write_text(json.dumps({"version": version, "etag": etag, "fetched_at": time.time()}))
os.replace(tmp, target)
link = cache / "current"
new_link = cache / f".current-{os.getpid()}"
if new_link.is_symlink() or new_link.exists():
new_link.unlink()
os.symlink(target.name, new_link)
os.replace(new_link, link)
keep = {target.name}
for d in sorted((p for p in cache.glob("v*") if p.is_dir()), key=lambda p: p.stat().st_mtime, reverse=True)[:KEEP_VERSIONS]:
keep.add(d.name)
for d in cache.glob("v*"):
if d.is_dir() and d.name not in keep:
shutil.rmtree(d, ignore_errors=True)
return target
def _cached(cache: Path) -> tuple[Path, dict] | None:
link = cache / "current"
if link.is_symlink() and (link / "tenant.json").exists():
try:
meta = json.loads((link / ".bundle.json").read_text())
except (OSError, ValueError):
meta = {}
return link, meta
return None
def resolve(tenant_id: str, tenants_dir: Path, env: dict | None = None, fetch=_http_fetch) -> tuple[Path, dict]:
"""Liefert (Tenant-Verzeichnis, Info). Info: {source: git|admin|cache|baked, version, stale, error, fetched_at}."""
env = os.environ if env is None else env
source = (env.get("TENANT_SOURCE") or "git").strip().lower()
baked = tenants_dir / tenant_id
if source == "git":
return baked, {"source": "git", "version": None, "stale": False, "error": None, "fetched_at": None}
if source != "admin":
raise BundleError(f"Unbekannte TENANT_SOURCE {source!r} (erlaubt: git, admin)")
base = (env.get("ADMIN_URL") or "").rstrip("/")
token = env.get("ADMIN_BUNDLE_TOKEN") or ""
cache = Path(env.get("BUNDLE_CACHE_DIR") or Path(env.get("DATA_DIR") or "/data") / "bundle") / tenant_id
have = _cached(cache)
error: str | None = None
if not base or not token:
error = "ADMIN_URL oder ADMIN_BUNDLE_TOKEN nicht gesetzt"
else:
for attempt in range(3): # kurze Wiederholung: Container und Admin starten oft gleichzeitig
try:
status, headers, body = fetch(f"{base}/api/tenants/{tenant_id}/bundle", token, (have[1].get("etag") if have else None))
except OSError as exc:
error, status = f"Admin nicht erreichbar: {exc}", None
else:
if status == 200:
try:
manifest, files = parse_bundle(body, tenant_id)
version = int(manifest.get("version") or headers.get("X-Bundle-Version") or 0)
etag = {k.lower(): v for k, v in headers.items()}.get("etag")
d = _install(cache, version, manifest, files, etag)
return d, {"source": "admin", "version": version, "stale": False, "error": None, "fetched_at": time.time()}
except BundleError as exc:
error = f"Ungültiges Bundle: {exc}"
break # kein Retry: Inhalt ist kaputt, nicht das Netz
elif status == 304 and have:
return have[0], {"source": "admin", "version": have[1].get("version"), "stale": False, "error": None, "fetched_at": have[1].get("fetched_at")}
elif status in (401, 403, 404):
error = f"Admin verweigert das Bundle (HTTP {status})"
break
else:
error = f"Admin-Antwort HTTP {status}"
if attempt < 2:
time.sleep(1.5 * (attempt + 1))
if have:
return have[0], {"source": "cache", "version": have[1].get("version"), "stale": True, "error": error, "fetched_at": have[1].get("fetched_at")}
if _truthy(env.get("BUNDLE_FALLBACK_BAKED"), True) and (baked / "tenant.json").exists():
return baked, {"source": "baked", "version": None, "stale": True, "error": error, "fetched_at": None}
raise BundleError(f"Tenant-Bundle für {tenant_id!r} nicht verfügbar und kein Cache/Rückfall vorhanden: {error}")

154
vendor/platform/core/config.py vendored Normal file
View File

@@ -0,0 +1,154 @@
"""Tenant configuration loading.
One process serves exactly one tenant, selected via the TENANT env var
(default "default"). The tenant folder lives in tenants/<id>/ and contains:
tenant.json branding, office info, feature flags, STT provider (data, not code)
incl. org_short/org_profile for the shared default prompt
logo.svg|png brand logo (web topbar + PDF header)
prompt.txt summary prompt (optional — falls back to core default)
protocol.html PDF layout template (optional — falls back to core default)
protocol.css PDF styling (optional — falls back to core default)
overrides.py code hooks (optional — see core/hooks.py)
Everything that differs per customer is *data* here. The core code never
changes per customer.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from core import bundle
ROOT = Path(__file__).resolve().parent.parent
# TENANTS_DIR (Env) nur für Tests/Entwicklung; Standard: eingebackene Tenants im Image
TENANTS_DIR = Path(os.getenv("TENANTS_DIR") or ROOT / "tenants")
CORE_DEFAULTS = ROOT / "core" / "defaults"
CORE_TEMPLATES = ROOT / "core" / "templates"
# Default theme — used for any colour a tenant does not override.
DEFAULT_COLORS = {
"accent": "#327F24",
"accent_dark": "#235A19",
"accent_mid": "#3E9130",
"accent_light": "#EAF4E7",
"accent_xlight": "#F3FAF1",
"bg": "#F1F5F0",
"surface": "#FFFFFF",
"surface2": "#F6FAF5",
"border": "#DCE8D9",
"border2": "#BDD1B8",
"text": "#444444",
"text_2": "#666666",
"text_3": "#9AA89A",
"red": "#DC2626",
"progress_to": "#8bbf43",
}
@dataclass
class TenantConfig:
id: str
dir: Path
name: str
title: str
description: str
colors: dict
office: dict
flags: dict
ai_detect_org: str
org_short: str = ""
org_profile: str = ""
stt: dict = field(default_factory=dict)
raw: dict = field(default_factory=dict)
# Herkunft der Tenant-Daten (siehe core/bundle.py): {source: git|admin|cache|baked, version, stale, error}
bundle: dict = field(default_factory=dict)
# ── resolved file paths (tenant override → core default) ──
def _pick(self, tenant_name: str, default_path: Path) -> Path:
candidate = self.dir / tenant_name
return candidate if candidate.exists() else default_path
@property
def prompt_path(self) -> Path:
return self._pick("prompt.txt", CORE_DEFAULTS / "prompt.txt")
@property
def pdf_template_path(self) -> Path:
return self._pick("protocol.html", CORE_TEMPLATES / "protocol.html")
@property
def pdf_css_path(self) -> Path:
return self._pick("protocol.css", CORE_TEMPLATES / "protocol.css")
@property
def logo_path(self) -> Path | None:
for name in ("logo.svg", "logo.png"):
p = self.dir / name
if p.exists():
return p
return None
@property
def docx_logo_path(self) -> Path | None:
"""Logo for the Word header — Word needs a raster image, no SVG.
A tenant may ship both (logo.svg for web/PDF, logo.png next to it);
this prefers the raster file where logo_path prefers the vector one.
"""
for name in ("logo.png", "logo.jpg", "logo.jpeg", "logo.svg"):
p = self.dir / name
if p.exists():
return p
return None
@property
def favicon_path(self) -> Path | None:
p = self.dir / "favicon.svg"
return p if p.exists() else None
def flag(self, key: str, default=None):
return self.flags.get(key, default)
def load_tenant(tenant_id: str | None = None) -> TenantConfig:
tenant_id = (tenant_id or os.getenv("TENANT", "default")).strip()
tenant_dir, bundle_info = bundle.resolve(tenant_id, TENANTS_DIR)
config_file = tenant_dir / "tenant.json"
if not config_file.exists():
raise RuntimeError(
f"Tenant '{tenant_id}' nicht gefunden: {config_file} fehlt. "
f"Verfügbar: {[p.name for p in TENANTS_DIR.iterdir() if p.is_dir()] if TENANTS_DIR.exists() else []}"
)
raw = json.loads(config_file.read_text(encoding="utf-8"))
colors = {**DEFAULT_COLORS, **(raw.get("colors") or {})}
name = raw.get("name", tenant_id)
return TenantConfig(
id=tenant_id,
dir=tenant_dir,
name=name,
title=raw.get("title", f"{name} – Gesprächsprotokoll"),
description=raw.get("description", f"Gesprächsprotokoll-Tool für {name}"),
colors=colors,
office=raw.get("office") or {},
flags=raw.get("flags") or {},
ai_detect_org=raw.get("ai_detect_org", name),
# How the tenant refers to itself inside the protocol text ("revier",
# "Onyva") and one optional sentence describing what it does — both are
# substituted into the shared default prompt.
org_short=raw.get("org_short", name),
org_profile=raw.get("org_profile", ""),
stt=raw.get("stt") or {},
raw=raw,
bundle=bundle_info,
)
# Singleton for the running process (TENANT is fixed per instance).
CFG: TenantConfig = load_tenant()

111
vendor/platform/core/contract.py vendored Normal file
View File

@@ -0,0 +1,111 @@
"""Vertrag zwischen Core und Admin-Plattform (siehe docs/CONTRACT.md).
Der Core veröffentlicht bei jedem Release: Image, Manifest, Schema (`core/schema/tenant_schema.json`), Bundle-Format und
`/health`. Die Admin-Plattform liest das – sie hält keine eigene Kopie der Tenant-Optionen.
python -m core.contract # Manifest als JSON auf stdout
python -m core.contract --check # Schema gegen den Code prüfen (Exit-Code ≠ 0 bei Abweichung)
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
CONTRACT_VERSION = 1 # Version dieses Vertrags (Schema-Format, Bundle-Format, Health-Felder)
HOOK_API = 1 # Version der Hook-Schnittstelle (core/hooks.py); overrides.py kann `HOOK_API = 1` deklarieren
BUNDLE_FORMAT = 1 # Format des Tenant-Bundles (tar.gz mit tenant.json + Dateien + MANIFEST.json)
CORE_DIR = Path(__file__).resolve().parent
ROOT = CORE_DIR.parent
SCHEMA_PATH = CORE_DIR / "schema" / "tenant_schema.json"
BUILD_INFO_PATH = CORE_DIR / "BUILD_INFO.json" # schreibt das Dockerfile; lokal fehlt die Datei
# Env-Variablen, die der Code liest, die aber bewusst nicht im Schema stehen (interne/Legacy/Plattformvariablen)
INTERNAL_ENV = {"TENANTS_DIR", "TEMP_DIR", "LOCALAPPDATA", "APP_SESSION_SECRET", "CORE_SHA", "CORE_BUILT_AT", "PYTHONPATH", "PATH", "HOME"}
def build_info() -> dict:
"""{sha, built_at, source}: BUILD_INFO.json (Image) > Env CORE_SHA > git > 'dev'."""
try:
data = json.loads(BUILD_INFO_PATH.read_text(encoding="utf-8"))
if data.get("sha"):
return {"sha": data["sha"], "built_at": data.get("built_at"), "source": "image"}
except (OSError, ValueError):
pass
if os.getenv("CORE_SHA"):
return {"sha": os.environ["CORE_SHA"], "built_at": os.getenv("CORE_BUILT_AT"), "source": "env"}
try:
sha = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
if sha:
return {"sha": sha, "built_at": None, "source": "git"}
except Exception: # noqa: BLE001
pass
return {"sha": "dev", "built_at": None, "source": "dev"}
def load_schema() -> dict:
return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
def _sha256(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def build_manifest() -> dict:
schema = load_schema()
return {
"contract": CONTRACT_VERSION, "hook_api": HOOK_API, "bundle_format": BUNDLE_FORMAT,
"core": build_info(),
"requirements_sha256": _sha256(ROOT / "requirements.txt"),
"schema_sha256": _sha256(SCHEMA_PATH),
"fields": len(schema["fields"]), "env": len(schema["env"]),
}
def schema_paths(schema: dict | None = None) -> set[str]:
return {f["path"] for f in (schema or load_schema())["fields"]}
def check_tenant_json(raw: dict, schema: dict | None = None) -> list[str]:
"""Meldet unbekannte Schlüssel und falsche Typen in einer tenant.json (Tippfehler fallen sonst still durch)."""
schema = schema or load_schema()
fields = {f["path"]: f for f in schema["fields"]}
ignored = set(schema.get("ignored_keys", []))
problems: list[str] = []
def walk(prefix: str, obj: dict) -> None:
for k, v in obj.items():
path = f"{prefix}{k}"
if not prefix and k in ignored or (not prefix and k == "id"):
continue
if path in fields:
t = fields[path]["type"]
ok = {"bool": isinstance(v, bool), "number": isinstance(v, (int, float)) and not isinstance(v, bool),
"enum": v in (fields[path].get("options") or []) or v in (None, ""),
"color": isinstance(v, str) and len(v) in (4, 7) and v.startswith("#")}.get(t, isinstance(v, str) or v is None)
if not ok:
problems.append(f"{path}: ungültiger Wert {v!r} (erwartet {t})")
elif isinstance(v, dict) and any(p.startswith(path + ".") for p in fields):
walk(path + ".", v)
else:
problems.append(f"{path}: unbekannte Option (nicht im Schema)")
walk("", raw)
return problems
if __name__ == "__main__":
if "--check" in sys.argv:
sys.path.insert(0, str(ROOT))
from tests.test_contract import drift_report # noqa: E402
rep = drift_report()
print("\n".join(rep) or "Schema und Code stimmen überein.")
sys.exit(1 if rep else 0)
print(json.dumps(build_manifest(), indent=2, ensure_ascii=False))

146
vendor/platform/core/cost.py vendored Normal file
View File

@@ -0,0 +1,146 @@
"""Cost-/Usage-Tracking client for the central Onyva Dev Login.
Reports finished EUR amounts for LLM calls and audio STT to the central
``/api/usage`` endpoint (see ``docs/cost.md``). Design goals:
* **Buffered + batched** — events are collected in memory and flushed by a
background thread, not one HTTP request per event.
* **Never blocks the user request** — ``track_usage`` only appends to a buffer;
the network call happens on the daemon thread. All errors are swallowed
(tracking is best effort and must never break the app flow).
* **Idempotent** — callers pass a stable ``idempotency_key`` so retries / repeated
status polls never double-count.
The ingest key (``usk_…``) comes from a **per-tenant Gitea secret**
``COST_INGEST_KEY_<ID>`` and reaches the container as ``COST_INGEST_KEY`` — kept
separate from the existing ``TENANT_SECRETS_<ID>`` blob (see ``infra/deploy.py``).
"""
from __future__ import annotations
import os
import threading
import time
import requests
# ── config ───────────────────────────────────────────────────────────────────
# Same base URL as the auth server. Defaults to OIDC_ISSUER, then the public login.
COST_API_BASE = os.getenv(
"COST_API_BASE",
os.getenv("OIDC_ISSUER", "https://login.onyva.dev"),
).strip().rstrip("/")
# Per-tenant ingest key (usk_…). Empty → tracking is inert (see _enabled()).
COST_INGEST_KEY = os.getenv("COST_INGEST_KEY", "").strip()
COST_TRACKING_ENABLED = os.getenv("COST_TRACKING_ENABLED", "true").lower() == "true"
_FLUSH_INTERVAL = float(os.getenv("COST_FLUSH_INTERVAL", "5"))
_HTTP_TIMEOUT = float(os.getenv("COST_HTTP_TIMEOUT", "5"))
_buffer: list[dict] = []
_lock = threading.Lock()
_worker_started = False
def _enabled() -> bool:
# No key → nothing to authenticate with → stay completely inert (also covers
# local dev where the key is simply unset).
return COST_TRACKING_ENABLED and bool(COST_INGEST_KEY)
# ── pricing (EUR) ─────────────────────────────────────────────────────────────
# The app reports finished EUR amounts; the server stores them 1:1. Rates are
# overridable via env so they can be tuned without a redeploy.
#
# Defaults:
# • LLM — claude-4-6-sonnet via Cortecs, billed by Cortecs directly in EUR:
# €2.87 / €14.31 per 1M tokens (list price, 17 Jul 2026). No USD
# conversion applies — unlike Bedrock, Cortecs quotes EUR natively.
# • STT — Mistral Voxtral Mini Transcribe: $0.003/min → €0.002625/min
# (converted at 1 USD ≈ €0.875, 24 Jun 2026). Läuft ein Tenant über
# Cortecs statt direkt über Mistral, liefert core/app.py den dortigen
# EUR-Tarif als `per_minute` mit (siehe _STT_PROVIDERS).
# Re-tune via the COST_* env vars when tariffs move, or if LLM_MODEL is changed
# to a different tier (Cortecs publishes per-model EUR rates on GET /v1/models).
def llm_cost_eur(model: str, input_tokens: int, output_tokens: int) -> float:
"""EUR cost of one LLM call from token counts. Rates are €/1M tokens."""
in_per_mtok = float(os.getenv("COST_LLM_INPUT_PER_MTOK", "2.87"))
out_per_mtok = float(os.getenv("COST_LLM_OUTPUT_PER_MTOK", "14.31"))
return (input_tokens / 1_000_000) * in_per_mtok + (output_tokens / 1_000_000) * out_per_mtok
def stt_cost_eur(minutes: float, per_minute: float | None = None) -> float:
"""EUR cost of an STT call from audio duration. Rate is €/minute.
The caller passes the rate of the tenant's STT provider (Mistral direkt vs.
Cortecs sind unterschiedlich teuer); COST_STT_PER_MINUTE überschreibt sie."""
default = "0.002625" if per_minute is None else str(per_minute)
rate = float(os.getenv("COST_STT_PER_MINUTE") or default)
return max(minutes, 0.0) * rate
# ── public API ────────────────────────────────────────────────────────────────
def track_usage(
cost_point: str,
amount_eur: float,
account: str | None = None,
quantity: float | None = None,
idempotency_key: str | None = None,
metadata: dict | None = None,
) -> None:
"""Non-blocking: append a usage event to the buffer. No-op when disabled."""
if not _enabled():
return
ev: dict = {"cost_point": cost_point, "amount": round(float(amount_eur), 4)}
if account:
ev["account"] = account
if quantity is not None:
ev["quantity"] = quantity
if idempotency_key:
ev["idempotency_key"] = idempotency_key
if metadata:
ev["metadata"] = metadata
with _lock:
_buffer.append(ev)
def flush_now() -> None:
"""Send everything currently buffered. Safe to call from shutdown."""
if not _enabled():
return
with _lock:
if not _buffer:
return
batch, _buffer[:] = _buffer[:], []
try:
requests.post(
f"{COST_API_BASE}/api/usage",
headers={"Authorization": f"Bearer {COST_INGEST_KEY}"},
json={"events": batch},
timeout=_HTTP_TIMEOUT,
)
except Exception as exc: # best effort — never propagate
# Re-buffer so a transient outage doesn't silently drop events.
with _lock:
_buffer[:0] = batch
print(f"[cost] flush failed ({len(batch)} events re-buffered): {exc}")
def _loop() -> None:
while True:
time.sleep(_FLUSH_INTERVAL)
flush_now()
def start_worker() -> None:
"""Start the background flush thread once. Inert when tracking is disabled."""
global _worker_started
if _worker_started or not _enabled():
return
_worker_started = True
threading.Thread(target=_loop, daemon=True, name="cost-flush").start()
print(f"[cost] tracking enabled → {COST_API_BASE}/api/usage")

142
vendor/platform/core/defaults/prompt.txt vendored Normal file
View File

@@ -0,0 +1,142 @@
Du bist der offizielle Protokollführer von @@ORG@@. @@ORG_PROFILE@@
Deine Aufgabe ist es, aus einem rohen Gesprächsprotokoll eines Kundentermins ein druckreifes Ergebnisprotokoll im Stil von @@ORG_SHORT@@ zu erstellen. Das Ergebnis ist ein internes wie externes Arbeitsdokument, das nach dem Termin an Kunde und Team geht und als verbindliche Grundlage für die weitere Zusammenarbeit dient.
Zentrale Zielsetzung:
Erzeuge keine wortgetreue Wiedergabe des Gesprächs, sondern ein klar gegliedertes Ergebnisprotokoll. Im Mittelpunkt stehen Entscheidungen, Action Items mit Verantwortlichen und Fristen sowie offene Punkte. Diskussionen werden verdichtet, Smalltalk und Wiederholungen entfallen.
---
ABSOLUTE REGELN
1. Erfinde nichts. Füge keine Namen, Termine, Budgets, Tools, Plattformen, Domains, KPIs oder Zusagen hinzu, die nicht im Rohprotokoll enthalten oder daraus eindeutig ableitbar sind.
2. Bleibe streng quellentreu. Verdichte, ordne und glätte sprachlich, aber ändere keine inhaltliche Aussage.
3. Schreibe vollständig auf Deutsch.
4. Verwende niemals Markdown. Also keine Sternchen, Doppelsternchen, Rauten, Backticks oder eckigen Klammern (außer in der genau vorgegebenen Action-Item-Notation, siehe unten).
5. Gib ausschließlich den finalen Dokumenttext aus. Keine Vorbemerkung, keine Erklärung, keine Meta-Hinweise, keine Code-Blöcke.
6. Sachlicher, professioneller Ton. Sie-Anrede an den Kunden, soweit Anrede überhaupt vorkommt. Keine Werbesprache, keine Füllphrasen, keine Bewertungen.
7. Eigennamen, Zahlen, Geldbeträge, Datumsangaben, URLs und Tool-Bezeichnungen exakt bewahren.
8. Wenn ein Detail im Gespräch unklar bleibt, nimm es als offenen Punkt auf, statt etwas zu ergänzen.
---
EINGABE
Du erhältst vom Nutzer vor dem Gesprächsprotokoll drei Angaben:
Termin-Art: entweder eine konkrete Bezeichnung (z.B. "Kick-off", "Jour fixe", "Konzept-Review", "Status-Update", "Workshop", "Briefing", "Abnahme", "Strategie-Meeting") oder leer bzw. "(aus Gespräch erkennen)". Ist die Termin-Art vorgegeben, übernimm sie unverändert. Ist sie nicht vorgegeben, leite sie aus dem Gespräch ab. Gelingt das nicht eindeutig, nutze "Kundengespräch".
Projektname: entweder eine Bezeichnung des Projekts oder leer bzw. "(keiner)". Ist ein Projektname angegeben, führe ihn im Projekt-Block auf.
Meeting-Titel: ein freier Titel, der dir Orientierung geben kann, aber nicht ins Dokument gehört.
---
AUSGABEFORMAT
Die Ausgabe besteht aus zwei Teilen:
TEIL 1 — METADATEN-BLÖCKE (immer am Anfang, in genau dieser Reihenfolge, mit den exakten Markern)
===PROJEKT===
{Kunde / Unternehmen, z.B. "Müller Bau GmbH"}
{ggf. Projektname, z.B. "Relaunch Website 2026", nur wenn vorhanden}
===/PROJEKT===
===KONTAKT===
{Vorname Nachname - Funktion, alles auf einer Zeile, z.B. "Markus Müller - Geschäftsführer"}
{ggf. weitere Teilnehmer auf Kundenseite, je eine Zeile, gleiches Format "Vorname Nachname - Funktion"}
===/KONTAKT===
Wenn die Funktion nicht bekannt ist, schreibe nur den Namen ohne Trennstrich. Setze keine Anrede ("Herr"/"Frau") vor den Namen — Vor- und Nachname genügen.
Wenn im Gespräch kein Ansprechpartner identifizierbar ist, gib den Block trotzdem aus, aber mit einer einzigen Zeile dazwischen:
===KONTAKT===
(keiner)
===/KONTAKT===
===TERMINART===
{Die verwendete Termin-Art, exakt so wie in der Eingabe vorgegeben oder aus dem Gespräch erkannt}
===/TERMINART===
TEIL 2 — INHALTSABSCHNITTE (immer in dieser Reihenfolge, jeder Abschnitt mit seiner genauen Überschrift auf einer eigenen Zeile, gefolgt von einer Leerzeile und dem Abschnittsinhalt)
Anlass & Kontext
Besprochene Themen
Entscheidungen
Action Items
Offene Punkte
Nächste Schritte
---
INHALT DER EINZELNEN ABSCHNITTE
Abschnitt "Anlass & Kontext"
Knapper Absatz von 2 bis 4 Sätzen, der benennt:
- Worum es in diesem Termin ging (z.B. Kick-off, Status, Abnahme)
- Welcher Projekt- oder Phasenstand vorliegt (nur wenn aus Gespräch erkennbar)
- Welche übergeordnete Zielsetzung im Raum steht
Keine Aufzählung, sondern Fließtext. Smalltalk und Begrüßung gehören nicht hierhin.
Abschnitt "Besprochene Themen"
Bullet-Liste der inhaltlich besprochenen Themen. Jeder Punkt ist ein eigener Bullet, der mit "• " (Aufzählungspunkt + Leerzeichen) beginnt. Pro Thema 1 bis 3 Sätze, die das Wesentliche der Diskussion verdichten — also Position des Kunden, Hinweise von @@ORG_SHORT@@, Argumente, Optionen. Verzichte auf Wortlautwiedergabe und auf reine Wiederholung. Reihenfolge: thematisch sinnvoll, nicht zwingend chronologisch. Wenn mehrere Sub-Punkte zusammengehören, halte sie in einem Bullet.
Beispiel für Bullet-Stil:
• Startseiten-Hero: Kunde wünscht stärkeren Fokus auf USPs. @@ORG_SHORT@@ schlägt zweispaltiges Layout mit Slider rechts vor; Entscheidung wird nach Wireframe getroffen.
Abschnitt "Entscheidungen"
Bullet-Liste der im Termin verbindlich getroffenen Entscheidungen. Jeder Punkt beginnt mit "• ". Nur Beschlüsse aufnehmen, die im Gespräch ausdrücklich getroffen oder bestätigt wurden — keine Vermutungen, keine Empfehlungen ohne Zustimmung. Wenn keine Entscheidungen getroffen wurden, gib einen einzigen Satz aus: "In diesem Termin wurden keine verbindlichen Entscheidungen getroffen."
Abschnitt "Action Items"
Bullet-Liste der vereinbarten Aufgaben. Jeder Punkt beginnt mit "• " und folgt diesem festen Schema:
• [Verantwortlicher · Frist] Aufgabenbeschreibung im Imperativ
Erläuterungen:
- Verantwortlicher ist entweder "Kunde", "@@ORG_SHORT@@" oder ein konkreter Personenname, sofern im Gespräch genannt. Wenn unklar, nutze "offen".
- Frist ist ein konkretes Datum (TT.MM.JJJJ), eine Kalenderwoche ("KW 19"), eine relative Angabe ("bis nächste Woche") oder "offen". Bewahre die Schreibweise wie im Gespräch.
- Aufgabenbeschreibung ist ein konkreter Auftrag, kein Konzept. Schreibe "Logo-Dateien liefern" statt "Wir kümmern uns um die Logos".
Beispiele:
• [Kunde · 02.05.2026] Logo-Dateien und Brand-Guidelines im Vektorformat liefern
• [@@ORG_SHORT@@ · KW 19] Wireframes für Startseite und zwei Unterseiten erstellen
• [Markus Müller · offen] Freigabe der Domain-Liste
Wenn keine Action Items vereinbart wurden, gib einen einzigen Satz aus: "In diesem Termin wurden keine Action Items vereinbart."
Abschnitt "Offene Punkte"
Bullet-Liste aller Themen, zu denen Klärungsbedarf besteht oder die explizit vertagt wurden. Jeder Punkt beginnt mit "• ". Halte den Punkt so konkret, dass beim Folgetermin direkt daran weitergearbeitet werden kann. Wenn keine offenen Punkte vorliegen, gib einen einzigen Satz aus: "Es bestehen keine offenen Punkte."
Abschnitt "Nächste Schritte"
Knapper Absatz oder bis zu drei Bullet-Punkte. Inhalt: Folgetermin (Datum, falls vereinbart), nächster Meilenstein, anstehende Phase. Wenn nichts Konkretes vereinbart wurde, gib einen einzigen Satz aus: "Ein Folgetermin wurde nicht vereinbart."
---
FORMATREGELN
- Jede Abschnittsüberschrift steht allein in einer Zeile, exakt in der oben genannten Schreibweise.
- Nach jeder Überschrift folgt eine Leerzeile, dann der Abschnittsinhalt.
- Fließtext als sauberer, gut lesbarer Absatz.
- Bullet-Points beginnen mit "• " (Aufzählungspunkt + Leerzeichen). Keine "-", keine "*", keine Nummerierung.
- Eckige Klammern sind ausschließlich in der Action-Item-Notation [Verantwortlicher · Frist] erlaubt, sonst nirgendwo.
- Keine Tabellen, keine HTML-Ausgabe, kein Markdown.
- Keine doppelten Leerzeilen zwischen Absätzen innerhalb eines Abschnitts (höchstens eine Leerzeile).
- Zwischen zwei Abschnitten steht genau eine Leerzeile vor der nächsten Überschrift.
---
QUALITÄTSMASSSTAB
Das Ergebnis soll so wirken, als wäre es von einem erfahrenen Projektleiter von @@ORG_SHORT@@ direkt nach dem Termin redaktionell erstellt worden. Der Leser versteht innerhalb von 30 Sekunden: Worum ging es, was wurde entschieden, wer macht was bis wann, was ist offen. Keine Floskeln, keine Wiederholungen, keine erfundenen Details. Action Items sind als verbindliche Aufträge formuliert, nicht als Absichtserklärungen.

238
vendor/platform/core/docx_export.py vendored Normal file
View File

@@ -0,0 +1,238 @@
"""Renders a Word (.docx) protocol from the shared PDF context.
The *content* is 100 % identical to the PDF export: both consume the structured
dict produced by ``core.pdf.build_pdf_context`` (tenant-independent). This module
supplies the Word *layout*, kept as close as reasonably possible to the PDF
(``core/templates/protocol.html`` + ``protocol.css``):
* title + logo live in the page header (not a big body title),
* office/footer note + page numbers live in the page footer,
* section headings are filled accent bars,
* the "Erstellt durch" metadata column is right-aligned,
* body font approximates the PDF's Noto Sans / Arial stack.
Because it reuses the shared context, the Word download works for every tenant
automatically, with no per-tenant flag or template.
"""
from __future__ import annotations
import io
def _norm_hex(value: str, fallback: str) -> str:
v = str(value or "").lstrip("#")
if len(v) == 3:
v = "".join(c * 2 for c in v)
if len(v) != 6:
v = fallback.lstrip("#")
try:
int(v, 16)
except ValueError:
v = fallback.lstrip("#")
return v.upper()
def render_docx(context: dict, cfg) -> bytes:
from docx import Document
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Mm, Pt, RGBColor
colors = context.get("colors") or {}
def hx(key: str, fallback: str) -> str:
return _norm_hex(colors.get(key, fallback), fallback)
accent_hex = hx("accent", "327F24")
accent_dark_hex = hx("accent_dark", accent_hex)
accent_light_hex = hx("accent_light", "E8F0E5")
text2_hex = hx("text_2", "5A5A68")
text3_hex = hx("text_3", "8E8E9E")
def rgb(h: str) -> "RGBColor":
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
accent, accent_dark = rgb(accent_hex), rgb(accent_dark_hex)
text2, text3 = rgb(text2_hex), rgb(text3_hex)
BODY_FONT = "Arial" # PDF stack is Noto Sans → DejaVu Sans → Arial
CONTENT_W = Mm(166) # A4 (210) − 22mm left − 22mm right
doc = Document()
# ── page geometry (matches protocol.css @page margins) ──
sec = doc.sections[0]
sec.top_margin, sec.bottom_margin = Mm(26), Mm(24)
sec.left_margin, sec.right_margin = Mm(22), Mm(22)
# ── base font ──
normal = doc.styles["Normal"]
normal.font.name = BODY_FONT
normal.font.size = Pt(9.8)
normal.paragraph_format.space_after = Pt(0)
normal.paragraph_format.space_before = Pt(0)
office = context.get("office") or {}
terminart = context.get("terminart", "")
def shade(paragraph, fill_hex: str) -> None:
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), fill_hex)
paragraph._p.get_or_add_pPr().append(shd)
def field(paragraph, instr: str, size: float, italic: bool, color_hex: str) -> None:
fld = OxmlElement("w:fldSimple")
fld.set(qn("w:instr"), instr)
r = OxmlElement("w:r")
rpr = OxmlElement("w:rPr")
sz = OxmlElement("w:sz"); sz.set(qn("w:val"), str(int(size * 2))); rpr.append(sz)
if italic:
rpr.append(OxmlElement("w:i"))
col = OxmlElement("w:color"); col.set(qn("w:val"), color_hex); rpr.append(col)
r.append(rpr)
t = OxmlElement("w:t"); t.text = "1"; r.append(t)
fld.append(r)
paragraph._p.append(fld)
def right_tab_only(paragraph) -> None:
"""Give the paragraph a single right tab stop at the content edge.
Word *merges* a paragraph's tab stops with those of its style, and the
built-in Header/Footer styles carry a centre tab at half the US-Letter
text width. Without clearing it the first "\\t" stops mid-page (the logo
would look centred), so every inherited stop gets an explicit clear.
"""
pf = paragraph.paragraph_format
for ts in list(paragraph.style.paragraph_format.tab_stops):
pf.tab_stops.add_tab_stop(ts.position, WD_TAB_ALIGNMENT.CLEAR)
pf.tab_stops.add_tab_stop(CONTENT_W, WD_TAB_ALIGNMENT.RIGHT)
# ── page header: "<Terminart> | Gesprächsprotokoll" + logo right ──
hp = sec.header.paragraphs[0]
right_tab_only(hp)
hr = hp.add_run(f"{terminart} | Gesprächsprotokoll")
hr.italic = True
hr.font.size = Pt(9)
hr.font.color.rgb = text2
logo_path = getattr(cfg, "docx_logo_path", None) or getattr(cfg, "logo_path", None)
if logo_path and logo_path.suffix.lower() in (".png", ".jpg", ".jpeg"):
try:
hp.add_run("\t")
hp.add_run().add_picture(str(logo_path), height=Mm(11))
except Exception:
pass # a broken logo must never fail the whole export
# ── page footer: office / note (left, multi-line) + "Seite X / Y" (right) ──
fp = sec.footer.paragraphs[0]
right_tab_only(fp)
f1 = fp.add_run(
f"Gesprächsprotokoll · {terminart} · {context.get('date_de', '')} · "
f"{context.get('project_display', '')}"
)
f1.font.size = Pt(7); f1.font.color.rgb = text2
fp.add_run("\t")
field(fp, "PAGE", 7.5, True, text3_hex)
fpr = fp.add_run(" / "); fpr.font.size = Pt(7.5); fpr.italic = True; fpr.font.color.rgb = text3
field(fp, "NUMPAGES", 7.5, True, text3_hex)
erstellt = "Erstellt durch: " + (office.get("name") or "")
if office.get("subtitle"):
erstellt += f" {office['subtitle']}"
if office.get("tel"):
erstellt += f" • {office['tel']}"
if office.get("email"):
erstellt += f" • {office['email']}"
f2 = sec.footer.add_paragraph().add_run(erstellt)
f2.font.size = Pt(7); f2.font.color.rgb = text2
if context.get("footer_note"):
f3 = sec.footer.add_paragraph().add_run(context["footer_note"])
f3.font.size = Pt(7); f3.italic = True; f3.font.color.rgb = text2
if context.get("doc_title"):
tp = doc.add_paragraph()
tp.paragraph_format.space_after = Pt(6)
tr = tp.add_run(context["doc_title"])
tr.bold = True
tr.font.size = Pt(16)
tr.font.color.rgb = accent_dark
# ── metadata block: two columns (Projekt / Erstellt durch, right-aligned) ──
table = doc.add_table(rows=1, cols=2)
table.alignment = WD_TABLE_ALIGNMENT.LEFT
table.allow_autofit = False
left, right = table.rows[0].cells
left.width = right.width = Mm(83)
def cell_line(cell, text, *, size, color=None, bold=False, italic=False,
align=None, first=False, space_before=0.0):
if first and not cell.paragraphs[0].runs:
p = cell.paragraphs[0]
else:
p = cell.add_paragraph()
p.paragraph_format.space_after = Pt(0)
p.paragraph_format.space_before = Pt(space_before)
if align is not None:
p.alignment = align
run = p.add_run(text)
run.font.size = Pt(size)
run.bold, run.italic = bold, italic
if color is not None:
run.font.color.rgb = color
# left column
cell_line(left, context.get("projekt_label", "Projekt"), size=11, color=accent, italic=True, first=True)
for line in context.get("projekt") or []:
cell_line(left, line, size=10.2)
if context.get("kontakt"):
cell_line(left, "Ansprechpartner Kunde", size=9.5, color=accent, bold=True, space_before=3)
for line in context["kontakt"]:
cell_line(left, line, size=10.2)
# right column (right-aligned)
R = WD_ALIGN_PARAGRAPH.RIGHT
cell_line(right, "Erstellt durch", size=11, color=accent, italic=True, align=R, first=True)
if office.get("name"):
name = office["name"] + (f" {office['subtitle']}" if office.get("subtitle") else "")
cell_line(right, name, size=10.2, align=R)
for key in ("address", "tel", "email", "website"):
for line in str(office.get(key) or "").splitlines(): # multi-line address
cell_line(right, line, size=10.2, align=R)
doc.add_paragraph().paragraph_format.space_after = Pt(6) # gap before sections
# ── content sections: filled accent bar + indented body ──
for section in context.get("sections") or []:
bar = doc.add_paragraph()
bpf = bar.paragraph_format
bpf.space_before = Pt(10)
bpf.space_after = Pt(4)
bpf.left_indent = Mm(1.5)
bpf.right_indent = Mm(1.5)
bpf.keep_with_next = True
bpf.line_spacing = 1.3
shade(bar, accent_light_hex)
b_run = bar.add_run(section.get("heading", ""))
b_run.bold = True
b_run.font.size = Pt(10)
b_run.font.color.rgb = accent_dark
for block in section.get("blocks") or []:
if block.get("type") == "paragraph":
p = doc.add_paragraph(block.get("text", ""))
p.paragraph_format.left_indent = Mm(6)
p.paragraph_format.space_after = Pt(5)
p.paragraph_format.line_spacing = 1.4
elif block.get("type") == "list":
for item in block.get("items") or []:
p = doc.add_paragraph(item, style="List Bullet")
p.paragraph_format.left_indent = Mm(10)
p.paragraph_format.space_after = Pt(2)
p.paragraph_format.line_spacing = 1.4
buffer = io.BytesIO()
doc.save(buffer)
return buffer.getvalue()

64
vendor/platform/core/hooks.py vendored Normal file
View File

@@ -0,0 +1,64 @@
"""Per-tenant code hooks.
Most customers differ only in *data* (colours, logo, prompt, PDF template) and
need no code here. For the few that need genuine behaviour changes, drop an
`overrides.py` into their tenant folder defining a `Hooks(TenantHooks)` subclass
that overrides only the methods it needs. Core stays untouched, so core updates
keep flowing.
Example tenants/acme/overrides.py:
from core.hooks import TenantHooks
class Hooks(TenantHooks):
def build_prompt(self, base, ctx):
return base + "\\nBevorzuge Fachterminologie aus der Orthopädie."
def extra_routes(self, app):
@app.get("/health/acme")
def _h():
return {"ok": True}
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from core.contract import HOOK_API
class TenantHooks:
"""Default (no-op) behaviour. Override selectively per tenant."""
def build_prompt(self, base_prompt: str, ctx: dict) -> str:
"""Adjust the system prompt sent to the LLM. ctx carries the form fields."""
return base_prompt
def transform_transcript(self, transcript: str, ctx: dict) -> str:
"""Post-process the transcript before summarisation/delivery."""
return transcript
def pdf_context(self, context: dict) -> dict:
"""Last chance to enrich/alter the data passed to the PDF template."""
return context
def extra_routes(self, app) -> None:
"""Register tenant-specific FastAPI routes."""
return None
def load_hooks(tenant_dir: Path) -> TenantHooks:
override = tenant_dir / "overrides.py"
if not override.exists():
return TenantHooks()
spec = importlib.util.spec_from_file_location(f"tenant_overrides_{tenant_dir.name}", override)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
declared = getattr(module, "HOOK_API", HOOK_API) # fehlt die Deklaration, gilt die aktuelle Version
if declared != HOOK_API:
raise RuntimeError(f"{override}: HOOK_API={declared} wird von diesem Core nicht unterstützt (Core: {HOOK_API})")
hooks_cls = getattr(module, "Hooks", None)
if hooks_cls is None:
return TenantHooks()
return hooks_cls()

4
vendor/platform/core/pdf/__init__.py vendored Normal file
View File

@@ -0,0 +1,4 @@
from core.pdf.context import build_pdf_context
from core.pdf.render import render_pdf
__all__ = ["build_pdf_context", "render_pdf"]

92
vendor/platform/core/pdf/context.py vendored Normal file
View File

@@ -0,0 +1,92 @@
"""Builds the structured data the PDF template renders.
This is the *content* half of the PDF — purely data, identical logic for every
tenant. The *layout* half lives in the tenant's (or core's) protocol.html +
protocol.css. Core guarantees the shape of this dict; templates may ignore any
field they don't use, so adding fields here never breaks an old template.
"""
from __future__ import annotations
import base64
from datetime import datetime
from pathlib import Path
from core.protocol import (
group_section_blocks,
normalize_protocol_text,
parse_address_lines,
parse_protocol_sections,
project_label,
)
def _logo_data_uri(logo_path: Path | None) -> str | None:
if not logo_path or not logo_path.exists():
return None
mime = "image/svg+xml" if logo_path.suffix.lower() == ".svg" else "image/png"
data = base64.b64encode(logo_path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{data}"
def build_pdf_context(
cfg,
title: str,
summary: str,
insurance_type: str = "",
customer_2: str = "",
customer_name: str = "",
customer_address: str = "",
) -> dict:
clean = normalize_protocol_text(summary)
parsed = parse_protocol_sections(clean)
terminart = parsed["terminart"] or insurance_type.strip() or "Kundengespräch"
projekt: list[str] = []
if parsed["projekt"]:
projekt = parsed["projekt"]
else:
if customer_name.strip():
projekt.append(customer_name.strip())
if customer_2.strip():
projekt.append(customer_2.strip())
if not projekt:
projekt = ["Projekt"]
if parsed["kontakt"]:
kontakt = parsed["kontakt"]
elif customer_address.strip():
kontakt = parse_address_lines(customer_address)
else:
kontakt = []
sections = [
{"heading": s["heading"], "blocks": group_section_blocks(s)}
for s in parsed["sections"]
]
project_display = project_label(projekt)
date_de = datetime.now().strftime("%d.%m.%Y")
office = cfg.office
return {
"tenant": {"name": cfg.name, "id": cfg.id},
"colors": cfg.colors,
"logo_data_uri": _logo_data_uri(cfg.logo_path),
"title": title,
"terminart": terminart,
"projekt": projekt,
"projekt_label": cfg.raw.get("pdf_projekt_label", "Projekt"),
"project_display": project_display,
"kontakt": kontakt,
"office": office,
"sections": sections,
"date_de": date_de,
# {year} keeps a copyright line in the footer note current without a
# yearly config edit.
"footer_note": cfg.raw.get(
"pdf_footer_note",
"Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich "
"für die genannten Empfänger bestimmt.",
).replace("{year}", str(datetime.now().year)),
}

33
vendor/platform/core/pdf/render.py vendored Normal file
View File

@@ -0,0 +1,33 @@
"""Renders the PDF from an HTML+CSS template via WeasyPrint.
The template + stylesheet are resolved per tenant (tenant override → core
default) in core/config.py. A designer can fully restyle the PDF by editing
those two text files — no Python involved.
"""
from __future__ import annotations
from jinja2 import Environment, FileSystemLoader, select_autoescape
from core.config import CORE_TEMPLATES
def render_pdf(context: dict, cfg) -> bytes:
from weasyprint import CSS, HTML # imported lazily: only needed for PDF
template_path = cfg.pdf_template_path
css_path = cfg.pdf_css_path
env = Environment(
loader=FileSystemLoader([str(template_path.parent), str(CORE_TEMPLATES)]),
autoescape=select_autoescape(["html", "xml"]),
)
template = env.get_template(template_path.name)
html_str = template.render(**context)
# CSS is rendered through Jinja too, so colour tokens can be injected.
css_env = Environment(loader=FileSystemLoader([str(css_path.parent)]))
css_str = css_env.get_template(css_path.name).render(**context)
base_url = str(template_path.parent)
doc = HTML(string=html_str, base_url=base_url)
return doc.write_pdf(stylesheets=[CSS(string=css_str)])

160
vendor/platform/core/protocol.py vendored Normal file
View File

@@ -0,0 +1,160 @@
"""Parsing/normalisation of the Claude protocol output.
Tenant-independent: turns the raw LLM text into a structured shape that both
the PDF template and the e-mail rendering consume.
"""
from __future__ import annotations
import re
SECTION_HEADINGS = (
"Anlass & Kontext",
"Besprochene Themen",
"Entscheidungen",
"Action Items",
"Offene Punkte",
"Nächste Schritte",
)
def normalize_protocol_text(text: str) -> str:
normalized_lines = []
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw_line.rstrip()
if line.startswith("===") and line.endswith("==="):
normalized_lines.append(line.strip())
continue
stripped = line.strip()
if not stripped:
normalized_lines.append("")
continue
cleaned = re.sub(r"^\s{0,3}#{1,6}\s*", "", stripped)
cleaned = cleaned.replace("**", "").replace("__", "")
cleaned = re.sub(r"^\s*[-*]\s+", "• ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
normalized_lines.append(cleaned)
text = "\n".join(normalized_lines)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text
def parse_protocol_sections(text: str) -> dict:
"""Zerlegt den Claude-Output in Metadaten-Blöcke und Abschnitte."""
lines = text.replace("\r\n", "\n").split("\n")
def collect_block(marker: str) -> list[str] | None:
open_tag = f"==={marker}==="
close_tag = f"===/{marker}==="
try:
start = lines.index(open_tag)
end = lines.index(close_tag, start + 1)
except ValueError:
return None
block = [ln.strip() for ln in lines[start + 1:end] if ln.strip()]
return block
projekt = collect_block("PROJEKT") or []
kontakt = collect_block("KONTAKT") or []
terminart_block = collect_block("TERMINART") or []
terminart = terminart_block[0] if terminart_block else ""
if len(kontakt) == 1 and kontakt[0].lower().strip("() ") in {"keiner", "keine", "none", ""}:
kontakt = []
body_lines: list[str] = []
skip = False
for ln in lines:
stripped = ln.strip()
if stripped.startswith("===") and stripped.endswith("==="):
if stripped.startswith("===/"):
skip = False
continue
skip = True
continue
if skip:
continue
body_lines.append(ln)
sections: list[dict] = []
current = None
for raw in body_lines:
line = raw.strip()
if line in SECTION_HEADINGS:
if current is not None:
sections.append(current)
current = {"heading": line, "lines": []}
continue
if current is None:
continue
if line.startswith("• "):
current["lines"].append(("bullet", line[2:].strip()))
elif line:
current["lines"].append(("paragraph", line))
else:
current["lines"].append(("blank", ""))
if current is not None:
sections.append(current)
return {
"projekt": projekt,
"kontakt": kontakt,
"terminart": terminart,
"sections": sections,
}
def parse_address_lines(address: str) -> list[str]:
"""Splits a free-form address string into [street, PLZ city] lines."""
address = address.strip()
if not address:
return []
if "\n" in address:
return [l.strip() for l in address.splitlines() if l.strip()]
if "," in address:
return [p.strip() for p in address.split(",") if p.strip()]
m = re.search(r"(\b\d{5}\b.*)", address)
if m:
before = address[: m.start()].strip().rstrip(",").strip()
plz_city = m.group(1).strip()
return ([before] if before else []) + [plz_city]
return [address]
def project_label(projekt_lines: list[str]) -> str:
"""Erste nicht-leere Zeile des PROJEKT-Blocks (für Dateinamen/Subject)."""
for line in projekt_lines:
if line and line.strip():
return line.strip()
return "Projekt"
def safe_filename(title: str) -> str:
safe = re.sub(r"[^\w\-äöüÄÖÜß ]", "", title).strip()
safe = re.sub(r"\s+", "_", safe)
return safe or "Dokument"
def group_section_blocks(section: dict) -> list[dict]:
"""Turn the flat (kind, text) line list into HTML-friendly blocks:
consecutive bullets become one {'type':'list','items':[...]}, paragraphs
become {'type':'paragraph','text':...}."""
blocks: list[dict] = []
bullets: list[str] = []
def flush_bullets():
if bullets:
blocks.append({"type": "list", "items": list(bullets)})
bullets.clear()
for kind, text in section.get("lines", []):
if kind == "bullet":
bullets.append(text)
elif kind == "paragraph":
flush_bullets()
blocks.append({"type": "paragraph", "text": text})
# blank lines are visual only — ignored in the structured shape
flush_bullets()
return blocks

100
vendor/platform/core/retry.py vendored Normal file
View File

@@ -0,0 +1,100 @@
"""Lightweight retry helpers for transient provider failures.
The transcription (Mistral) and e-mail (SMTP) call sites had no retry at all, so
a brief provider outage aborted the whole job. These helpers add exponential
backoff with jitter for *transient* failures only — network errors, timeouts and
5xx/429 — while failing fast on permanent errors (bad request, auth, payload),
where retrying cannot help.
The OpenAI SDK used for Cortecs already retries internally (configured via
LLM_MAX_RETRIES at the client); this module covers the two raw call sites.
All knobs are env-tunable so they can be adjusted without a redeploy:
RETRY_MAX_ATTEMPTS total attempts incl. the first (default 4)
RETRY_BASE_DELAY base backoff in seconds (default 1.0)
RETRY_MAX_DELAY cap per wait in seconds (default 30.0)
"""
from __future__ import annotations
import os
import random
import time
import requests
RETRY_MAX_ATTEMPTS = int(os.getenv("RETRY_MAX_ATTEMPTS", "4"))
RETRY_BASE_DELAY = float(os.getenv("RETRY_BASE_DELAY", "1.0"))
RETRY_MAX_DELAY = float(os.getenv("RETRY_MAX_DELAY", "30.0"))
# Transient HTTP statuses — worth retrying.
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
# Network-level failures before any HTTP response arrives.
RETRYABLE_HTTP_EXC = (requests.exceptions.ConnectionError, requests.exceptions.Timeout)
def _backoff_delay(attempt: int, retry_after: float | None) -> float:
"""Seconds to wait before the next attempt (0-indexed)."""
if retry_after is not None:
return min(retry_after, RETRY_MAX_DELAY)
# exponential backoff with full jitter
ceiling = min(RETRY_BASE_DELAY * (2 ** attempt), RETRY_MAX_DELAY)
return random.uniform(0, ceiling)
def _retry_after_seconds(resp: requests.Response) -> float | None:
val = resp.headers.get("Retry-After")
if not val:
return None
try:
return float(val) # delta-seconds form
except ValueError:
return None # HTTP-date form — fall back to backoff
def request_with_retry(do_request, *, label: str = "request") -> requests.Response:
"""Call ``do_request()`` (a zero-arg callable returning a ``requests.Response``)
with retries on transient network errors and 5xx/429.
The caller still inspects the returned response for non-retryable ``>= 400``
statuses. ``do_request`` must rebuild any per-attempt state (e.g. re-open the
upload file) so each retry sends a complete request.
"""
for attempt in range(RETRY_MAX_ATTEMPTS):
last = attempt == RETRY_MAX_ATTEMPTS - 1
try:
resp = do_request()
except RETRYABLE_HTTP_EXC as exc:
if last:
raise
delay = _backoff_delay(attempt, None)
print(f"[retry] {label}: {type(exc).__name__} "
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
time.sleep(delay)
continue
if resp.status_code in RETRYABLE_STATUS and not last:
delay = _backoff_delay(attempt, _retry_after_seconds(resp))
print(f"[retry] {label}: HTTP {resp.status_code} "
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
try:
resp.close()
except Exception:
pass
time.sleep(delay)
continue
return resp
# Unreachable: the loop either returns a response or raises on the last attempt.
raise RuntimeError(f"{label}: retry loop ended without a result")
def call_with_retry(fn, *, retry_on: tuple[type[BaseException], ...], label: str = "call"):
"""Retry an arbitrary callable on the given (transient) exception types."""
for attempt in range(RETRY_MAX_ATTEMPTS):
try:
return fn()
except retry_on as exc:
if attempt == RETRY_MAX_ATTEMPTS - 1:
raise
delay = _backoff_delay(attempt, None)
print(f"[retry] {label}: {type(exc).__name__} "
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
time.sleep(delay)

View File

@@ -0,0 +1,928 @@
{
"contract": 1,
"description": "Tenant-Schema des Onyscribe-Cores. Quelle der Wahrheit für Admin-Plattform (Formulare, Validierung, Deploy-Env).",
"ignored_keys": [
"deploy"
],
"fields": [
{
"path": "name",
"type": "string",
"group": "Marke",
"label": "Name",
"help": "Anzeigename des Kunden (Web-UI, E-Mail, PDF-Fallback).",
"default": null,
"since": 1,
"required": true
},
{
"path": "title",
"type": "string",
"group": "Marke",
"label": "Seitentitel (Browser-Tab)",
"help": "Standard: „<Name> – Gesprächsprotokoll“.",
"default": null,
"since": 1
},
{
"path": "description",
"type": "string",
"group": "Marke",
"label": "Beschreibung",
"help": "Meta-Beschreibung der Web-UI. Standard: „Gesprächsprotokoll-Tool für <Name>“.",
"default": null,
"since": 1
},
{
"path": "colors.accent",
"type": "color",
"group": "Farben",
"label": "Akzent",
"help": "",
"default": "#327F24",
"since": 1
},
{
"path": "colors.accent_dark",
"type": "color",
"group": "Farben",
"label": "Akzent dunkel",
"help": "",
"default": "#235A19",
"since": 1
},
{
"path": "colors.accent_mid",
"type": "color",
"group": "Farben",
"label": "Akzent mittel",
"help": "",
"default": "#3E9130",
"since": 1
},
{
"path": "colors.accent_light",
"type": "color",
"group": "Farben",
"label": "Akzent hell",
"help": "",
"default": "#EAF4E7",
"since": 1
},
{
"path": "colors.accent_xlight",
"type": "color",
"group": "Farben",
"label": "Akzent extra hell",
"help": "",
"default": "#F3FAF1",
"since": 1
},
{
"path": "colors.bg",
"type": "color",
"group": "Farben",
"label": "Seitenhintergrund",
"help": "",
"default": "#F1F5F0",
"since": 1
},
{
"path": "colors.surface",
"type": "color",
"group": "Farben",
"label": "Karten",
"help": "",
"default": "#FFFFFF",
"since": 1
},
{
"path": "colors.surface2",
"type": "color",
"group": "Farben",
"label": "Karten 2",
"help": "",
"default": "#F6FAF5",
"since": 1
},
{
"path": "colors.border",
"type": "color",
"group": "Farben",
"label": "Rahmen",
"help": "",
"default": "#DCE8D9",
"since": 1
},
{
"path": "colors.border2",
"type": "color",
"group": "Farben",
"label": "Rahmen 2",
"help": "",
"default": "#BDD1B8",
"since": 1
},
{
"path": "colors.text",
"type": "color",
"group": "Farben",
"label": "Text",
"help": "",
"default": "#444444",
"since": 1
},
{
"path": "colors.text_2",
"type": "color",
"group": "Farben",
"label": "Text sekundär",
"help": "",
"default": "#666666",
"since": 1
},
{
"path": "colors.text_3",
"type": "color",
"group": "Farben",
"label": "Text tertiär",
"help": "",
"default": "#9AA89A",
"since": 1
},
{
"path": "colors.red",
"type": "color",
"group": "Farben",
"label": "Rot (Hinweis)",
"help": "",
"default": "#DC2626",
"since": 1
},
{
"path": "colors.progress_to",
"type": "color",
"group": "Farben",
"label": "Fortschritt (Verlauf-Ende)",
"help": "",
"default": "#8bbf43",
"since": 1
},
{
"path": "office.name",
"type": "string",
"group": "Absender",
"label": "Name",
"help": "Kopf-/Fußzeile von PDF, Word und E-Mail.",
"default": "",
"since": 1
},
{
"path": "office.subtitle",
"type": "string",
"group": "Absender",
"label": "Zusatz (Rechtsform)",
"help": "",
"default": "",
"since": 1
},
{
"path": "office.address",
"type": "text",
"group": "Absender",
"label": "Adresse",
"help": "Mehrzeilig möglich.",
"default": "",
"since": 1
},
{
"path": "office.tel",
"type": "string",
"group": "Absender",
"label": "Telefon",
"help": "",
"default": "",
"since": 1
},
{
"path": "office.email",
"type": "string",
"group": "Absender",
"label": "E-Mail",
"help": "",
"default": "",
"since": 1
},
{
"path": "office.website",
"type": "string",
"group": "Absender",
"label": "Website",
"help": "",
"default": "",
"since": 1
},
{
"path": "pdf_projekt_label",
"type": "string",
"group": "PDF",
"label": "Bezeichnung „Projekt“",
"help": "Beschriftung des Projekt-Feldes im PDF (z. B. „Thema“).",
"default": "Projekt",
"since": 1
},
{
"path": "pdf_footer_note",
"type": "text",
"group": "PDF",
"label": "Fußzeilen-Hinweis",
"help": "Platzhalter {year} = aktuelles Jahr.",
"default": "Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich für die genannten Empfänger bestimmt.",
"since": 1
},
{
"path": "ai_detect_org",
"type": "string",
"group": "Prompt-Identität",
"label": "Organisation (voll)",
"help": "Ersetzt @@ORG@@ im Standard-Prompt. Standard: Name.",
"default": null,
"since": 1
},
{
"path": "org_short",
"type": "string",
"group": "Prompt-Identität",
"label": "Kurzname im Protokolltext",
"help": "Ersetzt @@ORG_SHORT@@. Standard: Name.",
"default": null,
"since": 1
},
{
"path": "org_profile",
"type": "text",
"group": "Prompt-Identität",
"label": "Profil (ein Satz)",
"help": "Ersetzt @@ORG_PROFILE@@ – was die Organisation macht.",
"default": "",
"since": 1
},
{
"path": "flags.ui_kontakt_field",
"type": "bool",
"group": "Funktion",
"label": "Feld „Ansprechpartner Kunde“",
"help": "Aus: Feld in der Web-UI ausgeblendet (z. B. bei Vereinen).",
"default": true,
"since": 1
},
{
"path": "flags.multi_audio",
"type": "bool",
"group": "Funktion",
"label": "Mehrere Audiodateien (bis 5)",
"help": "Aus: nur eine Datei pro Protokoll.",
"default": true,
"since": 1
},
{
"path": "stt.provider",
"type": "enum",
"group": "Spracherkennung",
"label": "Provider",
"help": "Env STT_PROVIDER überschreibt. Bei „cortecs“ genügt der geteilte CORTECS_API_KEY.",
"default": "mistral",
"since": 1,
"options": [
"mistral",
"cortecs"
]
},
{
"path": "stt.model",
"type": "string",
"group": "Spracherkennung",
"label": "Modell",
"help": "Env STT_MODEL überschreibt. Standard je Provider.",
"default": null,
"since": 1
},
{
"path": "stt.cost_per_minute",
"type": "number",
"group": "Spracherkennung",
"label": "Kosten pro Minute (EUR)",
"help": "Nur für die Kostenerfassung; Standard je Provider.",
"default": null,
"since": 1,
"advanced": true
}
],
"files": [
{
"path": "prompt.txt",
"kind": "text",
"label": "Prompt",
"default": "defaults/prompt.txt",
"help": "Platzhalter @@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@. Ohne Datei gilt der Standard."
},
{
"path": "protocol.html",
"kind": "text",
"label": "PDF-Layout (HTML/Jinja)",
"default": "templates/protocol.html"
},
{
"path": "protocol.css",
"kind": "text",
"label": "PDF-Styling (CSS/Jinja)",
"default": "templates/protocol.css"
},
{
"path": "overrides.py",
"kind": "code",
"label": "Hook-Code",
"help": "Klasse Hooks(TenantHooks); Hooks siehe „hooks“. Optional HOOK_API = 1."
},
{
"path": "logo.svg",
"kind": "image",
"label": "Logo (SVG)",
"help": "Web-UI und PDF."
},
{
"path": "logo.png",
"kind": "image",
"label": "Logo (PNG)",
"help": "Word-Export braucht ein Rasterbild."
},
{
"path": "favicon.svg",
"kind": "image",
"label": "Favicon (SVG)"
}
],
"extra_files": true,
"hooks": [
{
"name": "build_prompt",
"help": "Prompt vor dem LLM-Aufruf anpassen (base_prompt, ctx)."
},
{
"name": "transform_transcript",
"help": "Transkript nachbearbeiten."
},
{
"name": "pdf_context",
"help": "Daten fürs PDF-Template ändern."
},
{
"name": "extra_routes",
"help": "Zusätzliche FastAPI-Routen registrieren."
}
],
"prompt_placeholders": [
"@@ORG@@",
"@@ORG_SHORT@@",
"@@ORG_PROFILE@@"
],
"env": [
{
"name": "OIDC_ISSUER",
"kind": "setting",
"scope": "shared",
"label": "OIDC-Issuer",
"help": "Login-System der Tenant-Container.",
"default": "https://login.onyva.dev",
"group": "Login",
"setting": "oidc_issuer"
},
{
"name": "AUTH_ENABLED",
"kind": "setting",
"scope": "shared",
"label": "Login aktiv",
"help": "Aus nur für lokale Entwicklung.",
"default": "true",
"group": "Login",
"setting": "auth_enabled",
"type": "bool"
},
{
"name": "APP_COOKIE_SECURE",
"kind": "setting",
"scope": "shared",
"label": "Cookies „secure“",
"help": "Hinter HTTPS immer true.",
"default": "false",
"group": "Login",
"setting": "app_cookie_secure",
"type": "bool"
},
{
"name": "MAX_FILE_SIZE_MB",
"kind": "setting",
"scope": "shared",
"label": "Max. Dateigröße (MB)",
"help": "",
"default": "300",
"group": "Limits",
"setting": "max_file_size_mb",
"type": "int"
},
{
"name": "STT_MAX_AUDIO_MINUTES",
"kind": "setting",
"scope": "shared",
"label": "Max. Audiolänge (Minuten)",
"help": "Grenze von Voxtral Mini Transcribe V2: 180.",
"default": "180",
"group": "Limits",
"setting": "stt_max_audio_minutes",
"type": "int"
},
{
"name": "LLM_MODEL",
"kind": "setting",
"scope": "shared",
"label": "Protokoll-Modell",
"help": "Modell-ID bei Cortecs.",
"default": "claude-4-6-sonnet",
"group": "LLM",
"setting": "llm_model"
},
{
"name": "LLM_MAX_TOKENS",
"kind": "setting",
"scope": "shared",
"label": "Max. Ausgabe-Tokens",
"help": "",
"default": "16000",
"group": "LLM",
"setting": "llm_max_tokens",
"type": "int"
},
{
"name": "LLM_TEMPERATURE",
"kind": "setting",
"scope": "shared",
"label": "Temperatur",
"help": "",
"default": "0.2",
"group": "LLM",
"setting": "llm_temperature",
"type": "float"
},
{
"name": "LLM_MAX_RETRIES",
"kind": "setting",
"scope": "shared",
"label": "Wiederholungen (SDK)",
"help": "",
"default": "4",
"group": "LLM",
"setting": "llm_max_retries",
"type": "int",
"advanced": true
},
{
"name": "LLM_TIMEOUT",
"kind": "setting",
"scope": "shared",
"label": "Timeout (s)",
"help": "",
"default": "600",
"group": "LLM",
"setting": "llm_timeout",
"type": "int",
"advanced": true
},
{
"name": "CORTECS_BASE_URL",
"kind": "setting",
"scope": "shared",
"label": "Cortecs-Basis-URL",
"help": "",
"default": "https://api.cortecs.ai/v1",
"group": "LLM",
"setting": "cortecs_base_url"
},
{
"name": "STT_PROVIDER",
"kind": "setting",
"scope": "shared",
"label": "STT-Provider (global)",
"help": "Überschreibt tenant.json; leer = je Tenant.",
"default": null,
"group": "Spracherkennung",
"setting": "stt_provider",
"advanced": true
},
{
"name": "STT_MODEL",
"kind": "setting",
"scope": "shared",
"label": "STT-Modell (global)",
"help": "Überschreibt tenant.json; leer = je Tenant.",
"default": null,
"group": "Spracherkennung",
"setting": "stt_model",
"advanced": true
},
{
"name": "STT_DIARIZE",
"kind": "setting",
"scope": "shared",
"label": "Sprechererkennung",
"help": "",
"default": "true",
"group": "Spracherkennung",
"setting": "stt_diarize",
"type": "bool"
},
{
"name": "STT_TIMEOUT",
"kind": "setting",
"scope": "shared",
"label": "STT-Timeout (s)",
"help": "Wächst zusätzlich mit der Audiolänge.",
"default": "600",
"group": "Spracherkennung",
"setting": "stt_timeout",
"type": "int"
},
{
"name": "MISTRAL_TRANSCRIBE_MODEL",
"kind": "setting",
"scope": "shared",
"label": "Mistral-Modell (Legacy)",
"help": "Wirkt nur für Tenants mit Provider mistral.",
"default": "voxtral-mini-latest",
"group": "Spracherkennung",
"setting": "mistral_transcribe_model",
"advanced": true
},
{
"name": "MISTRAL_DIARIZE",
"kind": "setting",
"scope": "shared",
"label": "Mistral Diarize (Legacy)",
"help": "",
"default": "true",
"group": "Spracherkennung",
"setting": "mistral_diarize",
"type": "bool",
"advanced": true
},
{
"name": "MISTRAL_TIMEOUT",
"kind": "setting",
"scope": "shared",
"label": "Mistral-Timeout (Legacy)",
"help": "",
"default": "600",
"group": "Spracherkennung",
"setting": "mistral_timeout",
"type": "int",
"advanced": true
},
{
"name": "SMTP_HOST",
"kind": "setting",
"scope": "shared",
"label": "SMTP-Host",
"help": "",
"default": "",
"group": "E-Mail",
"setting": "smtp_host"
},
{
"name": "SMTP_PORT",
"kind": "setting",
"scope": "shared",
"label": "SMTP-Port",
"help": "",
"default": "587",
"group": "E-Mail",
"setting": "smtp_port",
"type": "int"
},
{
"name": "SMTP_USER",
"kind": "setting",
"scope": "shared",
"label": "SMTP-Benutzer",
"help": "",
"default": "",
"group": "E-Mail",
"setting": "smtp_user"
},
{
"name": "SMTP_FROM",
"kind": "setting",
"scope": "shared",
"label": "Absendername",
"help": "",
"default": "",
"group": "E-Mail",
"setting": "smtp_from"
},
{
"name": "COST_API_BASE",
"kind": "setting",
"scope": "shared",
"label": "Cost-API-Basis",
"help": "Standard: <OIDC_ISSUER>/api/cost.",
"default": "",
"group": "Kosten",
"setting": "cost_api_base"
},
{
"name": "COST_TRACKING_ENABLED",
"kind": "setting",
"scope": "shared",
"label": "Kostenerfassung aktiv",
"help": "",
"default": "true",
"group": "Kosten",
"setting": "cost_tracking_enabled",
"type": "bool"
},
{
"name": "COST_LLM_INPUT_PER_MTOK",
"kind": "setting",
"scope": "shared",
"label": "LLM-Preis Eingabe (EUR / Mio. Tokens)",
"help": "",
"default": "2.87",
"group": "Kosten",
"setting": "cost_llm_input_per_mtok",
"type": "float",
"advanced": true
},
{
"name": "COST_LLM_OUTPUT_PER_MTOK",
"kind": "setting",
"scope": "shared",
"label": "LLM-Preis Ausgabe (EUR / Mio. Tokens)",
"help": "",
"default": "14.31",
"group": "Kosten",
"setting": "cost_llm_output_per_mtok",
"type": "float",
"advanced": true
},
{
"name": "COST_STT_PER_MINUTE",
"kind": "setting",
"scope": "shared",
"label": "STT-Preis (EUR / Minute, global)",
"help": "Nicht global setzen, wenn Tenants verschiedene Provider nutzen.",
"default": null,
"group": "Kosten",
"setting": "cost_stt_per_minute",
"type": "float",
"advanced": true
},
{
"name": "COST_FLUSH_INTERVAL",
"kind": "setting",
"scope": "shared",
"label": "Cost-Flush-Intervall (s)",
"help": "",
"default": "5",
"group": "Kosten",
"setting": "cost_flush_interval",
"type": "float",
"advanced": true
},
{
"name": "COST_HTTP_TIMEOUT",
"kind": "setting",
"scope": "shared",
"label": "Cost-HTTP-Timeout (s)",
"help": "",
"default": "5",
"group": "Kosten",
"setting": "cost_http_timeout",
"type": "float",
"advanced": true
},
{
"name": "RETRY_MAX_ATTEMPTS",
"kind": "setting",
"scope": "shared",
"label": "Retry: Versuche",
"help": "",
"default": "4",
"group": "Robustheit",
"setting": "retry_max_attempts",
"type": "int",
"advanced": true
},
{
"name": "RETRY_BASE_DELAY",
"kind": "setting",
"scope": "shared",
"label": "Retry: Basis-Wartezeit (s)",
"help": "",
"default": "1.0",
"group": "Robustheit",
"setting": "retry_base_delay",
"type": "float",
"advanced": true
},
{
"name": "RETRY_MAX_DELAY",
"kind": "setting",
"scope": "shared",
"label": "Retry: maximale Wartezeit (s)",
"help": "",
"default": "30.0",
"group": "Robustheit",
"setting": "retry_max_delay",
"type": "float",
"advanced": true
},
{
"name": "CORTECS_API_KEY",
"kind": "secret",
"scope": "shared",
"label": "Cortecs-API-Key",
"help": "LLM und (bei Provider cortecs) STT.",
"default": null,
"group": "Secrets",
"setting": "cortecs_api_key",
"required": true
},
{
"name": "SMTP_PASSWORD",
"kind": "secret",
"scope": "shared",
"label": "SMTP-Passwort",
"help": "",
"default": null,
"group": "Secrets",
"setting": "smtp_password"
},
{
"name": "MISTRAL_API_KEY",
"kind": "secret",
"scope": "tenant",
"label": "Mistral-API-Key",
"help": "Nur bei STT-Provider mistral.",
"default": null,
"group": "Secrets",
"setting": "mistral_api_key",
"required_if": "stt.provider!=cortecs"
},
{
"name": "OIDC_CLIENT_ID",
"kind": "secret",
"scope": "tenant",
"label": "OIDC-Client-ID",
"help": "Wird vom Admin über die Login-API verwaltet.",
"default": null,
"group": "Secrets",
"setting": "oidc_client_id",
"required": true
},
{
"name": "OIDC_CLIENT_SECRET",
"kind": "secret",
"scope": "tenant",
"label": "OIDC-Client-Secret",
"help": "",
"default": null,
"group": "Secrets",
"setting": "oidc_client_secret",
"required": true
},
{
"name": "SESSION_SECRET",
"kind": "secret",
"scope": "tenant",
"label": "Session-Secret",
"help": "Signiert Session-Cookies.",
"default": null,
"group": "Secrets",
"setting": "session_secret",
"required": true
},
{
"name": "COST_INGEST_KEY",
"kind": "secret",
"scope": "tenant",
"label": "Cost-Ingest-Key",
"help": "Schlüssel für die Kostenerfassung.",
"default": null,
"group": "Secrets",
"setting": "cost_ingest_key",
"required": true
},
{
"name": "TENANT",
"kind": "runtime",
"scope": "tenant",
"label": "Tenant-ID",
"help": "= tenant.id",
"default": null,
"group": "Laufzeit",
"setting": "tenant"
},
{
"name": "DATA_DIR",
"kind": "runtime",
"scope": "tenant",
"label": "Datenverzeichnis",
"help": "Volume des Containers.",
"default": "/data",
"group": "Laufzeit",
"setting": "data_dir"
},
{
"name": "PORT",
"kind": "runtime",
"scope": "tenant",
"label": "Port",
"help": "",
"default": "8276",
"group": "Laufzeit",
"setting": "port"
},
{
"name": "OIDC_REDIRECT_URI",
"kind": "runtime",
"scope": "tenant",
"label": "OIDC-Redirect-URI",
"help": "= https://<host>/auth/callback",
"default": null,
"group": "Laufzeit",
"setting": "oidc_redirect_uri"
},
{
"name": "OIDC_LOGOUT_REDIRECT_URI",
"kind": "runtime",
"scope": "tenant",
"label": "OIDC-Logout-Ziel",
"help": "= https://<host>/",
"default": null,
"group": "Laufzeit",
"setting": "oidc_logout_redirect_uri"
},
{
"name": "TENANT_SOURCE",
"kind": "runtime",
"scope": "tenant",
"label": "Quelle der Tenant-Daten",
"help": "git = ins Image gebacken (Legacy) · admin = Bundle der Admin-Plattform.",
"default": "git",
"group": "Laufzeit",
"setting": "tenant_source"
},
{
"name": "ADMIN_URL",
"kind": "runtime",
"scope": "tenant",
"label": "URL der Admin-Plattform",
"help": "Nur bei TENANT_SOURCE=admin.",
"default": null,
"group": "Laufzeit",
"setting": "admin_url"
},
{
"name": "ADMIN_BUNDLE_TOKEN",
"kind": "runtime",
"scope": "tenant",
"label": "Bundle-Token",
"help": "Pro Tenant; nur bei TENANT_SOURCE=admin.",
"default": null,
"group": "Laufzeit",
"setting": "admin_bundle_token",
"secret": true
},
{
"name": "BUNDLE_CACHE_DIR",
"kind": "runtime",
"scope": "tenant",
"label": "Bundle-Cache",
"help": "Standard: <DATA_DIR>/bundle.",
"default": null,
"group": "Laufzeit",
"setting": "bundle_cache_dir",
"advanced": true
},
{
"name": "BUNDLE_FALLBACK_BAKED",
"kind": "runtime",
"scope": "tenant",
"label": "Rückfall auf eingebackenen Tenant",
"help": "Nur Migrationszeit; danach false.",
"default": "true",
"group": "Laufzeit",
"setting": "bundle_fallback_baked",
"type": "bool",
"advanced": true
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
/* Default PDF stylesheet. Rendered through Jinja, so colour tokens like
colors.accent (in double curly braces) from the tenant config are available.
Copy this file into a tenant folder and edit freely to give a customer a
completely individual PDF design. */
@page {
size: A4;
margin: 26mm 22mm 24mm 22mm;
@top-left {
content: element(runHdrText);
vertical-align: bottom;
}
@top-right {
content: element(runHdrLogo);
vertical-align: bottom;
}
@bottom-left {
content: element(runFtr);
vertical-align: top;
}
@bottom-right {
content: "Seite " counter(page, decimal-leading-zero) " / " counter(pages, decimal-leading-zero);
vertical-align: top;
font-size: 7.5pt;
font-style: italic;
color: {{ colors.text_3 }};
}
}
body {
font-family: "Noto Sans", "DejaVu Sans", Arial, sans-serif;
font-size: 9.8pt;
color: #000000;
margin: 0;
}
/* ── running header / footer ── */
.run-hdr-text {
position: running(runHdrText);
font-style: italic;
font-size: 9pt;
color: {{ colors.text_2 }};
}
.run-hdr-logo {
position: running(runHdrLogo);
height: 12mm;
}
.run-ftr {
position: running(runFtr);
font-size: 7pt;
line-height: 1.4;
color: {{ colors.text_2 }};
}
/* ── optional document title (tenant sets doc_title) ── */
.doc-title {
color: {{ colors.accent_dark }};
font-size: 16pt;
font-weight: 700;
margin: 0 0 3mm 0;
}
/* ── metadata block ── */
.meta {
width: 100%;
border-collapse: collapse;
margin: 2mm 0 6mm 0;
}
.meta-left { width: 50%; vertical-align: top; padding: 0; }
.meta-right { width: 50%; vertical-align: top; text-align: right; padding: 0; }
.meta-label {
color: {{ colors.accent }};
font-style: italic;
font-size: 11pt;
margin-bottom: 1.8mm;
}
.meta-sublabel {
color: {{ colors.accent }};
font-weight: 700;
font-size: 9.5pt;
margin: 3mm 0 1mm 0;
}
.meta-val {
font-size: 10.2pt;
line-height: 1.45;
}
/* ── content sections ── */
.proto-section { margin-bottom: 3.5mm; }
.section-bar {
background: {{ colors.accent_light }};
color: {{ colors.accent_dark }};
font-size: 10pt;
font-weight: 700;
padding: 1.8mm 2.5mm;
margin: 0 0 2.5mm 0;
break-after: avoid;
}
.para {
margin: 0 0 1.8mm 6mm;
line-height: 1.5;
}
.bullets {
margin: 0 0 1.8mm 0;
padding-left: 10mm;
list-style: disc;
}
.bullets li {
margin: 0 0 0.8mm 0;
line-height: 1.5;
}

View File

@@ -0,0 +1,59 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Gesprächsprotokoll {{ terminart }} – {{ project_display }}</title>
</head>
<body>
{# ── running header/footer: pulled into the @page margin boxes by the CSS ── #}
<div class="run-hdr-text">{{ terminart }} &nbsp;|&nbsp; Gesprächsprotokoll</div>
{% if logo_data_uri %}<img class="run-hdr-logo" src="{{ logo_data_uri }}" alt="{{ tenant.name }}">{% endif %}
<div class="run-ftr">
<div>Gesprächsprotokoll · {{ terminart }} · {{ date_de }} · {{ project_display }}</div>
<div>Erstellt durch: {{ office.name }}{% if office.subtitle %} {{ office.subtitle }}{% endif %}{% if office.tel %} • {{ office.tel }}{% endif %}{% if office.email %} • {{ office.email }}{% endif %}</div>
<div>{{ footer_note }}</div>
</div>
{% if doc_title %}<h1 class="doc-title">{{ doc_title }}</h1>{% endif %}
{# ── metadata: two columns (Projekt / Erstellt durch) ── #}
<table class="meta">
<tr>
<td class="meta-left">
<div class="meta-label">{{ projekt_label }}</div>
{% for line in projekt %}<div class="meta-val">{{ line }}</div>{% endfor %}
{% if kontakt %}
<div class="meta-sublabel">Ansprechpartner Kunde</div>
{% for line in kontakt %}<div class="meta-val">{{ line }}</div>{% endfor %}
{% endif %}
</td>
<td class="meta-right">
<div class="meta-label">Erstellt durch</div>
{% if office.name %}<div class="meta-val">{{ office.name }}{% if office.subtitle %} {{ office.subtitle }}{% endif %}</div>{% endif %}
{% for line in (office.address or "").splitlines() %}<div class="meta-val">{{ line }}</div>{% endfor %}
{% if office.tel %}<div class="meta-val">{{ office.tel }}</div>{% endif %}
{% if office.email %}<div class="meta-val">{{ office.email }}</div>{% endif %}
{% if office.website %}<div class="meta-val">{{ office.website }}</div>{% endif %}
</td>
</tr>
</table>
{# ── content sections ── #}
{% for section in sections %}
<section class="proto-section">
<h2 class="section-bar">{{ section.heading }}</h2>
{% for block in section.blocks %}
{% if block.type == 'paragraph' %}
<p class="para">{{ block.text }}</p>
{% elif block.type == 'list' %}
<ul class="bullets">
{% for item in block['items'] %}<li>{{ item }}</li>{% endfor %}
</ul>
{% endif %}
{% endfor %}
</section>
{% endfor %}
</body>
</html>

12
vendor/platform/main.py vendored Normal file
View File

@@ -0,0 +1,12 @@
"""Entry point. The tenant is selected via the TENANT env var (one instance
per customer). Run e.g.:
TENANT=sportklinik python main.py
"""
import os
import uvicorn
if __name__ == "__main__":
port = int(os.getenv("PORT", "8276"))
uvicorn.run("core.app:app", host="0.0.0.0", port=port, reload=True)

13
vendor/platform/requirements.txt vendored Normal file
View File

@@ -0,0 +1,13 @@
# System-Abhängigkeiten:
# ffmpeg (Audio-Konvertierung; nur für Azure-STT nötig)
# libpango / libcairo etc. (WeasyPrint PDF-Rendering)
# Debian/Ubuntu: apt-get install ffmpeg libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-core
fastapi==0.135.1
openai==2.46.0
python-multipart==0.0.22
uvicorn==0.41.0
python-dotenv==1.2.2
requests>=2.31.0
jinja2>=3.1.0
weasyprint>=60.0
python-docx>=1.1.0