Compare commits

...

28 commits

Author SHA1 Message Date
Jonas Miederer
b1c8d6c66e Merge branch 'chore/langflow-ops'
Some checks failed
/ test (push) Has been cancelled
/ build (push) Has been cancelled
/ validate (push) Has been cancelled
2026-09-14 21:39:17 +02:00
Jonas Miederer
25cf645295 chore: prod env checker, secret guidance, internet-exposure runbook 2026-09-14 21:39:06 +02:00
Jonas Miederer
c3c4986979 Merge branch 'fix/edge-hardening' 2026-09-14 21:35:18 +02:00
Jonas Miederer
e82ce391f6 fix: edge hardening, rootless containers, fail-closed frontend mode 2026-09-14 21:35:06 +02:00
Jonas Miederer
d903d0951f Merge branch 'fix/api-authz' 2026-09-14 20:12:58 +02:00
Jonas Miederer
94e5e53092 fix: authorize flow management, gate runs, token-gate callbacks, strict tenants 2026-09-14 20:12:32 +02:00
Jonas Miederer
cfcf0e8bd6 Merge branch 'chore/ruff-hygiene' 2026-09-14 08:10:23 +02:00
Jonas Miederer
a6cc585dd0 chore: enforce ruff lint and format standards across backend 2026-09-14 08:10:14 +02:00
Jonas Miederer
a8e312a4f8 feat: landing page overhaul with pilot framing and compliance-safe copy 2026-09-13 23:29:20 +02:00
Jonas Miederer
2d7773dbca chore: add ROADMAP.md as authoritative future-ideas file 2026-09-13 21:40:18 +02:00
Jonas Miederer
fa9d99d5c0 chore: document GCM credential setup in AGENTS.md 2026-09-13 21:14:43 +02:00
Jonas Miederer
c1d505238f chore: add GCM with libicu76 dependency to devcontainer setup 2026-09-13 21:09:23 +02:00
Jonas Miederer
9420ce10cf chore: install GCM in devcontainer for HTTPS git auth 2026-09-13 21:06:28 +02:00
Jonas Miederer
c948657b25 chore: version 0.1 — changelog, VERSION file, agent workflow rules 2026-09-13 19:32:41 +02:00
Jonas Miederer
df5c1e296c feat: bell-tower favicon, lfx-clean flows, branded HTML mail 2026-09-13 17:57:42 +02:00
Jonas Miederer
672faf3af8 feat: photo landing page with favicon set 2026-09-13 12:52:46 +02:00
Jonas Miederer
b533549e45 feat: email-first login with allow-check and access forward 2026-09-12 11:20:55 +02:00
Jonas Miederer
82f7ff8b02 feat: conversion landing page with automated screenshots 2026-09-12 09:18:59 +02:00
Jonas Miederer
85c5a8381c docs: plan note for Mantine design pass 2026-09-12 08:39:23 +02:00
Jonas Miederer
38d4899b07 feat: Mantine design system with dark mode 2026-09-12 08:39:00 +02:00
Jonas Miederer
6e12720ad6 fix: mail delivery never fails requests after commit 2026-09-12 07:44:36 +02:00
Jonas Miederer
62d01b5097 feat: router with RJSF workflows and access management UI 2026-09-12 07:36:05 +02:00
Jonas Miederer
46ecab00db feat: form-input library with schema parsing and endpoint 2026-09-12 07:36:05 +02:00
Jonas Miederer
80d81b8022 fix: postCreate failures (absolute paths, sudo HOME, socket-wait bug) 2026-09-11 19:21:13 +02:00
Jonas Miederer
d4aeab8dbf fix: lfx boot, deploy key, and entrypoint perms for compose/devcontainer 2026-09-11 17:44:23 +02:00
Jonas Miederer
c8280f7078 feat: magic-link auth with access requests and RBAC 2026-09-11 17:27:58 +02:00
Jonas Miederer
c2125f063b fix: devcontainer build and setup on fresh clones 2026-09-11 17:18:59 +02:00
Jonas Miederer
1e8050611c chore: phase 0 reproducible docker/dev setup with version pinning 2026-09-09 09:44:59 +00:00
109 changed files with 8747 additions and 800 deletions

View file

@ -11,14 +11,23 @@
},
"mounts": [
"source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly",
"source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind"
"source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume",
"source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume"
],
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
// moby:false — python:3.12-slim is Debian trixie now, whose repos have
// no moby-cli (feature default). false installs the upstream Docker CE
// CLI instead, which supports trixie.
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"overrideCommand": false,
"postCreateCommand": "sudo -E bash .devcontainer/setup.sh",
"postStartCommand": "bash .devcontainer/postStart.sh",
// Absolute paths: lifecycle commands run in the image WORKDIR
// (/srv/kirche-einfach/backend), not the workspace folder — relative
// .devcontainer/... paths fail with "No such file or directory" (exit 127).
"postCreateCommand": "sudo -E bash /srv/kirche-einfach/.devcontainer/setup.sh",
"postStartCommand": "bash /srv/kirche-einfach/.devcontainer/postStart.sh",
"customizations": {
"vscode": {
"extensions": [
@ -29,7 +38,7 @@
"esbenp.prettier-vscode"
],
"settings": {
"python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python"
"python.defaultInterpreterPath": "/opt/venv/bin/python"
}
}
}

View file

@ -2,31 +2,73 @@
# Dev-only setup (runs via postCreateCommand in the attached `api` container).
# Production needs none of this: images carry their dependencies and the
# api entrypoint migrates on every start.
set -e
set -eo pipefail
REPO=/srv/kirche-einfach
# Run a command as vscode with the current environment intact.
# (`su vscode -c` resets the environment, which used to drop
# UV_PROJECT_ENVIRONMENT/UV_PYTHON_INSTALL_DIR plus every compose-provided
# variable — uv then created a project-local backend/.venv in the bind-mount
# instead of using /opt/venv, and sync_flows.py ran without its settings.)
# -H repoints HOME to /home/vscode: without it HOME stays /root (from the
# outer sudo) and uv/pip caches fail with Permission denied.
as_vscode() {
sudo -H -u vscode -E bash -c "$*"
}
# Venv lives outside the bind-mount (see backend/Dockerfile).
export UV_PYTHON_INSTALL_DIR=/opt/python UV_PROJECT_ENVIRONMENT=/opt/venv
echo "=== Setting up kirche-einfach ==="
# Backend deps into the venv volume (setup runs via sudo; handed to vscode below).
# Writable dirs first: this script runs via sudo as root, but the editor and
# most commands run as vscode. (A root-created backend/data/ or venv used to
# break the DB and the interpreter for vscode.)
mkdir -p "$REPO/backend/data" \
"$REPO/frontend/node_modules" \
/opt/python /opt/venv \
/home/vscode/.cache \
/home/vscode/.local/share/uv \
/home/vscode/.local/share/opencode \
/home/vscode/.config/opencode
chown -R vscode:vscode "$REPO/backend/data" \
"$REPO/frontend/node_modules" \
/opt/python /opt/venv \
/home/vscode/.cache \
/home/vscode/.local/share/uv \
/home/vscode/.local/share/opencode \
/home/vscode/.config/opencode 2>/dev/null || true
# Backend deps into /opt/venv, as vscode (a root-owned venv is unusable for
# the editor; uv resolves at setup time, no lockfile is committed).
echo "--- Installing Python dependencies ---"
cd "$REPO/backend"
uv sync
as_vscode "uv sync"
# opencode CLI (dev tooling; intentionally not baked into the image).
if [ ! -x /home/vscode/.opencode/bin/opencode ]; then
echo "--- Installing opencode ---"
su vscode -c "curl -fsSL https://opencode.ai/install | bash"
as_vscode "curl -fsSL https://opencode.ai/install | bash"
ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode
fi
# Compose services (langflow, lfx) must be up; fail loudly otherwise.
# (api/frontend are starting alongside this script.)
echo "--- Waiting for compose services ---"
# Fresh clones have no `.env` (gitignored) and compose `env_file` is
# optional, so provision one from the example instead of failing setup.
# Fill in real secrets afterwards; empty SMTP_* means the API logs mail.
if [ ! -f "$REPO/.env" ]; then
echo "ERROR: $REPO/.env is missing. Copy .env.example to .env and fill in secrets." >&2
exit 1
echo "WARN: $REPO/.env missing — copying .env.example (fill in secrets)."
cp "$REPO/.env.example" "$REPO/.env"
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
fi
# Dev needs the Vite HMR server; the compose default is fail-closed preview.
if ! grep -q "^FRONTEND_MODE=" "$REPO/.env" 2>/dev/null; then
echo "FRONTEND_MODE=dev" >> "$REPO/.env"
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
fi
echo "--- Waiting for compose services ---"
python3 - <<'EOF'
import socket
import sys
@ -36,13 +78,13 @@ targets = [("langflow", 7860), ("lfx", 6004)]
deadline = time.time() + 180
pending = dict(targets)
while pending and time.time() < deadline:
for host, port in list(pending):
for host, port in list(pending.items()):
s = socket.socket()
s.settimeout(2)
try:
s.connect((host, port))
print(f"OK {host}:{port}", flush=True)
del pending[(host, port)]
del pending[host]
except OSError:
pass
finally:
@ -57,13 +99,18 @@ EOF
# entrypoint on every start).
echo "--- Syncing canonical flows ---"
cd "$REPO/backend"
uv run python scripts/sync_flows.py --sync || echo "WARN: flow sync failed (check backend/flows and LFX)"
as_vscode "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)"
echo "--- Installing Git Credential Manager ---"
# GCM (Linux) depends on libicu (.NET runtime) — runtime libs only.
apt-get install -y -qq libicu76 2>/dev/null || true
GCM_DEB_URL="https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.9.1/gcm-linux-x64-2.9.1.deb"
GCM_DEB="/tmp/gcm-linux-x64-2.9.1.deb"
curl -fsSL "$GCM_DEB_URL" -o "$GCM_DEB"
dpkg -i "$GCM_DEB" 2>/dev/null || apt-get install -f -y
as_vscode "git config --global credential.helper manager"
rm -f "$GCM_DEB"
echo ""
echo "=== Setup complete ==="
echo "Forgejo username: $FORGEJO_USERNAME"
# Hand dirs back to vscode (this script runs via sudo; uv creates caches as root)
chown -R vscode:vscode "$REPO/backend/.venv" \
"$REPO/frontend/node_modules" \
/home/vscode/.cache 2>/dev/null || true

View file

@ -1,12 +1,21 @@
# Copy to `.env` and fill in values. `.env` is gitignored.
# It holds only ubiquitous variables (identical value in dev and prod).
# Dev-only variables travel via devcontainer.json `containerEnv`
# (e.g. APP_ENV=dev); paths resolve from the repo root in code.
# Generate secrets with: openssl rand -hex 32
# Validate before deploying: uv run python scripts/check_prod.py --env ../.env
# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas).
# Prod overrides via this .env file (e.g. APP_ENV=prod).
# Paths resolve from the repo root in code.
FORGEJO_TOKEN=
# --- langflow (authoring UI) ---
# --- langflow (authoring UI) + lfx (flow runtime): pinned in lockstep ---
# LFX contract: major.minor of Langflow and LFX must match (patches are
# independent). Bump deliberately on both sides together (compose image pin
# below + Dockerfile.lfx ARG + backend dev dep `lfx~=`), then re-check
# backend/flows/*.json with `lfx upgrade --upgrade-flow=safe`.
LANGFLOW_VERSION=1.12.0
LANGFLOW_URL=http://langflow:7860
LANGFLOW_SUPERUSER=langflow
# Long random values (check_prod.py enforces length). Never leave the
# placeholders: the authoring UI is internet-facing.
LANGFLOW_SUPERUSER_PASSWORD=
LANGFLOW_SECRET_KEY=
# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope.
@ -19,9 +28,27 @@ LFX_API_KEY=lfx-
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
CALLBACK_BASE_URL=http://api:8000
# Shared secret for flow callbacks (POST /api/approvals carries it as
# X-Callback-Token). Long random value; empty disables callbacks.
CALLBACK_TOKEN=
# --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
FRONTEND_MODE=dev
# --- frontend mode: unset/preview serves the production bundle ---
# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it.
# Never set `dev` in prod: the Vite dev server must not face the internet.
# FRONTEND_MODE=dev
# --- access management (request + approval, invite via email) ---
# Public frontend URL (magic links point here).
FRONTEND_URL=http://localhost:3000
# Comma-separated emails that become admins (role assignment + bootstrap).
ADMIN_EMAILS=
# Reply-To on outgoing mail; also receives new access-request notifications.
CONTACT_TO=
MAIL_FROM=
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
# --- LLM (optional; tests mock the provider without it) ---
# LLM_API_KEY=

View file

@ -19,4 +19,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: cd backend && uv sync
- run: cd backend && uv run ruff check .
- run: cd backend && uv run ruff format . --check --diff
- run: cd backend && uv run pytest

4
.gitignore vendored
View file

@ -26,6 +26,10 @@ backend/data/
*.swp
*.swo
# Devcontainer
.devcontainer/devcontainer-lock.json
.devcontainer/opencode-data/
# OS
.DS_Store
Thumbs.db

View file

@ -7,6 +7,8 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/
Backend (run from `backend/`):
- `uv sync` — install dependencies
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed)
- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`)
- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`)
- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored)
- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX
- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)
@ -32,6 +34,11 @@ profiles, or override files. Containers: `api` (FastAPI :8000, attached),
- Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via
`git pull` + restart, image rebuilds only for dependency changes
(`docker compose up -d --build`).
- Git in the container uses HTTPS with GCM (`credential.helper=manager`,
installed by `.devcontainer/setup.sh` together with `libicu76` runtime).
Remote URL has no embedded token. First push/pull triggers a device-code
prompt (open URL on host). Rotate GitLab tokens in GitLab UI before
switching credentials.
## Definition of done
@ -62,3 +69,25 @@ report = await sync_from_directory(session, lfx, settings.flows_dir)
## Git
When committing: `git add -A && git commit -m "<message>"` (conventional commits: `feat:`, `fix:`, `chore:`, `docs:`). When pushing: `git push` (branch is tracking `origin/main`).
## Versioning
- `VERSION` (repo root) is canonical — currently `0.1.0`.
- Backend: `backend/pyproject.toml` version; Frontend: `frontend/package.json` version — bump both together with VERSION on every release.
- SemVer: feat → minor (X.Y+1.0), fix → patch (X.Y.Z+1), breaking → major (X+1.0.0). Tag `vX.Y.Z` at release merge.
- Changelog: every user-visible change gets an `[Unreleased]` entry (Keep a Changelog categories) before commit; at release move it into `[X.Y.Z] - YYYY-MM-DD`.
## Git workflow
- New work branches from `main`: `feat/<short>` for features, `fix/<short>` for fixes.
- Push branch, open MR, merge `--no-ff` on green CI, delete branch.
- Never push directly to `main` for features or fixes.
## Web-dev digest
- Small batches: PR diff ≤ ~200 lines ideal; trunk-based with short-lived branches (above).
- Tests green before merge (definition of done); `npm run build` green for frontend.
- Docs in same PR as feature (README, AGENTS, CHANGELOG).
- Deprecate in minor, remove in next major; document Removed/Deprecated in CHANGELOG.
- Lockfiles (uv.lock, package-lock.json, compose pins) — don't drift without reason.
- Future implementation ideas → `ROADMAP.md` (authoritative); `CHANGELOG.md` is historical only.

49
CHANGELOG.md Normal file
View file

@ -0,0 +1,49 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Security
- Production ops: `scripts/check_prod.py` validates `.env` secrets and
modes before deploy (with tests); README production checklist
(proxy gates for Langflow, secret rotation, upgrade runbook).
### Security
- API authorization: flow management is admin-only, runs and run/approval
reads require login with strict session-tenant scoping, approval
decisions record the session user, and flow callbacks authenticate with
a shared `X-Callback-Token` (`CALLBACK_TOKEN`). Request-controlled
`tenant_id`, `callback_base_url` and `continuation_flow_id` were
removed (SSRF / cross-tenant / flow-chaining hardening);
continuations now resolve from the admin-authored definition.
- Frontend: `/app/*` routes require login; approver is the session user.
- Edge hardening: interactive API docs only with debug, security headers
(nosniff/frame/Referrer-Policy), 2 MiB body cap, per-IP run throttle,
generic upstream-error responses, magic links never logged.
- Containers run rootless (api/vite as uid 1000, lfx as `lfx` user);
frontend fails closed to preview mode unless `FRONTEND_MODE=dev`.
### Added
- Landing page: status-quo problem block, 5-step process with quote,
portal overview with admin areas, AI platform parts, exception tiers,
quantified benefits, integrations, use-case examples, pilot-program
section, expanded FAQ (8 items incl. KI data usage, price model,
responsibility).
### Changed
- Landing page copy overhauled after 3 reviewer rounds (church-admin
compliance wording: "entwickelt nach DSG-EKD/DSGVO" + AVV/TOMs,
human release responsibility, honest pilot framing instead of
invented testimonials; CTA now "Pilotgespräch vereinbaren").
- New photo `pilot-meeting.webp` (consultation scene, self-hosted).
- Backend lint + format standards via ruff (pinned dev dependency,
enforced in CI): `uv run ruff check .`, `uv run ruff format`.
## [0.1.0] - 2026-09-13
### Added
- Repo scaffold: docker-compose dev/prod, version-pinned images, devcontainer.
- Magic-link auth, access requests, RBAC, frontend access-management UI.
- File-based flow source of truth + sync engine + LFX runtime.
- RJSF dynamic forms, router, Mantine dark-mode design system.
- Conversion landing page, automated screenshots, bell-tower favicon.
- Email-first login, branded multipart HTML magic-link mails.
- lfx level-4 validation on all canonical flows + regression test.

View file

@ -8,12 +8,21 @@ ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# lfx = flow runtime; requests = convenience for custom-component code.
RUN pip install lfx requests
# Pinned to the Langflow minor (contract: major.minor must match Langflow;
# patches independent). Bump together with LANGFLOW_VERSION (.env.example).
ARG LFX_VERSION=1.12.0
RUN pip install "lfx~=${LFX_VERSION}" requests
RUN mkdir -p /flows
# Non-root runtime: uploads persist into the mounted volume.
RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows
VOLUME /flows
EXPOSE 6004
USER lfx
# Serve the /flows dir (uploaded flows persist there via --flow-dir).
# Flags verified against `lfx serve --help` (lfx 1.12.0).
CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"]
# No positional path: with one, lfx>=1.12.1 exits when the dir holds no
# .json yet ("No .json files found"), restart-looping the container on
# first boot. --flow-dir alone starts clean (health 200); the API sync
# deploys backend/flows/*.json right after boot.
# Flags verified against `lfx serve --help` (lfx 1.12.x).
CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows"]

View file

@ -22,8 +22,28 @@ needs judgment. Every step is documented and traceable.
deploy to the lightweight LFX runtime and can run synchronously or streamed.
- **Approvals** — a flow can pause and ask a human for a decision. Approving
can automatically start a follow-up ("continuation") flow.
- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben*
(approvals), *Administration* (import, sync, deploy).
- **Access management** — public landing page (German) with an access-request
form (request + approval). Approved users get a passwordless magic-link
invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants
come from the session. First admin via `POST /api/auth/admin-bootstrap`
(only on an empty user table, email must be in `ADMIN_EMAILS`).
- **Dynamic forms** — flows declare form fields as typed inputs on a
`CustomInput*` custom component (reusable library in
`backend/components/`, mounted into Langflow via
`LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a
JSON Schema + RJSF uiSchema from the deployed version; the webapp renders
the matching form automatically. Flows without such a node fall back to a
single textarea. See
`backend/components/kirche/README.md` for authoring new form fields.
- **Web UI** — landing page (*Start*), access request (*Zugang*), and the
logged-in webapp: *Workflows* (choose + run with dynamic form),
*Aufgaben* (approvals), *Verwaltung* (admin: access requests, import,
sync, deploy).
## Future
Ideas and planned features live in [ROADMAP.md](ROADMAP.md)
(the single authoritative place for future implementation ideas).
## Infrastructure
@ -106,15 +126,32 @@ Flow workflow: design in Langflow → export into `backend/flows/<name>.json`
(`--pull`, or UI download) → commit → the watcher deploys it automatically.
Deleting a file removes the flow including its runs (git keeps the JSON).
## Production (self-hosted, internal use — no TLS/reverse proxy included)
## Production (self-hosted — TLS/reverse proxy required, not included)
1. Check out the repo on the host, create `.env` from `.env.example` with
real secrets and `FRONTEND_MODE=preview`.
2. `docker compose up -d --build`.
3. Code updates via `git pull` + `docker compose restart api frontend`
real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`.
2. Validate it: `uv run python scripts/check_prod.py --env ../.env`
(run from `backend/`; blocks on weak secrets or dev modes).
3. `docker compose up -d --build`.
4. Code updates via `git pull` + `docker compose restart api frontend`
(the checkout is bind-mounted); image rebuilds only for dependency
changes (`docker compose up -d --build`).
Internet exposure checklist (both forwarded ports sit behind your HTTPS
proxy — nothing here substitutes for it):
- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or
OIDC) in front of Langflow `:7860`. Langflow has a history of
critical unauthenticated RCEs — treat its UI as privileged tooling,
never share flows publicly ("Shareable Playground" = RCE risk).
- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate
after any incident or staff change; re-run `check_prod.py`.
- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION`
(`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then
`lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on
`backend/flows/*.json`. Check Langflow security advisories monthly.
- Backups: `backend/data/` (SQLite) and the `langflow-data` volume.
Container updates: registry images (`langflowai/langflow:latest`)
auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built
images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`.

20
ROADMAP.md Normal file
View file

@ -0,0 +1,20 @@
# Roadmap
Future implementation ideas for kirche-einfach.
Ideas live here until they ship (then a `feat:` commit + `[Unreleased]`
entry in CHANGELOG.md). Keep entries short, ordered by priority.
This file is the **authoritative place** for future ideas.
`CHANGELOG.md` is historical (what shipped); `AGENTS.md` is rules/architecture.
## Planned
(none yet — add ideas here as they emerge)
## Considered / not started
- **Member form-filling page** — members fill out forms (attendance, sign-up) via the same input schema used by admins to *run* flows.
- **Approval decision flow** — complete approve/reject-with-reason + member notification for access requests.
- **E2E tests** — Playwright/Cypress smoke tests across key user journeys.
- **CI pipeline** — complete Forgejo workflows (backend pytest, frontend build, compose validation, lint).
- **Audit log** — track who submitted/approved what (church compliance).

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

9
backend/.dockerignore Normal file
View file

@ -0,0 +1,9 @@
# Keep the build context lean: only pyproject/alembic/entrypoint plus
# app/migrations/scripts are COPYed. Local venvs, databases and caches must
# never enter the context (slow builds, root-owned file leaks).
.venv/
data/
__pycache__/
*.py[cod]
.pytest_cache/
uv.lock

View file

@ -4,17 +4,28 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
PIP_NO_CACHE_DIR=1 \
# Venv + managed Python live outside the repo bind-mount: a venv under
# backend/ used to be shadowed by a named volume (empty on first start)
# and backed by a root-owned interpreter vscode could not execute.
# /opt is image-local, world-readable, identical in dev and prod.
UV_PYTHON_INSTALL_DIR=/opt/python \
UV_PROJECT_ENVIRONMENT=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Foundational, inert in prod: non-root user for the editor, sudo for setup,
# openssh-client for the devcontainer post-start hook.
# openssh-client for the devcontainer post-start hook, curl for the opencode
# installer in setup.sh, git for the VS Code source-control view, gosu to
# drop privileges before serving (the server never runs as root).
RUN apt-get update \
&& apt-get install -y --no-install-recommends sudo openssh-client \
&& apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -s /bin/bash vscode \
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \
&& mkdir -p /opt/python /opt/venv \
&& chmod 755 /opt/python /opt/venv
WORKDIR /srv/kirche-einfach/backend
COPY pyproject.toml alembic.ini entrypoint.sh ./
@ -23,9 +34,12 @@ COPY migrations ./migrations
COPY scripts ./scripts
# No lockfile is committed; dependencies resolve at build time.
# Full groups (incl. dev): pytest and friends must work in dev with zero steps.
RUN uv sync && chmod +x entrypoint.sh
# a+rX: the image builds as root, but the attached devcontainer runs as vscode.
RUN uv sync && chmod +x entrypoint.sh && chmod -R a+rX /opt/python /opt/venv
ENV FLOWS_WATCH_ENABLED=true
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
# Explicit interpreter: the repo bind-mount shadows the image at runtime and
# git tracks entrypoint.sh as 100644, so the exec bit cannot be relied on.
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]

View file

@ -0,0 +1 @@
"""Magic-link authentication + access-request approval (SMTP invite)."""

192
backend/app/auth/api.py Normal file
View file

@ -0,0 +1,192 @@
"""Access + auth HTTP API (magic-link login, session cookie)."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlmodel import Session
from app.models import User
from . import mail, service
from .deps import (
SESSION_COOKIE,
SESSION_MAX_AGE,
get_current_user,
get_db,
require_admin,
)
from .schemas import (
AccessDecisionRead,
AccessRequestCreate,
AccessRequestRead,
AdminBootstrapRequest,
MagicConsumeRequest,
MagicRequestRead,
MagicRequestRequest,
MeRead,
MessageRead,
)
access_router = APIRouter(prefix="/api/access", tags=["access"])
auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
def _read_request(r) -> AccessRequestRead:
return AccessRequestRead(
id=r.id,
name=r.name,
email=r.email,
reason=r.reason,
status=r.status,
created_at=r.created_at,
decided_at=r.decided_at,
decided_by=r.decided_by,
)
def _read_me(user: User) -> MeRead:
return MeRead(
id=user.id,
email=user.email,
name=user.name,
role=user.role,
tenant_id=user.tenant_id,
)
def _set_session_cookie(response: Response, session_id: str) -> None:
# localhost is a secure context, so Secure is safe for local dev too.
response.set_cookie(
SESSION_COOKIE,
session_id,
max_age=SESSION_MAX_AGE,
path="/",
httponly=True,
secure=True,
samesite="lax",
)
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]):
try:
request = service.create_access_request(session, data.name, data.email, data.reason)
except service.RateLimitedError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
mail.send_access_notification(str(request.id), request.name, request.email)
return _read_request(request)
@access_router.get("/requests", response_model=list[AccessRequestRead])
def list_requests(
session: Annotated[Session, Depends(get_db)],
admin: Annotated[User, Depends(require_admin)],
status: str | None = Query(default=None),
):
return [_read_request(r) for r in service.list_access_requests(session, status)]
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
def approve_request(
request_id: UUID,
session: Annotated[Session, Depends(get_db)],
admin: Annotated[User, Depends(require_admin)],
):
try:
request, _user, token = service.approve_access_request(session, request_id, admin.email)
except service.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except service.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
# The token travels by mail only — never in an API response.
email_sent = mail.send_magic_link(request.email, mail.magic_link(token))
return AccessDecisionRead(request=_read_request(request), email_sent=email_sent)
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
def reject_request(
request_id: UUID,
session: Annotated[Session, Depends(get_db)],
admin: Annotated[User, Depends(require_admin)],
):
try:
request = service.reject_access_request(session, request_id, admin.email)
except service.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except service.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return _read_request(request)
@auth_router.post("/magic/request", response_model=MagicRequestRead)
def request_magic(
data: MagicRequestRequest,
session: Annotated[Session, Depends(get_db)],
):
"""Email-first login: allowed emails get a magic link mailed.
403 means "not allowed" — the frontend forwards those to /access.
The token travels by mail only, never in the response.
"""
try:
user, token = service.request_magic_link(session, data.email)
except service.ForbiddenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except service.RateLimitedError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
email_sent = mail.send_magic_link(user.email, mail.magic_link(token))
return MagicRequestRead(message="login link sent", email_sent=email_sent)
@auth_router.post("/magic/consume", response_model=MeRead)
def consume_magic(
data: MagicConsumeRequest,
response: Response,
session: Annotated[Session, Depends(get_db)],
):
try:
user_session = service.consume_magic_token(session, data.token)
except (
service.InvalidTokenError,
service.ExpiredTokenError,
service.UsedTokenError,
) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
user = service.get_user(session, user_session.user_id)
if user is None: # pragma: no cover - consume already validated the user
raise HTTPException(status_code=400, detail="unknown token")
_set_session_cookie(response, user_session.id)
return _read_me(user)
@auth_router.get("/me", response_model=MeRead)
def read_me(user: Annotated[User, Depends(get_current_user)]):
return _read_me(user)
@auth_router.post("/logout", response_model=MessageRead)
def logout(
request: Request,
response: Response,
session: Annotated[Session, Depends(get_db)],
):
# Revoke the server-side row when the cookie maps to a live session;
# always clear the client cookie.
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
service.revoke_session(session, session_id)
response.delete_cookie(SESSION_COOKIE, path="/")
return MessageRead(message="logged out")
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]):
try:
_user, token = service.bootstrap_admin(session, data.email)
except service.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except service.ForbiddenError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token))
return MessageRead(message="admin created, magic link sent")

54
backend/app/auth/deps.py Normal file
View file

@ -0,0 +1,54 @@
"""Session-cookie dependencies. Handlers stay thin; DB via get_session."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session
from app.db import get_session
from app.models import User
from . import service
SESSION_COOKIE = "kirche_session"
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
def get_db() -> Iterator[Session]:
yield from get_session()
def get_optional_user(
request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE)
if not session_id:
return None
record = service.get_valid_session(session, session_id)
if record is None:
return None
return service.get_user(session, record.user_id)
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:
user = get_optional_user(request, session)
if user is None:
raise HTTPException(status_code=401, detail="not authenticated")
return user
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin only")
return user
def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str:
"""Tenant comes from the session; the body/query param is a deprecated
fallback for unauthenticated callers."""
if user is not None:
return user.tenant_id
return explicit or default

178
backend/app/auth/mail.py Normal file
View file

@ -0,0 +1,178 @@
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.
Mail design follows the web branding (see frontend/src/theme.ts):
kirchenblau header, serif headline, white card on paper background.
Email-safe by construction: table layout, inline styles only, system
fonts, no external images (many clients block them / track opens).
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from app.settings import settings
logger = logging.getLogger(__name__)
# Brand palette (mirrors frontend/src/theme.ts kirchenblau).
BLUE = "#1d4a79"
BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
BLUE = "#1d4a79"
BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
"""Branded HTML mail body (table layout, inline styles, no images)."""
button = ""
if button_url:
button = (
'<table role="presentation" cellpadding="0" cellspacing="0" '
'style="margin:24px auto 8px auto;"><tr>'
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
"font-weight:600;color:#ffffff;text-decoration:none;"
f'border-radius:8px;">{button_label}</a></td></tr></table>'
)
return f"""<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{BLUE}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{INK};">{title}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{MUTED};">{intro}</p>
{button}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
{note}
</p>
</td></tr>
</table>
</body>
</html>"""
def _send(message: EmailMessage) -> None:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
smtp.starttls()
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password)
smtp.send_message(message)
def _branded_mail(
subject: str,
to_email: str,
headline: str,
intro: str,
button_label: str,
button_url: str,
note: str,
) -> EmailMessage:
"""Text + branded HTML alternative; the link is a real button."""
message = EmailMessage()
message["Subject"] = subject
message["To"] = to_email
message["From"] = settings.mail_from or settings.contact_to or to_email
if settings.contact_to:
message["Reply-To"] = settings.contact_to
message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n")
# Email-safe HTML: tables + inline CSS; some clients strip <style>, so
# the palette is inlined via the f-string constants above.
message.add_alternative(_page(headline, intro, button_label, button_url, note), subtype="html")
return message
def send_magic_link(to_email: str, link: str) -> bool:
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
Never raises: callers invoke it after committing (approve/bootstrap),
so a delivery failure must not turn into a 500 — the token stays valid
and the admin sees ``email_sent: false``.
The plaintext link only ever appears here: service/api logging must
never include tokens or session ids (see test_auth no-leak test).
"""
if not settings.smtp_host:
# Never log the link itself: without SMTP this fallback fires in
# every environment, and logs are rarely as protected as mail.
logger.info("SMTP not configured; dropping magic link for %s", to_email)
return False
message = _branded_mail(
subject="Dein Login-Link für Kirche Einfach",
to_email=to_email,
headline="Anmeldung bei Kirche Einfach",
intro=(
"Klicke auf den Button, um dich anzumelden. Der Link ist "
"15 Minuten gültig und funktioniert nur einmal."
),
button_label="Jetzt anmelden",
button_url=link,
note=(
"Falls du den Button nicht nutzen kannst, kopiere den Link "
"aus der Textversion dieser E-Mail in deinen Browser. "
"Diese E-Mail wurde angefordert — wenn du das warst, ist "
"alles gut; sonst kannst du sie ignorieren."
),
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
logger.warning("magic-link delivery failed for %s", to_email)
return False
logger.info("magic link mailed to %s", to_email)
return True
def send_access_notification(request_id: str, name: str, email: str) -> bool:
"""Notify CONTACT_TO about a new access request (best effort)."""
if not settings.smtp_host or not settings.contact_to:
logger.info("new access request %s from %s <%s>", request_id, name, email)
return False
message = _branded_mail(
subject=f"Neue Zugangsanfrage: {name}",
to_email=settings.contact_to,
headline="Neue Zugangsanfrage",
intro=(
f"{name} ({email}) hat Zugang angefordert und wartet auf Freigabe in der Verwaltung."
),
button_label="In der Verwaltung prüfen",
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
note="Diese Benachrichtigung geht an das Kontakt-Postfach.",
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
logger.warning("access-request notification failed for %s", request_id)
return False
return True

View file

@ -0,0 +1,58 @@
"""Pydantic contracts for the access/auth API."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class AccessRequestCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
email: str = Field(min_length=3, max_length=320)
reason: str = Field(default="", max_length=2000)
class AccessRequestRead(BaseModel):
id: UUID
name: str
email: str
reason: str
status: str
created_at: datetime
decided_at: datetime | None
decided_by: str | None
class AccessDecisionRead(BaseModel):
request: AccessRequestRead
# False when SMTP is unset (dev): the magic link is logged, not mailed.
email_sent: bool
class MagicConsumeRequest(BaseModel):
token: str = Field(min_length=1, max_length=200)
class MagicRequestRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
class MagicRequestRead(BaseModel):
message: str
email_sent: bool
class MeRead(BaseModel):
id: UUID
email: str
name: str
role: str
tenant_id: str
class AdminBootstrapRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
class MessageRead(BaseModel):
message: str

306
backend/app/auth/service.py Normal file
View file

@ -0,0 +1,306 @@
"""Access requests, magic-link tokens, and sessions (DB via function args).
Keep API handlers thin: all persistence lives here. Tokens and session ids
are bearer secrets — log user/request ids only, never secret values.
"""
from __future__ import annotations
import hashlib
import logging
import secrets
from datetime import UTC, datetime, timedelta
from uuid import UUID
from sqlmodel import Session, func, select
from app.models import AccessRequest, MagicLinkToken, User, UserSession
from app.settings import settings
logger = logging.getLogger(__name__)
MAGIC_LINK_TTL = timedelta(minutes=15)
SESSION_TTL = timedelta(days=30)
# Public request endpoint: at most this many requests per email per hour.
REQUESTS_PER_EMAIL_PER_HOUR = 5
# Same for magic-link requests (email bombing / token spam protection).
MAGIC_LINKS_PER_EMAIL_PER_HOUR = 5
class AuthError(Exception):
pass
class NotFoundError(AuthError):
pass
class ConflictError(AuthError):
pass
class ForbiddenError(AuthError):
pass
class InvalidTokenError(AuthError):
pass
class ExpiredTokenError(AuthError):
pass
class UsedTokenError(AuthError):
pass
class RateLimitedError(AuthError):
pass
def utcnow() -> datetime:
return datetime.now(UTC)
def normalize_email(email: str) -> str:
return email.strip().lower()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def is_admin_email(email: str) -> bool:
return normalize_email(email) in settings.admin_email_list
def role_for(email: str) -> str:
return "admin" if is_admin_email(email) else "member"
def get_user(session: Session, user_id: UUID) -> User | None:
return session.get(User, user_id)
def get_user_by_email(session: Session, email: str) -> User | None:
return session.exec(select(User).where(User.email == normalize_email(email))).first()
def user_count(session: Session) -> int:
return session.exec(select(func.count()).select_from(User)).one()
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
since = utcnow() - timedelta(hours=hours)
return session.exec(
select(func.count())
.select_from(AccessRequest)
.where(
AccessRequest.email == normalize_email(email),
AccessRequest.created_at >= since,
)
).one()
def create_access_request(
session: Session, name: str, email: str, reason: str = ""
) -> AccessRequest:
email = normalize_email(email)
if recent_request_count(session, email) >= REQUESTS_PER_EMAIL_PER_HOUR:
raise RateLimitedError("too many requests from this email, try again later")
request = AccessRequest(name=name.strip(), email=email, reason=reason.strip())
session.add(request)
session.commit()
session.refresh(request)
logger.info("access request %s from %s", request.id, email)
return request
def get_access_request(session: Session, request_id: UUID) -> AccessRequest:
request = session.get(AccessRequest, request_id)
if request is None:
raise NotFoundError(f"access request {request_id} not found")
return request
def list_access_requests(session: Session, status: str | None = None) -> list[AccessRequest]:
query = select(AccessRequest).order_by(AccessRequest.created_at.desc())
if status is not None:
query = query.where(AccessRequest.status == status)
return list(session.exec(query).all())
def _get_or_create_user(session: Session, name: str, email: str) -> User:
user = get_user_by_email(session, email)
if user is None:
user = User(name=name.strip(), email=email, role=role_for(email))
session.add(user)
session.flush()
logger.info("user %s created for %s", user.id, email)
else:
if not user.name and name.strip():
user.name = name.strip()
new_role = role_for(email)
if user.role != new_role:
user.role = new_role
session.add(user)
session.flush()
return user
def issue_magic_token(session: Session, user: User) -> str:
token = secrets.token_urlsafe(32)
session.add(
MagicLinkToken(
user_id=user.id,
token_hash=hash_token(token),
expires_at=utcnow() + MAGIC_LINK_TTL,
)
)
session.commit()
logger.info("magic token issued for user %s", user.id)
return token
def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
since = utcnow() - timedelta(hours=hours)
return session.exec(
select(func.count())
.select_from(MagicLinkToken)
.where(
MagicLinkToken.user_id == user.id,
MagicLinkToken.created_at >= since,
)
).one()
def request_magic_link(session: Session, email: str) -> tuple[User, str]:
"""Self-service login link for an allowed email.
Allowed = approved before (has a User row) or listed in ADMIN_EMAILS
(admin user is created on first request). Anything else raises
ForbiddenError — the frontend forwards those to the access form.
Note: 403-vs-200 distinguishes registered emails by design here.
"""
email = normalize_email(email)
user = get_user_by_email(session, email)
if user is None:
if not is_admin_email(email):
raise ForbiddenError("email is not allowed to log in")
user = _get_or_create_user(session, "", email)
session.commit()
session.refresh(user)
if recent_token_count(session, user) >= MAGIC_LINKS_PER_EMAIL_PER_HOUR:
raise RateLimitedError("too many login links, try again later")
return user, issue_magic_token(session, user)
def approve_access_request(
session: Session, request_id: UUID, decided_by: str
) -> tuple[AccessRequest, User, str]:
"""Approve → upsert User + fresh magic-link token (plaintext, mail it)."""
request = get_access_request(session, request_id)
if request.status != "pending":
raise ConflictError(f"access request {request_id} already {request.status}")
user = _get_or_create_user(session, request.name, request.email)
request.status = "approved"
request.decided_at = utcnow()
request.decided_by = normalize_email(decided_by)
session.add(request)
session.commit()
session.refresh(request)
token = issue_magic_token(session, user)
logger.info(
"access request %s approved by %s; user %s",
request.id,
request.decided_by,
user.id,
)
return request, user, token
def reject_access_request(session: Session, request_id: UUID, decided_by: str) -> AccessRequest:
request = get_access_request(session, request_id)
if request.status != "pending":
raise ConflictError(f"access request {request_id} already {request.status}")
request.status = "rejected"
request.decided_at = utcnow()
request.decided_by = normalize_email(decided_by)
session.add(request)
session.commit()
session.refresh(request)
logger.info("access request %s rejected by %s", request.id, request.decided_by)
return request
def _aware(value: datetime) -> datetime:
# SQLite returns naive datetimes; stored values are UTC.
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
def consume_magic_token(session: Session, token: str) -> UserSession:
"""Single-use consume → session row. used_at is set before creation."""
record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
).first()
if record is None:
raise InvalidTokenError("unknown token")
if record.used_at is not None:
raise UsedTokenError("token already used")
if _aware(record.expires_at) <= utcnow():
raise ExpiredTokenError("token expired")
user = session.get(User, record.user_id)
if user is None:
raise InvalidTokenError("unknown token")
# Mark used first: a reused token can never mint a second session.
record.used_at = utcnow()
session.add(record)
user_session = UserSession(
id=secrets.token_urlsafe(32),
user_id=user.id,
expires_at=utcnow() + SESSION_TTL,
)
session.add(user_session)
session.commit()
session.refresh(user_session)
logger.info("magic token consumed for user %s", user.id)
return user_session
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
record = session.get(UserSession, session_id)
if record is None or record.revoked_at is not None:
return None
if _aware(record.expires_at) <= utcnow():
return None
return record
def revoke_session(session: Session, session_id: str) -> None:
record = session.get(UserSession, session_id)
if record is not None and record.revoked_at is None:
record.revoked_at = utcnow()
session.add(record)
session.commit()
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
"""First-admin setup: only when the user table is empty AND the email
is in ADMIN_EMAILS. Issues a magic-link token like approve does."""
email = normalize_email(email)
if user_count(session) > 0:
raise ConflictError("users already exist, bootstrap is closed")
if not is_admin_email(email):
raise ForbiddenError("email is not an admin email")
user = User(name="", email=email, role="admin")
session.add(user)
session.flush()
session.commit()
session.refresh(user)
token = issue_magic_token(session, user)
logger.info("admin bootstrapped for user %s", user.id)
return user, token

View file

@ -1,23 +1,35 @@
"""Flows + approvals HTTP API."""
"""Flows + approvals HTTP API.
Auth model: flow *management* (create/version/deploy/sync/import) is
admin-only. Browsing (list/get/input-schema) is public so the website
renders. Runs, run reads and approval reads require login and are scoped
to the session tenant. Flow *callbacks* (LFX has no session) carry a
shared X-Callback-Token instead.
"""
from __future__ import annotations
import json
import logging
import secrets
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Annotated
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin
from app.db import get_engine, get_session
from app.models import User
from app.settings import settings
from . import input_schema as form_schema
from . import runner, store
from .deploy import import_from_langflow
from .sync import lfx_id_for, sync_from_directory
from .langflow_client import (
LangflowClient,
LangflowError,
@ -30,7 +42,6 @@ from .lfx_client import (
LfxError,
LfxInvalidFlow,
LfxNotFound,
LfxRunFailed,
LfxUnavailable,
)
from .schemas import (
@ -44,14 +55,18 @@ from .schemas import (
FlowImportRequest,
FlowVersionCreate,
FlowVersionRead,
InputSchemaRead,
RunRead,
RunRequest,
SyncReport,
)
from .sync import lfx_id_for, sync_from_directory
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
logger = logging.getLogger(__name__)
def get_db() -> Iterator[Session]:
yield from get_session()
@ -62,9 +77,7 @@ def get_lfx() -> LfxClient:
def get_langflow() -> LangflowClient:
return LangflowClient(
settings.langflow_url, api_key=settings.langflow_api_key or ""
)
return LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key or "")
def _read_definition(d) -> FlowDefinitionRead:
@ -89,6 +102,7 @@ def _read_version(v) -> FlowVersionRead:
deployed_at=v.deployed_at,
author=v.author,
created_at=v.created_at,
input_schema=v.input_schema,
)
@ -124,7 +138,11 @@ def _read_task(t) -> ApprovalRead:
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
def create_flow(
data: FlowDefinitionCreate,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return _read_definition(store.create_definition(session, data))
except store.ConflictError as exc:
@ -134,14 +152,15 @@ def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
@flows_router.get("", response_model=list[FlowDefinitionRead])
def list_flows(session: Session = Depends(get_db)):
def list_flows(session: Annotated[Session, Depends(get_db)]):
return [_read_definition(d) for d in store.list_definitions(session)]
@flows_router.post("/sync", response_model=SyncReport)
async def sync_flows(
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
try:
@ -151,18 +170,19 @@ async def sync_flows(
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
def get_flow(flow_id: UUID, session: Session = Depends(get_db)):
def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
try:
return _read_definition(store.get_definition(session, flow_id))
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.post(
"/{flow_id}/versions", response_model=FlowVersionRead, status_code=201
)
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
def create_version(
flow_id: UUID, data: FlowVersionCreate, session: Session = Depends(get_db)
flow_id: UUID,
data: FlowVersionCreate,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return _read_version(store.create_version(session, flow_id, data))
@ -173,21 +193,24 @@ def create_version(
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
def list_versions(flow_id: UUID, session: Session = Depends(get_db)):
def list_versions(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
return [_read_version(v) for v in store.list_versions(session, flow_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.post(
"/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead
)
@flows_router.post("/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead)
async def deploy_version(
flow_id: UUID,
version_no: int,
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
try:
version = store.get_version_by_no(session, flow_id, version_no)
@ -206,9 +229,11 @@ async def deploy_version(
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return _read_version(store.mark_deployed(session, version, uploaded.id))
@ -222,55 +247,64 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
async def run_flow(
flow_id: UUID,
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User, Depends(get_current_user)],
stream: bool = Query(default=False),
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
):
# Tenant, callback URL and continuation always come from the server:
# the session, the settings, and the admin-authored definition.
tenant_id = user.tenant_id
try:
definition = store.get_definition(session, flow_id)
version = _resolve_version(session, flow_id, data.version_no)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except store.InvalidError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if stream:
return await _run_stream(session, lfx, version, data)
return await _run_stream(session, lfx, version, data, user, definition)
try:
run = await runner.execute_run(
session,
lfx,
version,
payload=data.input,
tenant_id=data.tenant_id,
tenant_id=tenant_id,
session_id=data.session_id,
callback_base_url=data.callback_base_url or settings.callback_base_url,
continuation_flow_id=data.continuation_flow_id,
callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token,
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return _read_run(run)
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
async def _run_stream(
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
):
if not version.lfx_flow_id:
raise HTTPException(
status_code=422, detail=f"version {version.id} is not deployed to LFX"
)
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
run = store.create_run(
session,
version,
input=data.input,
tenant_id=data.tenant_id,
tenant_id=user.tenant_id,
session_id=data.session_id,
)
envelope = runner.build_envelope(
payload=data.input,
run_id=run.id,
callback_base_url=data.callback_base_url or settings.callback_base_url,
continuation_flow_id=data.continuation_flow_id,
callback_base_url=settings.callback_base_url,
continuation_flow_id=definition.continuation_flow_id,
callback_token=settings.callback_token,
)
run_id = run.id
lfx_flow_id = version.lfx_flow_id
@ -279,30 +313,28 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
async def event_gen() -> AsyncIterator[str]:
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
try:
async with httpx.AsyncClient(
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
) as client:
async with client.stream(
async with (
httpx.AsyncClient(
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
) as client,
client.stream(
"POST",
f"/flows/{lfx_flow_id}/stream",
json={"input_value": envelope, "session_id": session_id},
) as response:
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
async for chunk in response.aiter_text():
yield chunk
) as response,
):
if response.status_code == 404:
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
response.raise_for_status()
async for chunk in response.aiter_text():
yield chunk
except (LfxNotFound, httpx.HTTPError) as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
# Finalize from a fresh session: the request session may be closed.
with Session(get_engine()) as fresh:
try:
fresh_run = store.get_run(fresh, run_id)
status = (
"pending_approval"
if runner._task_exists(fresh, run_id)
else "completed"
)
status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed"
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
except store.NotFoundError:
status = "failed"
@ -314,32 +346,39 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(
flow_id: UUID,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
# Strict tenant scoping: the session decides, query params cannot widen.
try:
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
return [_read_run(r) for r in store.list_runs(session, flow_id, user.tenant_id)]
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@flows_router.get("/langflow/available")
async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
async def list_langflow_flows(
langflow: Annotated[LangflowClient, Depends(get_langflow)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Browse flows on the Langflow authoring server (admin import source)."""
try:
return await langflow.list_flows()
except LangflowUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
async def import_flow(
data: FlowImportRequest,
session: Session = Depends(get_db),
langflow: LangflowClient = Depends(get_langflow),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
langflow: Annotated[LangflowClient, Depends(get_langflow)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
_admin: Annotated[User, Depends(require_admin)],
):
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
try:
@ -355,9 +394,11 @@ async def import_flow(
except LangflowNotFound as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except LangflowUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("langflow unavailable: %s", exc)
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
except LangflowError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.warning("langflow error: %s", exc)
raise HTTPException(status_code=502, detail="authoring server error") from exc
except store.ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except store.InvalidError as exc:
@ -367,33 +408,70 @@ async def import_flow(
except LfxConflict as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except LfxUnavailable as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
logger.warning("lfx unavailable: %s", exc)
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
except LfxError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return FlowImportRead(
definition=_read_definition(definition), version=_read_version(version)
logger.warning("lfx error: %s", exc)
raise HTTPException(status_code=502, detail="flow runtime error") from exc
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
def get_input_schema(
flow_id: UUID,
session: Annotated[Session, Depends(get_db)],
):
"""Form schema of the latest deployed version (404 when none)."""
try:
version = store.get_latest_deployed_version(session, flow_id)
except (store.NotFoundError, store.InvalidError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
return InputSchemaRead(
flow_id=flow_id,
version_no=version.version_no,
json_schema=cached.get("json_schema", {}),
ui_schema=cached.get("ui_schema", {}),
fallback=cached.get("fallback"),
)
@flows_router.get("/runs/{run_id}", response_model=RunRead)
def get_run(
run_id: UUID,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
"""Read a run. Other tenants' runs 404 (strict session scoping)."""
try:
run = store.get_run(session, run_id)
except store.NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
if tenant_id is not None and run.tenant_id != tenant_id:
if run.tenant_id != user.tenant_id:
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
return _read_run(run)
CALLBACK_TOKEN_HEADER = "X-Callback-Token"
def _check_callback_token(token: str | None) -> None:
"""LFX-served flows prove they carry our run envelope (no session there)."""
expected = settings.callback_token
if not expected:
raise HTTPException(status_code=503, detail="flow callbacks are disabled")
if not token or not secrets.compare_digest(token, expected):
raise HTTPException(status_code=401, detail="invalid callback token")
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
def create_approval(
data: ApprovalCreate,
session: Annotated[Session, Depends(get_db)],
callback_token: Annotated[str | None, Header(alias=CALLBACK_TOKEN_HEADER)] = None,
):
"""Flow callback contract: report a terminal approval state for a run."""
_check_callback_token(callback_token)
try:
return _read_task(store.create_approval_task(session, data))
except store.NotFoundError as exc:
@ -406,20 +484,25 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
@approvals_router.get("", response_model=list[ApprovalRead])
def list_approvals(
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_db),
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
return [_read_task(t) for t in store.list_approval_tasks(session, user.tenant_id)]
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
async def decide_approval(
task_id: UUID,
data: DecisionRequest,
session: Session = Depends(get_db),
lfx: LfxClient = Depends(get_lfx),
session: Annotated[Session, Depends(get_db)],
lfx: Annotated[LfxClient, Depends(get_lfx)],
user: Annotated[User, Depends(get_current_user)],
):
"""Decide an open task. Approve starts the continuation flow (if any)."""
"""Decide an open task. Approve starts the continuation flow (if any).
Identity comes from the session: the approver is the logged-in user,
and the continuation is the task's own (never client-chosen).
"""
try:
task = store.get_task(session, task_id)
except store.NotFoundError as exc:
@ -439,7 +522,7 @@ async def decide_approval(
session,
task,
decision="approved" if data.decision == "approve" else "rejected",
approver=data.approver,
approver=user.email,
note=data.note,
)
except store.ConflictError as exc:
@ -461,7 +544,8 @@ async def decide_approval(
payload=payload,
tenant_id=task.tenant_id,
session_id=f"continuation-of-{task.run_id}",
callback_base_url=data.callback_base_url or settings.callback_base_url,
callback_base_url=settings.callback_base_url,
callback_token=settings.callback_token,
)
except LfxNotFound as exc:
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc

View file

@ -7,7 +7,7 @@ from sqlmodel import Session
from app.models import FlowDefinition, FlowVersion
from app.settings import settings
from . import export, store, sync
from . import export, sync
from .langflow_client import LangflowClient
from .lfx_client import LfxClient

View file

@ -24,9 +24,7 @@ def slugify(name: str) -> str:
def canonical_bytes(doc: dict) -> bytes:
"""Deterministic serialization: stable bytes for stable git diffs."""
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode(
"utf-8"
)
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
def content_hash(doc: dict) -> str:
@ -38,9 +36,7 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
return Path(flows_dir) / f"{stem}.json"
def write_flow_file(
flows_dir: str | Path, doc: dict, *, name: str | None = None
) -> Path:
def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path:
"""Write a flow document to backend/flows atomically (tmp + rename).
The filename derives from `name` (or the document's own name). Returns
@ -50,9 +46,7 @@ def write_flow_file(
flows_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(name or doc.get("name") or "flow")
target = flow_path(flows_dir, stem)
fd, tmp_name = tempfile.mkstemp(
dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp"
)
fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(canonical_bytes(doc))

View file

@ -0,0 +1,152 @@
"""Derive a web-form schema from a Langflow export.
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
``display_name`` starting with ``CustomInput`` — see
``backend/components/kirche/form_inputs.py``) and maps their ``template``
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
* ``name`` → property key, ``display_name`` → ``title``,
``info`` → ``description``, ``value`` → ``default``,
``options`` → ``enum``, ``required`` → required array
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
form fields, and are skipped
* ``advanced`` fields render last via ``ui:order``
* ``_input_type`` → JSON type + widget (unknown types fall back to string
so the parser never crashes on new fields)
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
renders one free-text box instead of a generated form.
"""
from __future__ import annotations
def _form_templates(langflow_json: dict) -> list[dict]:
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
if not isinstance(langflow_json, dict):
return []
data = langflow_json.get("data") or {}
nodes = data.get("nodes") or []
found = []
for node in nodes:
if not isinstance(node, dict):
continue
node_data = node.get("data") or {}
node_type = str(node_data.get("type") or "")
inner = node_data.get("node") or {}
display = str(inner.get("display_name") or "")
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
found.append(inner)
return found
def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
"""(JSON-Schema property, uiSchema entry) for one template field."""
input_type = str(field.get("_input_type") or "")
prop: dict = {}
ui: dict = {}
title = field.get("display_name") or name
info = field.get("info") or ""
if title:
prop["title"] = title
if info:
prop["description"] = info
options = field.get("options") or []
if input_type in ("StrInput", "MessageTextInput"):
prop["type"] = "string"
elif input_type == "MultilineInput":
prop["type"] = "string"
ui["ui:widget"] = "textarea"
elif input_type == "SecretStrInput":
prop["type"] = "string"
ui["ui:widget"] = "password"
elif input_type == "IntInput":
prop["type"] = "integer"
elif input_type == "FloatInput":
prop["type"] = "number"
elif input_type == "BoolInput":
prop["type"] = "boolean"
elif input_type == "DropdownInput":
prop["type"] = "string"
if options:
prop["enum"] = list(options)
elif input_type == "MultiselectInput":
prop["type"] = "array"
items: dict = {"type": "string"}
if options:
items["enum"] = list(options)
prop["items"] = items
prop["uniqueItems"] = True
elif input_type == "SliderInput":
prop["type"] = "number"
spec = field.get("range_spec") or {}
if spec.get("min") is not None:
prop["minimum"] = spec["min"]
if spec.get("max") is not None:
prop["maximum"] = spec["max"]
if spec.get("step"):
prop["multipleOf"] = spec["step"]
ui["ui:widget"] = "range"
elif input_type == "FileInput":
# Stub for now: string reference only, real upload comes later.
prop["type"] = "string"
else:
prop["type"] = "string"
value = field.get("value")
if value is not None:
# Whitespace-only string values are the flow generator's sentinel
# for "required, filled at runtime" (see make_flow_fixtures.py) —
# not a real default for the web form.
if isinstance(value, str) and not value.strip():
return prop, ui
# A default outside its enum would fail validation on touch.
enum = prop.get("enum")
if not (enum is not None and value not in enum):
# bool is an int subclass; keep integer defaults clean.
if prop.get("type") == "integer" and isinstance(value, bool):
pass
else:
prop["default"] = value
return prop, ui
def extract_form_schema(langflow_json: dict) -> dict:
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
templates = _form_templates(langflow_json)
if not templates:
return {
"json_schema": {"type": "object", "properties": {}},
"ui_schema": {},
"fallback": "textarea",
}
template = templates[0].get("template") or {}
properties: dict = {}
required: list[str] = []
ui_schema: dict = {}
normal: list[str] = []
advanced: list[str] = []
for key, field in template.items():
if key == "code" or not isinstance(field, dict):
continue
if field.get("show") is False:
continue
name = field.get("name") or key
prop, ui = _field_schema(name, field)
properties[name] = prop
if field.get("required"):
required.append(name)
if ui:
ui_schema[name] = ui
(advanced if field.get("advanced") else normal).append(name)
if advanced:
ui_schema["ui:order"] = normal + advanced + ["*"]
return {
"json_schema": {
"type": "object",
"properties": properties,
"required": required,
},
"ui_schema": ui_schema,
}

View file

@ -51,9 +51,7 @@ class LfxRunResult:
class LfxClient:
"""Thin async wrapper around the LFX serve HTTP API."""
def __init__(
self, base_url: str, timeout: float = 120.0, api_key: str | None = None
) -> None:
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.api_key = api_key

View file

@ -20,15 +20,15 @@ def build_envelope(
run_id: UUID,
callback_base_url: str,
continuation_flow_id: UUID | None,
callback_token: str,
) -> str:
return json.dumps(
{
"payload": payload,
"run_id": str(run_id),
"callback_url": callback_base_url.rstrip("/"),
"continuation_flow_id": (
str(continuation_flow_id) if continuation_flow_id else None
),
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
"callback_token": callback_token,
}
)
@ -36,9 +36,7 @@ def build_envelope(
def _task_exists(session: Session, run_id: UUID) -> bool:
return (
session.exec(
select(ApprovalTask).where(
ApprovalTask.run_id == run_id, ApprovalTask.status == "open"
)
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
).first()
is not None
)
@ -54,6 +52,7 @@ async def execute_run(
session_id: str | None = None,
callback_base_url: str,
continuation_flow_id: UUID | None = None,
callback_token: str = "",
) -> FlowRun:
"""Run a deployed version to a terminal state (no interrupts).
@ -72,6 +71,7 @@ async def execute_run(
run_id=run.id,
callback_base_url=callback_base_url,
continuation_flow_id=continuation_flow_id,
callback_token=callback_token,
)
try:
result = await lfx.run_flow(
@ -80,9 +80,7 @@ async def execute_run(
session_id=session_id or str(run.id),
)
except LfxRunFailed as exc:
return store.set_run_result(
session, run, "failed", {"error": str(exc)}
)
return store.set_run_result(session, run, "failed", {"error": str(exc)})
output = {
"result": result.result,
"component": result.component,

View file

@ -37,19 +37,31 @@ class FlowVersionRead(BaseModel):
deployed_at: datetime | None
author: str
created_at: datetime
input_schema: dict | None = None
class InputSchemaRead(BaseModel):
"""Form schema of the latest deployed version (RJSF renders it)."""
flow_id: UUID
version_no: int
json_schema: dict
ui_schema: dict = Field(default_factory=dict)
# Set when the flow has no CustomInput node: render a free-text box.
fallback: str | None = None
class RunRequest(BaseModel):
"""Start a flow run. `input` is the domain payload handed to the flow."""
"""Start a flow run. `input` is the domain payload handed to the flow.
Tenant, callback URL and continuation come from the server (session /
settings / task), never from the caller — they must not be
client-controllable (SSRF / cross-flow chaining).
"""
input: dict = Field(default_factory=dict)
version_no: int | None = None
tenant_id: str = Field(default="default", max_length=200)
session_id: str | None = None
# Base URL the flow uses for callbacks (e.g. approval tasks).
# Defaults to settings.callback_base_url; tests point it at a live server.
callback_base_url: str | None = None
continuation_flow_id: UUID | None = None
class RunRead(BaseModel):
@ -103,10 +115,10 @@ class FlowImportRead(BaseModel):
class DecisionRequest(BaseModel):
"""Decide a task. Identity comes from the session, not the body."""
decision: str = Field(pattern="^(approve|reject)$")
approver: str = Field(default="", max_length=200)
note: str = Field(default="", max_length=2000)
callback_base_url: str | None = None
class DecisionRead(BaseModel):

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy.exc import IntegrityError
@ -10,6 +10,7 @@ from sqlmodel import Session, func, select
from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from . import input_schema as form_schema
from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate
@ -26,9 +27,10 @@ class InvalidError(Exception):
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
if data.continuation_flow_id is not None and session.get(
FlowDefinition, data.continuation_flow_id
) is None:
if (
data.continuation_flow_id is not None
and session.get(FlowDefinition, data.continuation_flow_id) is None
):
raise InvalidError("continuation_flow_id unknown")
definition = FlowDefinition(
name=data.name,
@ -56,18 +58,14 @@ def list_definitions(session: Session) -> list[FlowDefinition]:
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
def get_definition_by_source_path(
session: Session, source_path: str
) -> FlowDefinition | None:
def get_definition_by_source_path(session: Session, source_path: str) -> FlowDefinition | None:
"""Find the definition synced from a given backend/flows filename."""
return session.exec(
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
).first()
def set_definition_name(
session: Session, definition: FlowDefinition, name: str
) -> FlowDefinition:
def set_definition_name(session: Session, definition: FlowDefinition, name: str) -> FlowDefinition:
"""Rename a definition and record its source file. Name clashes 409."""
definition.name = name
session.add(definition)
@ -87,32 +85,22 @@ def next_version_no(session: Session, flow_id: UUID) -> int:
return (max_no or 0) + 1
def delete_definition_cascade(
session: Session, definition: FlowDefinition
) -> dict[str, int]:
def delete_definition_cascade(session: Session, definition: FlowDefinition) -> dict[str, int]:
"""Remove a definition and its whole subtree (tasks, runs, versions).
Used when the canonical file disappears: the directory is the source of
truth, so the derived registry follows. Commits once.
"""
version_ids = list(
session.exec(
select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)
).all()
session.exec(select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)).all()
)
task_count = 0
run_count = 0
if version_ids:
tasks = list(
session.exec(
select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))
).all()
)
runs = list(
session.exec(
select(FlowRun).where(FlowRun.version_id.in_(version_ids))
).all()
session.exec(select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))).all()
)
runs = list(session.exec(select(FlowRun).where(FlowRun.version_id.in_(version_ids))).all())
task_count, run_count = len(tasks), len(runs)
for row in (*tasks, *runs):
session.delete(row)
@ -141,9 +129,7 @@ def validate_flow_json(flow_json: dict) -> None:
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
def create_version(
session: Session, flow_id: UUID, data: FlowVersionCreate
) -> FlowVersion:
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
get_definition(session, flow_id)
validate_flow_json(data.langflow_json)
max_no = session.exec(
@ -154,6 +140,9 @@ def create_version(
version_no=(max_no or 0) + 1,
langflow_json=data.langflow_json,
author=data.author,
# Single choke point: sync, import, and API creation all cache the
# form schema here, so reads never re-parse.
input_schema=form_schema.extract_form_schema(data.langflow_json),
)
session.add(version)
session.commit()
@ -203,7 +192,7 @@ def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion:
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
version.lfx_flow_id = lfx_flow_id
version.deployed_at = datetime.now(timezone.utc)
version.deployed_at = datetime.now(UTC)
session.add(version)
session.commit()
session.refresh(version)
@ -230,12 +219,10 @@ def create_run(
return run
def set_run_result(
session: Session, run: FlowRun, status: str, output: dict | None
) -> FlowRun:
def set_run_result(session: Session, run: FlowRun, status: str, output: dict | None) -> FlowRun:
run.status = status
run.output = output
run.finished_at = datetime.now(timezone.utc)
run.finished_at = datetime.now(UTC)
session.add(run)
session.commit()
session.refresh(run)
@ -249,9 +236,7 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
return run
def list_runs(
session: Session, flow_id: UUID, tenant_id: str | None = None
) -> list[FlowRun]:
def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) -> list[FlowRun]:
get_definition(session, flow_id)
query = (
select(FlowRun)
@ -268,9 +253,10 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
run = session.get(FlowRun, data.run_id)
if run is None:
raise NotFoundError(f"run {data.run_id} not found")
if data.continuation_flow_id is not None and session.get(
FlowDefinition, data.continuation_flow_id
) is None:
if (
data.continuation_flow_id is not None
and session.get(FlowDefinition, data.continuation_flow_id) is None
):
raise InvalidError("continuation_flow_id unknown")
existing = session.exec(
select(ApprovalTask).where(
@ -289,7 +275,7 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
)
session.add(task)
run.status = "pending_approval"
run.finished_at = datetime.now(timezone.utc)
run.finished_at = datetime.now(UTC)
session.add(run)
session.commit()
session.refresh(task)
@ -326,7 +312,7 @@ def decide_task(
task.status = decision
task.approver = approver
task.decision_note = note
task.decided_at = datetime.now(timezone.utc)
task.decided_at = datetime.now(UTC)
session.add(task)
session.commit()
session.refresh(task)

View file

@ -41,9 +41,7 @@ def lfx_id_for(definition_id: UUID, version_no: int) -> str:
Same (definition, version) always maps to the same id, so re-syncs and
crash recovery overwrite instead of orphaning LFX registrations.
"""
return str(
uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}")
)
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}"))
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
@ -72,9 +70,7 @@ async def sync_document(
with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename)
if definition is None:
definition = store.create_definition(
session, FlowDefinitionCreate(name=name)
)
definition = store.create_definition(session, FlowDefinitionCreate(name=name))
definition.source_path = source_filename
session.add(definition)
session.commit()
@ -86,10 +82,8 @@ async def sync_document(
store.set_definition_name(session, definition, name)
latest = _latest_version(session, definition.id)
if (
latest is not None
and export.content_hash(latest.langflow_json)
== export.content_hash(doc)
if latest is not None and export.content_hash(latest.langflow_json) == export.content_hash(
doc
):
return definition, latest, "skipped"
@ -110,15 +104,9 @@ async def sync_document(
FlowVersionCreate(langflow_json=doc, author=author),
)
if version.version_no != version_no: # pragma: no cover - lock prevents this
raise store.ConflictError(
f"concurrent version write for flow {definition.id}"
)
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
version = store.mark_deployed(session, version, uploaded.id)
if (
latest is not None
and latest.lfx_flow_id
and latest.lfx_flow_id != uploaded.id
):
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
try:
await lfx.delete_flow(latest.lfx_flow_id)
except LfxError as exc:
@ -175,10 +163,8 @@ async def sync_from_directory(
report.errors[filename] = "top-level JSON must be an object"
continue
try:
_, _, action = await sync_document(
session, lfx, doc, source_filename=filename
)
except Exception as exc: # noqa: BLE001 - reconciliation is total
_, _, action = await sync_document(session, lfx, doc, source_filename=filename)
except (store.InvalidError, store.ConflictError, store.NotFoundError, LfxError) as exc:
report.errors[filename] = str(exc)
continue
getattr(report, action).append(path.stem)
@ -193,7 +179,7 @@ async def sync_from_directory(
continue
try:
await delete_definition_tree(session, lfx, definition)
except Exception as exc: # noqa: BLE001 - reconciliation is total
except store.NotFoundError as exc:
report.errors[stale] = str(exc)
continue
report.deleted.append(Path(stale).stem)
@ -205,9 +191,7 @@ def default_session_factory() -> Callable[[], Session]:
def default_lfx_factory() -> Callable[[], LfxClient]:
return lambda: LfxClient(
settings.lfx_url, api_key=settings.lfx_api_key or None
)
return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
def run_sync_blocking(
@ -246,14 +230,12 @@ async def watch_forever(
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
try:
await asyncio.wait_for(stop.wait(), timeout=30)
except asyncio.TimeoutError:
except TimeoutError:
pass
if stop.is_set():
return
logger.info("watching %s", flows_dir)
async for _ in awatch(
flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100
):
async for _ in awatch(flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100):
if stop.is_set():
break
try:
@ -261,7 +243,7 @@ async def watch_forever(
except FileNotFoundError as exc:
logger.error("flow sync failed: %s", exc)
continue
except Exception: # noqa: BLE001 - watcher must survive
except Exception:
logger.exception("flow sync failed")
continue
logger.info(

View file

@ -5,9 +5,12 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router
from .flows.sync import run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .settings import settings
logger = logging.getLogger(__name__)
@ -29,8 +32,8 @@ def _blocking_startup_sync() -> None:
logger.warning("startup flow sync: %s: %s", filename, reason)
except FileNotFoundError as exc:
logger.error("startup flow sync skipped: %s", exc)
except Exception: # noqa: BLE001 - API must start even if sync fails
logger.exception("startup flow sync failed")
except Exception:
logger.exception("startup flow sync failed") # never fatal at boot
@asynccontextmanager
@ -49,7 +52,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
except asyncio.CancelledError:
pass
@ -57,14 +60,25 @@ app = FastAPI(
title="Kirche Einfach API",
description="KI-gestützte Verwaltungsplattform für Kirche",
version="0.1.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
# Interactive docs only in debug: /api/docs maps the whole surface.
docs_url="/api/docs" if settings.debug else None,
redoc_url="/api/redoc" if settings.debug else None,
openapi_url="/api/openapi.json" if settings.debug else None,
lifespan=lifespan,
)
# TLS terminates at the reverse proxy: uvicorn --proxy-headers (see
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
# IPs (throttle) and absolute URLs are correct. The API is not directly
# internet-reachable (proxy-only), so trusting them is safe.
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
app.add_middleware(RunThrottleMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.include_router(flows_router)
app.include_router(approvals_router)
app.include_router(access_router)
app.include_router(auth_router)
@app.get("/")

92
backend/app/middleware.py Normal file
View file

@ -0,0 +1,92 @@
"""Edge hardening: run throttling, security headers.
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
main.py). All limits are settings-driven so tests can shrink them. State
is in-process memory: correct for the single-replica SQLite deployment;
revisit with Redis if the API ever scales horizontally.
"""
from __future__ import annotations
import time
from collections import defaultdict
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.settings import settings
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
# per client IP and minute.
RUN_PATH_SUFFIX = "/run"
THROTTLE_WINDOW_SECONDS = 60.0
_hits: dict[str, list[float]] = defaultdict(list)
def reset_throttle() -> None:
"""Drop all counters (tests)."""
_hits.clear()
def _client_ip(request: Request) -> str:
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
# scope["client"] from X-Forwarded-For.
if request.client is not None:
return request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
return forwarded.split(",")[0].strip() or "unknown"
class RunThrottleMiddleware:
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] != "http"
or scope.get("method") != "POST"
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
):
await self.app(scope, receive, send)
return
request = Request(scope)
now = time.monotonic()
window_start = now - THROTTLE_WINDOW_SECONDS
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
if len(bucket) >= settings.run_throttle_per_minute:
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
response.headers["Retry-After"] = str(retry_after)
await response(scope, receive, send)
return
bucket.append(now)
_hits[_client_ip(request)] = bucket
await self.app(scope, receive, send)
class SecurityHeadersMiddleware:
"""Baseline response headers (TLS/HSTS live on the proxy)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def secured(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(raw=message.setdefault("headers", []))
headers["X-Content-Type-Options"] = "nosniff"
headers["X-Frame-Options"] = "SAMEORIGIN"
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
await send(message)
await self.app(scope, receive, secured)

View file

@ -1,3 +1,13 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from .user import AccessRequest, MagicLinkToken, User, UserSession
__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"]
__all__ = [
"AccessRequest",
"ApprovalTask",
"FlowDefinition",
"FlowRun",
"FlowVersion",
"MagicLinkToken",
"User",
"UserSession",
]

View file

@ -1,6 +1,6 @@
"""Flow engine domain models (SQLModel tables)."""
from datetime import datetime, timezone
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
def _ts_column() -> Column:
@ -33,9 +33,7 @@ class FlowDefinition(SQLModel, table=True):
# (e.g. "simple_rule.json"). None for definitions created via the API.
source_path: str | None = Field(default=None, max_length=255)
# Flow to run after a human approval on this flow's runs (nullable).
continuation_flow_id: UUID | None = Field(
default=None, foreign_key="flow_definitions.id"
)
continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
@ -54,6 +52,9 @@ class FlowVersion(SQLModel, table=True):
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
author: str = Field(default="", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
# Cached form schema for this exact JSON (see app/flows/input_schema.py).
# Computed at version creation; nullable for pre-Phase-3 rows.
input_schema: dict | None = Field(default=None, sa_column=_json_column())
class FlowRun(SQLModel, table=True):

View file

@ -0,0 +1,72 @@
"""Access-management domain models (SQLModel tables)."""
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime
from sqlmodel import Field, SQLModel
def utcnow() -> datetime:
return datetime.now(UTC)
def _ts_column() -> Column:
return Column(DateTime(timezone=True))
class User(SQLModel, table=True):
"""A person with API access. Role is member or admin."""
__tablename__ = "users"
id: UUID = Field(default_factory=uuid4, primary_key=True)
email: str = Field(index=True, unique=True, max_length=320)
name: str = Field(default="", max_length=200)
# member | admin
role: str = Field(default="member", max_length=20)
tenant_id: str = Field(default="default", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class AccessRequest(SQLModel, table=True):
"""A public request for access; an admin approves or rejects it."""
__tablename__ = "access_requests"
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(max_length=200)
email: str = Field(index=True, max_length=320)
reason: str = Field(default="", max_length=2000)
# pending | approved | rejected
status: str = Field(default="pending", index=True, max_length=20)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
decided_by: str | None = Field(default=None, max_length=320)
class MagicLinkToken(SQLModel, table=True):
"""A single-use 15-minute login token. Only the sha256 is stored."""
__tablename__ = "magic_link_tokens"
id: UUID = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=_ts_column())
# Set before the session row is created, so a reused token can never
# mint a second session.
used_at: datetime | None = Field(default=None, sa_column=_ts_column())
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
class UserSession(SQLModel, table=True):
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
__tablename__ = "user_sessions"
id: str = Field(primary_key=True, max_length=128)
user_id: UUID = Field(foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
expires_at: datetime = Field(sa_column=_ts_column())
revoked_at: datetime | None = Field(default=None, sa_column=_ts_column())

View file

@ -14,7 +14,9 @@ class Settings(BaseSettings):
debug: bool = False
# App database (SQLite file). Override via DATABASE_URL for other engines.
database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db")
# Must be a URL (not a bare path): db.py and Alembic hand it to
# create_engine, which rejects bare paths.
database_url: str = f"sqlite:///{REPO_ROOT / 'backend' / 'data' / 'kirche.db'}"
# Canonical flow definitions (source of truth): one Langflow export per file.
flows_dir: str = str(REPO_ROOT / "backend" / "flows")
@ -29,6 +31,31 @@ class Settings(BaseSettings):
# Base URL flows use for callbacks into this API. Must be reachable
# FROM the LFX process (compose: http://api:8000).
callback_base_url: str = "http://localhost:8000"
# Shared secret that authenticates flow callbacks (POST /api/approvals).
# The runner injects it into every run envelope; LFX-served flows send
# it back as X-Callback-Token. Empty disables callbacks (503) — set a
# long random value in prod (.env CALLBACK_TOKEN).
callback_token: str = ""
# Edge hardening (see app/middleware.py).
max_body_bytes: int = 2 * 1024 * 1024
run_throttle_per_minute: int = 60
# --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there).
frontend_url: str = "http://localhost:3000"
# Comma-separated emails that become admins (role assignment + bootstrap).
admin_emails: str = ""
# Reply-To on outgoing mail; also receives access-request notifications.
contact_to: str = ""
mail_from: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
@property
def admin_email_list(self) -> list[str]:
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
settings = Settings()

View file

@ -0,0 +1,58 @@
# Authoring web forms (CustomInputForm)
Flows that should render a web form start with a `CustomInputForm` node
(this package — the `kirche` category in the Langflow palette). The API
derives a JSON Schema + uiSchema from the node's template
(`app/flows/input_schema.py`) and the webapp renders it with RJSF.
## Adding a new form field
1. Open the Langflow authoring UI (http://localhost:7860) and load the
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
one web-form field. Add an `lfx.io` input, e.g.
```python
from lfx.io import DropdownInput
DropdownInput(
name="room", # JSON-Schema property key
display_name="Room", # form label (title)
info="Where should it take place", # help text (description)
options=["Chapel", "Hall"], # dropdown choices (enum)
value="Chapel", # prefill (default)
required=True, # marks the field required
)
```
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
`MultilineInput` → textarea, `SecretStrInput` → password,
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
`DropdownInput` → select, `MultiselectInput` → multi-select,
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
(real upload later).
3. Add the new field to `collect()` so it reaches the JSON output.
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
input, `show=False`). Submitted web-form values arrive there as
`{"payload": {...}}` and are merged over the defaults. Never make form
fields required *with* an incoming edge — that's the envelope's job.
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
The API watcher syncs + deploys it automatically; the form schema is
regenerated per version.
## Conventions the pipeline relies on
- `required=True` on a template field: `lfx validate --level 4` demands a
value or an edge, so the canonical-flow generator
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
sentinel value for such fields. The form parser treats whitespace-only
values as "no default" — don't ship real whitespace defaults.
- Field key = `name` (stable); label = `display_name`; help = `info`.
- Unknown future input types degrade to a plain text field in the web
form (parser fallback), so adding exotic lfx inputs never breaks runs.
## Regenerating the bundled fixtures
After editing the component source (`form_inputs.py`), refresh the
canonical + test-fixture exports:
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
docker compose exec -T api uv run pytest tests/test_lfx_validate.py

View file

@ -0,0 +1,12 @@
"""Kirche form-input components (Langflow category ``kirche``).
GUI-editable by design: plain :class:`lfx.custom.Component` subclasses with
``lfx.io`` inputs only (stdlib + ``lfx``), so both the Langflow authoring UI
and the LFX runtime can load them. The backend parses the exported node
template into a web form (``app/flows/input_schema.py``); the frontend
renders that schema with RJSF.
"""
from .form_inputs import CustomInputForm
__all__ = ["CustomInputForm"]

View file

@ -0,0 +1,162 @@
"""Structured form-input component for kirche-einfach flows.
A flow that starts with :class:`CustomInputForm` declares the fields of its
web form. The API reads the exported node template
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from
the ``lfx.io`` input definitions below — one JSON-Schema property per field,
keyed by ``name``. Field metadata maps as follows:
* ``display_name`` → ``title``, ``info`` → ``description``,
``value`` → ``default``, ``options`` → ``enum``
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
``FileInput`` is a stub for now: it carries a string reference only, real
upload comes later.
"""
from __future__ import annotations
import json
from typing import ClassVar
from lfx.custom import Component
from lfx.field_typing.range_spec import RangeSpec
from lfx.io import (
BoolInput,
DropdownInput,
FileInput,
FloatInput,
IntInput,
MessageTextInput,
MultilineInput,
MultiselectInput,
Output,
SecretStrInput,
SliderInput,
StrInput,
)
from lfx.schema.message import Message
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
class CustomInputForm(Component):
display_name = "CustomInput Form"
description = "Collects structured form values into a single JSON message."
icon = "ClipboardList"
inputs: ClassVar[list] = [
# Plumbing, not a form field: the API posts submitted web-form
# values as the run envelope (same {"payload": {...}} shape the
# other kirche components use); they arrive here through a ChatInput
# entry node and override the defaults below. Hidden in the GUI and
# skipped by the form parser (show=False).
MessageTextInput(
name="form_values",
display_name="Form values (JSON envelope)",
info="Submitted values as JSON; merged over the defaults",
value="",
show=False,
),
StrInput(
name="full_name",
display_name="Full name",
info="Your first and last name",
value="",
required=True,
),
MultilineInput(
name="notes",
display_name="Notes",
info="Anything else we should know",
value="",
),
SecretStrInput(
name="password",
display_name="Password",
info="Optional access phrase",
value="",
),
IntInput(
name="age",
display_name="Age",
info="Your age in years",
value=18,
),
FloatInput(
name="amount",
display_name="Amount",
info="Amount in euros",
value=0.0,
),
BoolInput(
name="newsletter",
display_name="Newsletter",
info="Send me the parish newsletter",
value=False,
),
DropdownInput(
name="department",
display_name="Department",
info="Which team is this for",
options=["Music", "Youth", "Seniors", "Admin"],
value="Music",
),
MultiselectInput(
name="topics",
display_name="Topics",
info="What are you interested in",
options=["Worship", "Outreach", "Courses"],
value=[],
),
SliderInput(
name="satisfaction",
display_name="Satisfaction",
info="How satisfied are you (1-5)",
value=3,
range_spec=RangeSpec(min=1, max=5, step=1),
),
FileInput(
name="attachment",
display_name="Attachment",
info="String reference only for now (no upload yet)",
file_types=["pdf", "png", "jpg"],
),
]
outputs: ClassVar[list] = [
Output(display_name="Form Data", name="form_data", method="collect"),
]
def collect(self) -> Message:
data = {
"full_name": _text(self.full_name),
"notes": _text(self.notes),
"password": _text(self.password),
"age": self.age,
"amount": self.amount,
"newsletter": self.newsletter,
"department": _text(self.department),
"topics": self.topics,
"satisfaction": self.satisfaction,
"attachment": _text(self.attachment),
}
# Submitted values (via form_values) win over the field defaults.
# Accepts the raw dict or the run envelope {"payload": {...}}.
try:
override = json.loads(_text(self.form_values) or "{}")
except json.JSONDecodeError:
override = {}
if isinstance(override, dict):
if isinstance(override.get("payload"), dict):
override = override["payload"]
for key in data:
if key in override:
data[key] = override[key]
return Message(text=json.dumps(data, ensure_ascii=False))

View file

@ -6,10 +6,24 @@ set -e
cd /srv/kirche-einfach/backend
.venv/bin/alembic upgrade head
# Runtime SQLite file lives in the bind-mount: ensure the dir exists and is
# owned by vscode (the entrypoint runs as root, but the editor runs as
# vscode — a root-owned data/ dir made the DB unwritable).
mkdir -p data
if id vscode >/dev/null 2>&1; then
chown -R vscode:vscode data 2>/dev/null || true
fi
/opt/venv/bin/alembic upgrade head
# Migrate ran as root: hand the (possibly just created) DB files back to
# vscode so editor-run commands stay writable.
if id vscode >/dev/null 2>&1; then
chown -R vscode:vscode data 2>/dev/null || true
fi
if [ "${APP_ENV:-prod}" = "dev" ]; then
exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers
else
exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers
fi

View file

@ -292,7 +292,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -399,6 +399,20 @@
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
},
{
"types": [
"Message"
],
"selected": "Message",
"name": "callback_token",
"display_name": "Callback Token",
"method": "get_callback_token",
"value": "__UNDEFINED__",
"cache": true,
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
}
],
"field_order": [
@ -409,7 +423,7 @@
"edited": false,
"metadata": {
"module": "custom_components.envelope",
"code_hash": "2b0bd3c3d529",
"code_hash": "02e69613e210",
"dependencies": {
"total_dependencies": 1,
"dependencies": [
@ -858,6 +872,32 @@
"node": {
"template": {
"_type": "Component",
"callback_token": {
"tool_mode": false,
"trace_as_input": true,
"trace_as_metadata": true,
"load_from_db": false,
"list": false,
"list_add_label": "Add More",
"override_skip": false,
"required": false,
"placeholder": "",
"show": true,
"name": "callback_token",
"value": "",
"display_name": "Callback Token",
"advanced": false,
"api_editable": false,
"input_types": [
"Message"
],
"dynamic": false,
"info": "",
"title_case": false,
"track_in_telemetry": false,
"type": "str",
"_input_type": "MessageTextInput"
},
"callback_url": {
"tool_mode": false,
"trace_as_input": true,
@ -891,7 +931,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -1040,6 +1080,7 @@
"trigger",
"run_id",
"callback_url",
"callback_token",
"continuation_flow_id",
"facts"
],
@ -1048,7 +1089,7 @@
"edited": false,
"metadata": {
"module": "custom_components.approval_callback",
"code_hash": "a334376410f7",
"code_hash": "28b2f5894566",
"dependencies": {
"total_dependencies": 2,
"dependencies": [
@ -1804,6 +1845,34 @@
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",
"data": {
"sourceHandle": {
"dataType": "CustomComponent",
"id": "CustomComponent-envelope",
"name": "callback_token",
"output_types": [
"Message"
]
},
"targetHandle": {
"fieldName": "callback_token",
"id": "CustomComponent-callback",
"inputTypes": [
"Message"
],
"type": "str"
}
},
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
"selected": false,
"source": "CustomComponent-envelope",
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,7 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@ -16,10 +14,11 @@ if config.config_file_name is not None:
# add your model's MetaData object here
# for 'autogenerate' support
from app.models import * # noqa: F401,F403 - register all tables
from app.settings import settings
from sqlmodel import SQLModel
from app.models import *
from app.settings import settings
target_metadata = SQLModel.metadata
# The URL always comes from settings (repo-root .env or environment),
@ -70,9 +69,7 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

View file

@ -1,108 +1,133 @@
"""flow engine tables
Revision ID: 62ea9a5d8f9d
Revises:
Revises:
Create Date: 2026-09-06 22:27:07.129788
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '62ea9a5d8f9d'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "62ea9a5d8f9d"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('flow_definitions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"flow_definitions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["continuation_flow_id"],
["flow_definitions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True)
op.create_table('flow_versions',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('flow_id', sa.Uuid(), nullable=False),
sa.Column('version_no', sa.Integer(), nullable=False),
sa.Column('langflow_json', sa.JSON(), nullable=True),
sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column('deployed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('flow_id', 'version_no')
op.create_index(op.f("ix_flow_definitions_name"), "flow_definitions", ["name"], unique=True)
op.create_table(
"flow_versions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("flow_id", sa.Uuid(), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False),
sa.Column("langflow_json", sa.JSON(), nullable=True),
sa.Column("lfx_flow_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("author", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["flow_id"],
["flow_definitions.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("flow_id", "version_no"),
)
op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False)
op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False)
op.create_table('flow_runs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('version_id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
sa.Column('input', sa.JSON(), nullable=True),
sa.Column('output', sa.JSON(), nullable=True),
sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_index(op.f("ix_flow_versions_flow_id"), "flow_versions", ["flow_id"], unique=False)
op.create_index(
op.f("ix_flow_versions_version_no"), "flow_versions", ["version_no"], unique=False
)
op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False)
op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False)
op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False)
op.create_table('approval_tasks',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('run_id', sa.Uuid(), nullable=False),
sa.Column('version_id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
sa.Column('payload', sa.JSON(), nullable=True),
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ),
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"flow_runs",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("version_id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
sa.Column("input", sa.JSON(), nullable=True),
sa.Column("output", sa.JSON(), nullable=True),
sa.Column("session_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["version_id"],
["flow_versions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_flow_runs_status"), "flow_runs", ["status"], unique=False)
op.create_index(op.f("ix_flow_runs_tenant_id"), "flow_runs", ["tenant_id"], unique=False)
op.create_index(op.f("ix_flow_runs_version_id"), "flow_runs", ["version_id"], unique=False)
op.create_table(
"approval_tasks",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("run_id", sa.Uuid(), nullable=False),
sa.Column("version_id", sa.Uuid(), nullable=False),
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
sa.Column("approver", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("decision_note", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["run_id"],
["flow_runs.id"],
),
sa.ForeignKeyConstraint(
["version_id"],
["flow_versions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_approval_tasks_run_id"), "approval_tasks", ["run_id"], unique=False)
op.create_index(op.f("ix_approval_tasks_status"), "approval_tasks", ["status"], unique=False)
op.create_index(
op.f("ix_approval_tasks_tenant_id"), "approval_tasks", ["tenant_id"], unique=False
)
op.create_index(
op.f("ix_approval_tasks_version_id"), "approval_tasks", ["version_id"], unique=False
)
op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False)
op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False)
op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False)
op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks')
op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks')
op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks')
op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks')
op.drop_table('approval_tasks')
op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs')
op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs')
op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs')
op.drop_table('flow_runs')
op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions')
op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions')
op.drop_table('flow_versions')
op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions')
op.drop_table('flow_definitions')
op.drop_index(op.f("ix_approval_tasks_version_id"), table_name="approval_tasks")
op.drop_index(op.f("ix_approval_tasks_tenant_id"), table_name="approval_tasks")
op.drop_index(op.f("ix_approval_tasks_status"), table_name="approval_tasks")
op.drop_index(op.f("ix_approval_tasks_run_id"), table_name="approval_tasks")
op.drop_table("approval_tasks")
op.drop_index(op.f("ix_flow_runs_version_id"), table_name="flow_runs")
op.drop_index(op.f("ix_flow_runs_tenant_id"), table_name="flow_runs")
op.drop_index(op.f("ix_flow_runs_status"), table_name="flow_runs")
op.drop_table("flow_runs")
op.drop_index(op.f("ix_flow_versions_version_no"), table_name="flow_versions")
op.drop_index(op.f("ix_flow_versions_flow_id"), table_name="flow_versions")
op.drop_table("flow_versions")
op.drop_index(op.f("ix_flow_definitions_name"), table_name="flow_definitions")
op.drop_table("flow_definitions")
# ### end Alembic commands ###

View file

@ -0,0 +1,96 @@
"""auth tables (users, access requests, magic links, sessions)
Revision ID: a3f1c9e2b4d7
Revises: d932b6993f9a
Create Date: 2026-09-11 15:30:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a3f1c9e2b4d7"
down_revision: str | Sequence[str] | None = "d932b6993f9a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("role", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_table(
"access_requests",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
sa.Column("reason", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_by", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_access_requests_email"), "access_requests", ["email"], unique=False)
op.create_index(op.f("ix_access_requests_status"), "access_requests", ["status"], unique=False)
op.create_table(
"magic_link_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_magic_link_tokens_token_hash"), "magic_link_tokens", ["token_hash"], unique=True
)
op.create_index(
op.f("ix_magic_link_tokens_user_id"), "magic_link_tokens", ["user_id"], unique=False
)
op.create_table(
"user_sessions",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
op.drop_table("user_sessions")
op.drop_index(op.f("ix_magic_link_tokens_user_id"), table_name="magic_link_tokens")
op.drop_index(op.f("ix_magic_link_tokens_token_hash"), table_name="magic_link_tokens")
op.drop_table("magic_link_tokens")
op.drop_index(op.f("ix_access_requests_status"), table_name="access_requests")
op.drop_index(op.f("ix_access_requests_email"), table_name="access_requests")
op.drop_table("access_requests")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

View file

@ -0,0 +1,32 @@
"""cache form schema per flow version
Revision ID: b7e2d4a1c9f3
Revises: a3f1c9e2b4d7
Create Date: 2026-09-11 20:30:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b7e2d4a1c9f3"
down_revision: str | Sequence[str] | None = "a3f1c9e2b4d7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"flow_versions",
sa.Column("input_schema", sa.JSON(), nullable=True),
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("flow_versions", "input_schema")

View file

@ -5,18 +5,18 @@ Revises: 62ea9a5d8f9d
Create Date: 2026-09-07 08:40:48.986026
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'd932b6993f9a'
down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "d932b6993f9a"
down_revision: str | Sequence[str] | None = "62ea9a5d8f9d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View file

@ -27,9 +27,22 @@ testpaths = ["tests"]
[dependency-groups]
dev = [
"lfx",
# Pinned to the Langflow minor (contract: major.minor must match).
# Bump together with LANGFLOW_VERSION (.env.example) + Dockerfile.lfx.
"lfx~=1.12.0",
"pytest>=8",
"pytest-asyncio>=0.24",
"pytest-cov>=6",
"respx>=0.22",
"ruff>=0.16,<0.17",
]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
# Default rule set minus EXE: Docker Desktop bind-mounts report every file as
# executable (git index stays 100644), so EXE002 would fire on the dev mount
# but never on Linux CI. Everything else is enforced as ruff's default.
ignore = ["EXE002"]

View file

@ -0,0 +1,111 @@
"""Validate a prod `.env` file before (re)deploying.
Usage (from backend/ or repo root):
uv run python scripts/check_prod.py --env ../.env
uv run python scripts/check_prod.py # defaults to repo-root .env
Fails (exit 1) on missing/weak secrets and insecure modes; warns on
soft misconfigurations. Safe to run anywhere: reads one file, no imports
from app (stdlib only), prints no secret values.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
REPO_ROOT = BACKEND.parent
MIN_TOKEN = 32
MIN_PASSWORD = 16
MIN_API_KEY = 16
def parse_env(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("'\"")
if key.startswith("export "):
key = key[len("export ") :].strip()
values[key] = value
return values
def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
"""(failures, warnings) for a parsed env file."""
failures: list[str] = []
warnings: list[str] = []
def secret(name: str, minimum: int, what: str) -> None:
value = values.get(name, "")
if not value:
failures.append(f"{name} is empty — generate one: openssl rand -hex 32")
elif len(value) < minimum:
failures.append(f"{name} too short ({len(value)} < {minimum} chars)")
def placeholder(name: str, prefixes: tuple[str, ...]) -> None:
value = values.get(name, "")
if value and any(value == p or value.startswith(p) for p in prefixes):
failures.append(f"{name} still holds an example placeholder")
# Shared + service API keys.
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
placeholder("LANGFLOW_API_KEY", ("sk-",))
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
placeholder("LFX_API_KEY", ("lfx-",))
# Langflow's own auth (internet-facing authoring UI).
secret("LANGFLOW_SUPERUSER_PASSWORD", MIN_PASSWORD, "langflow superuser password")
secret("LANGFLOW_SECRET_KEY", MIN_TOKEN, "langflow secret key")
# Insecure modes.
if values.get("FRONTEND_MODE", "preview") == "dev":
failures.append("FRONTEND_MODE=dev serves the Vite dev server — use preview in prod")
if values.get("APP_ENV", "prod") == "dev":
failures.append("APP_ENV=dev enables autoreload — unset it in prod")
if values.get("LANGFLOW_AUTO_LOGIN", "false").lower() == "true":
failures.append("LANGFLOW_AUTO_LOGIN=true allows passwordless superuser access")
# Soft misconfigurations.
if not values.get("ADMIN_EMAILS", ""):
warnings.append("ADMIN_EMAILS empty — nobody can bootstrap an admin account")
if values.get("SMTP_HOST", "") and not values.get("SMTP_PASSWORD", ""):
warnings.append("SMTP_HOST without SMTP_PASSWORD — magic-link mail will fail")
if not values.get("CONTACT_TO", ""):
warnings.append("CONTACT_TO empty — access-request notifications go nowhere")
frontend_url = values.get("FRONTEND_URL", "")
if frontend_url and not frontend_url.startswith("https://"):
warnings.append("FRONTEND_URL is not https — cookies and tokens travel in cleartext")
return failures, warnings
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
args = parser.parse_args()
path = Path(args.env)
if not path.is_file():
print(f"no env file at {path}")
return 1
failures, warnings = check(parse_env(path))
for message in warnings:
print(f"WARN: {message}")
for message in failures:
print(f"FAIL: {message}")
if failures:
print(f"{len(failures)} blocking issue(s) — fix before deploying")
return 1
print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -87,6 +87,77 @@ def custom_node(
return node_dict
def kirche_node(
node_id: str,
values: dict | None = None,
position: tuple[int, int] = (0, 0),
) -> dict:
"""A node from our GUI-editable category lib (backend/components/kirche).
Built with lfx's own frontend-node serialization, exactly like
``Component.to_frontend_node()`` — minus its final ``Template.from_dict``
round-trip: lfx 1.12.x cannot re-instantiate ``MultiselectInput`` (it
emits ``list``, the constructor forbids extras; their own builtins fail
the same way). The template dict before that round-trip is complete and
faithful, so we use it directly and append the ``code`` field by hand
(mirroring the shape ``build_custom_component_template`` produces).
"""
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
from kirche.form_inputs import CustomInputForm
from lfx.template.frontend_node.custom_components import (
ComponentFrontendNode,
)
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
inst = CustomInputForm()
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
inst._map_parameters_on_frontend_node(frontend_node)
node = frontend_node.to_dict(keep_name=False)
node = inst._update_template(node)
inst._map_parameters_on_template(node["template"])
if not getattr(inst, "_code", None):
inst.set_class_code()
node["template"]["code"] = {
"advanced": True,
"api_editable": False,
"dynamic": True,
"fileTypes": [],
"info": "",
"list": False,
"load_from_db": False,
"multiline": True,
"name": "code",
"password": False,
"placeholder": "",
"required": True,
"show": True,
"title_case": False,
"type": "code",
"value": inst._code,
}
node_dict = {
"data": {"node": node, "type": comp_name, "id": node_id},
"id": node_id,
}
node_dict = _normalize(node_dict, node_id, comp_name, *position)
for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value
# lfx validate --level 4 errors on required inputs with no value and no
# incoming edge. Our form fields are filled at runtime via the
# form_values envelope (collect() merges), never via edges — so required
# template fields carry a single-space sentinel value. The form parser
# (app/flows/input_schema.py) treats whitespace-only values as "no
# default", keeping the web form clean.
for field_def in node_dict["data"]["node"]["template"].values():
if (
isinstance(field_def, dict)
and field_def.get("required")
and field_def.get("value") in (None, "", [], {})
):
field_def["value"] = " "
return node_dict
# --------------------------------------------------------------------------
# edges
# --------------------------------------------------------------------------
@ -135,9 +206,7 @@ def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
# --------------------------------------------------------------------------
# flow document
# --------------------------------------------------------------------------
def flow_doc(
name: str, description: str, nodes: list[dict], edges: list[dict]
) -> dict:
def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict]) -> dict:
return {
"data": {
"nodes": nodes,
@ -161,7 +230,7 @@ def flow_doc(
# --------------------------------------------------------------------------
# custom component code
# --------------------------------------------------------------------------
RULE_CHECK_CODE = '''from lfx.custom import Component
RULE_CHECK_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
@ -205,9 +274,9 @@ class RuleCheckComponent(Component):
except (TypeError, ValueError):
schwelle = 200000.0
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
'''
"""
ENVELOPE_CODE = '''from lfx.custom import Component
ENVELOPE_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
import json
@ -233,6 +302,11 @@ class EnvelopeComponent(Component):
name="continuation",
method="get_continuation",
),
Output(
display_name="Callback Token",
name="callback_token",
method="get_callback_token",
),
]
def _text(self, value) -> str:
@ -264,9 +338,12 @@ class EnvelopeComponent(Component):
def get_continuation(self) -> Message:
value = self._env().get("continuation_flow_id")
return Message(text=str(value) if value else "")
'''
APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
def get_callback_token(self) -> Message:
return Message(text=str(self._env().get("callback_token", "")))
"""
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
from lfx.io import MessageTextInput, Output
from lfx.schema.message import Message
@ -279,6 +356,10 @@ class ApprovalCallbackComponent(Component):
MessageTextInput(name="trigger", display_name="Trigger"),
MessageTextInput(name="run_id", display_name="Run ID"),
MessageTextInput(name="callback_url", display_name="Callback URL"),
MessageTextInput(
name="callback_token",
display_name="Callback Token",
),
MessageTextInput(
name="continuation_flow_id",
display_name="Continuation Flow ID",
@ -315,13 +396,14 @@ class ApprovalCallbackComponent(Component):
"payload": {"verdict": trigger, "facts": facts},
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
}
headers = {"X-Callback-Token": self._text(self.callback_token)}
try:
response = httpx.post(url, json=body, timeout=10.0)
response = httpx.post(url, json=body, headers=headers, timeout=10.0)
response.raise_for_status()
except Exception as exc:
return Message(text=f"CALLBACK_FAILED: {exc}")
return Message(text="PENDING_HUMAN_APPROVAL")
'''
"""
# --------------------------------------------------------------------------
@ -384,9 +466,7 @@ def build_approval_handoff() -> dict:
"ChatInput-entry",
position=(0, 300),
)
envelope = custom_node(
ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300)
)
envelope = custom_node(ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300))
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
router = builtin_node(
"lfx.components.flow_controls.conditional_router",
@ -420,6 +500,7 @@ def build_approval_handoff() -> dict:
edge(router, "true_result", callback, "trigger"),
edge(envelope, "run_id", callback, "run_id"),
edge(envelope, "callback_url", callback, "callback_url"),
edge(envelope, "callback_token", callback, "callback_token"),
edge(envelope, "continuation", callback, "continuation_flow_id"),
edge(envelope, "payload", callback, "facts"),
edge(callback, "result", out_pending, "input_value"),
@ -433,12 +514,43 @@ def build_approval_handoff() -> dict:
)
def build_intake_form() -> dict:
entry = builtin_node(
"lfx.components.input_output.chat",
"ChatInput",
"ChatInput-entry",
position=(0, 200),
)
form = kirche_node(
"CustomInputForm-intake",
position=(400, 200),
)
out = builtin_node(
"lfx.components.input_output.chat_output",
"ChatOutput",
"ChatOutput-collected",
position=(800, 200),
)
nodes = [entry, form, out]
edges = [
edge(entry, "message", form, "form_values"),
edge(form, "form_data", out, "input_value"),
]
return flow_doc(
"intake_form",
"Structured intake via CustomInputForm (parsed into a web form).",
nodes,
edges,
)
def main() -> None:
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
FLOW_DIR.mkdir(parents=True, exist_ok=True)
flows = {
"simple_rule.json": build_simple_rule(),
"approval_handoff.json": build_approval_handoff(),
"intake_form.json": build_intake_form(),
}
for filename, doc in flows.items():
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"

View file

@ -10,18 +10,31 @@ from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
VENV_BIN = BACKEND / ".venv" / "bin"
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
LFX_PORT = 6004
API_PORT = 8000
def _free_port() -> int:
# Ephemeral ports: the dev API/LFX may already occupy the defaults
# (e.g. when smoke runs inside the api container itself).
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
LFX_PORT = _free_port()
API_PORT = _free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
@ -41,6 +54,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
import httpx
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
# Management + runs are login-gated; the smoke admin session is
# planted directly in the throwaway DB (see main()).
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
r = c.request(method, path, json=body)
try:
return r.status_code, r.json()
@ -58,9 +74,7 @@ def main() -> None:
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
flow_dir = Path("/tmp/smoke-e2e-flows")
flow_dir.mkdir(exist_ok=True)
(flow_dir / "seed.json").write_text(
(FIXTURES / "simple_rule.json").read_text()
)
(flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
# Migrate a throwaway SQLite schema to head before starting the API.
from alembic import command as alembic_command
from alembic.config import Config
@ -69,8 +83,36 @@ def main() -> None:
cfg = Config()
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
cfg.set_main_option("sqlalchemy.url", db_url)
# migrations/env.py takes the URL from settings (not the ini value):
# point this process at the throwaway DB before migrating.
from app.settings import settings as app_settings
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head")
# Plant an admin session: management + runs require login, and there is
# no SMTP in smoke to deliver a magic link.
from datetime import UTC, datetime, timedelta
from sqlmodel import Session, create_engine
from app.models import User, UserSession
engine = create_engine(db_url)
with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin")
s.add(user)
s.flush()
s.add(
UserSession(
id="smoke-session",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
)
s.commit()
engine.dispose()
env = {
**os.environ,
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
@ -81,16 +123,29 @@ def main() -> None:
"FLOWS_WATCH_ENABLED": "false",
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
"CALLBACK_TOKEN": "smoke-callback-token",
}
lfx = subprocess.Popen(
[str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1",
"--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env,
[
str(VENV_BIN / "lfx"),
"serve",
"--host",
"127.0.0.1",
"--port",
str(LFX_PORT),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
uvicorn = subprocess.Popen(
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1",
"--port", str(API_PORT)],
cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
cwd=str(BACKEND),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
try:
@ -106,7 +161,8 @@ def main() -> None:
fx = json.loads((FIXTURES / "simple_rule.json").read_text())
s, ver = api(
"POST", f"/api/flows/{fid}/versions",
"POST",
f"/api/flows/{fid}/versions",
{"langflow_json": fx, "author": "smoke"},
)
check("create version", s == 201, f"v{ver.get('version_no')}")
@ -115,17 +171,25 @@ def main() -> None:
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
s, run = api(
"POST", f"/api/flows/{fid}/run",
"POST",
f"/api/flows/{fid}/run",
{"input": {"betrag": 120000, "schwelle": 200000}},
)
check("run standard", s == 200 and run.get("status") == "completed",
json.dumps(run.get("output"))[:120])
check(
"run standard",
s == 200 and run.get("status") == "completed",
json.dumps(run.get("output"))[:120],
)
s, run2 = api(
"POST", f"/api/flows/{fid}/run",
"POST",
f"/api/flows/{fid}/run",
{"input": {"betrag": 999000, "schwelle": 200000}},
)
check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""))
check(
"run sonderfall",
s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""),
)
s, tasks = api("GET", "/api/approvals")
check("approvals empty (no callback flow)", s == 200 and tasks == [])

View file

@ -19,7 +19,7 @@ from sqlmodel import Session
from app.db import get_engine, reset_engine
from app.flows import export, sync
from app.flows.langflow_client import LangflowClient
from app.flows.langflow_client import LangflowClient, LangflowError
from app.flows.lfx_client import LfxClient
from app.flows.schemas import SyncReport
from app.settings import settings
@ -38,7 +38,7 @@ async def pull_from_langflow(flows_dir: Path) -> dict:
name = doc.get("name") or flow_id
path = export.write_flow_file(flows_dir, doc, name=name)
pulled.append(path.stem)
except Exception as exc: # noqa: BLE001 - pull-all is total
except (LangflowError, OSError, ValueError) as exc:
errors[flow_id] = str(exc)
return {"pulled": sorted(pulled), "errors": errors}
@ -73,12 +73,11 @@ async def main_async(args) -> int:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--pull", action="store_true",
help="export all Langflow flows to backend/flows first")
parser.add_argument("--sync", action="store_true",
help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None,
help="flows directory (default: settings.flows_dir)")
parser.add_argument(
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
)
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
args = parser.parse_args()
if not args.pull and not args.sync:
args.sync = True

View file

@ -17,6 +17,8 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
# Must precede all `app.*` imports: Settings is instantiated at import time.
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -81,6 +83,48 @@ async def client():
yield c
@pytest.fixture()
def login(client, session):
"""Log the test client in as a fresh user: ``await login()`` (admin)
or ``await login(role="member", tenant_id="pfarramt-a")``."""
async def _login(
email: str = "admin@example.com",
role: str = "admin",
tenant_id: str = "default",
):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select
from app.auth.deps import SESSION_COOKIE
from app.models import User, UserSession
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
user = session.exec(select(User).where(User.email == email)).first()
if user is None:
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
session.add(user)
else:
user.role = role
user.tenant_id = tenant_id
session.add(user)
session.flush()
user_session = UserSession(
id=f"test-session-{uuid4().hex}",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
session.add(user_session)
session.commit()
client.cookies.set(SESSION_COOKIE, user_session.id)
return user
return _login
@pytest.fixture(scope="session")
def lfx_server(tmp_path_factory):
"""Real `lfx serve` subprocess on a free port; points settings at it.
@ -107,9 +151,7 @@ def lfx_server(tmp_path_factory):
sock.close()
flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = (
Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json"
).read_text()
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
(flow_dir / "seed.json").write_text(seed)
env = {

View file

@ -292,7 +292,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -399,6 +399,20 @@
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
},
{
"types": [
"Message"
],
"selected": "Message",
"name": "callback_token",
"display_name": "Callback Token",
"method": "get_callback_token",
"value": "__UNDEFINED__",
"cache": true,
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
}
],
"field_order": [
@ -409,7 +423,7 @@
"edited": false,
"metadata": {
"module": "custom_components.envelope",
"code_hash": "2b0bd3c3d529",
"code_hash": "02e69613e210",
"dependencies": {
"total_dependencies": 1,
"dependencies": [
@ -858,6 +872,32 @@
"node": {
"template": {
"_type": "Component",
"callback_token": {
"tool_mode": false,
"trace_as_input": true,
"trace_as_metadata": true,
"load_from_db": false,
"list": false,
"list_add_label": "Add More",
"override_skip": false,
"required": false,
"placeholder": "",
"show": true,
"name": "callback_token",
"value": "",
"display_name": "Callback Token",
"advanced": false,
"api_editable": false,
"input_types": [
"Message"
],
"dynamic": false,
"info": "",
"title_case": false,
"track_in_telemetry": false,
"type": "str",
"_input_type": "MessageTextInput"
},
"callback_url": {
"tool_mode": false,
"trace_as_input": true,
@ -891,7 +931,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -1040,6 +1080,7 @@
"trigger",
"run_id",
"callback_url",
"callback_token",
"continuation_flow_id",
"facts"
],
@ -1048,7 +1089,7 @@
"edited": false,
"metadata": {
"module": "custom_components.approval_callback",
"code_hash": "a334376410f7",
"code_hash": "28b2f5894566",
"dependencies": {
"total_dependencies": 2,
"dependencies": [
@ -1804,6 +1845,34 @@
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",
"data": {
"sourceHandle": {
"dataType": "CustomComponent",
"id": "CustomComponent-envelope",
"name": "callback_token",
"output_types": [
"Message"
]
},
"targetHandle": {
"fieldName": "callback_token",
"id": "CustomComponent-callback",
"inputTypes": [
"Message"
],
"type": "str"
}
},
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
"selected": false,
"source": "CustomComponent-envelope",
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",

File diff suppressed because one or more lines are too long

View file

@ -30,51 +30,70 @@ async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
return response.json()
async def _handoff_task(client, live_server, continuation_flow_id=None):
async def _handoff_task(client, live_server, monkeypatch):
"""Run approval_handoff over threshold; returns (run, task)."""
from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
body = {
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
}
if continuation_flow_id is not None:
body["continuation_flow_id"] = continuation_flow_id
run = (
await client.post(f"/api/flows/{flow['id']}/run", json=body)
).json()
body = {"input": {"betrag": 250000, "schwelle": 200000}}
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
return run, task
async def test_approve_without_continuation(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"},
json={"decision": "approve", "note": "ok"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["task"]["status"] == "approved"
assert body["task"]["approver"] == "sachbearbeiter"
assert body["task"]["approver"] == "admin@example.com"
assert body["task"]["decided_at"] is not None
assert body["continuation_run"] is None
async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server):
async def test_approve_with_continuation_runs_facts(
client, lfx_server, live_server, login, monkeypatch
):
await login()
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
_, task = await _handoff_task(
client, live_server, continuation_flow_id=cont["id"]
# Continuation is authoring-time wiring on the definition (admin-only);
# run bodies cannot set it.
response = await client.post(
"/api/flows",
json={"name": f"handoff-{uuid4().hex[:8]}", "continuation_flow_id": cont["id"]},
)
assert response.status_code == 201, response.text
flow = response.json()
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server)
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
assert task["continuation_flow_id"] == cont["id"]
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "approver": "chef"},
json={"decision": "approve"},
)
assert response.status_code == 200, response.text
body = response.json()
@ -87,21 +106,51 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
assert "Sonderfall" in cont_run["output"]["result"]
async def test_reject_task(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
"""A run body cannot chain arbitrary flows (G)."""
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 250000, "schwelle": 200000},
"continuation_flow_id": cont["id"],
},
)
).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
assert task["continuation_flow_id"] is None
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
# Any logged-in user (not just admins) can decide; identity is the session.
await login(email="chef@example.com", role="member")
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "reject", "approver": "chef", "note": "zu teuer"},
json={"decision": "reject", "note": "zu teuer"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["task"]["status"] == "rejected"
assert body["task"]["approver"] == "chef@example.com"
assert body["task"]["decision_note"] == "zu teuer"
assert body["continuation_run"] is None
async def test_decision_guards(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
url = f"/api/approvals/{task['id']}/decision"
response = await client.post(url, json={"decision": "approve"})
@ -109,12 +158,10 @@ async def test_decision_guards(client, lfx_server, live_server):
response = await client.post(url, json={"decision": "reject"})
assert response.status_code == 409
response = await client.post(
f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"}
)
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
assert response.status_code == 404
_, task2 = await _handoff_task(client, live_server)
_, task2 = await _handoff_task(client, live_server, monkeypatch)
response = await client.post(
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
)
@ -128,9 +175,10 @@ async def test_decision_guards(client, lfx_server, live_server):
assert response.status_code == 200
async def test_decision_bad_continuation_reference(client, session):
async def test_decision_bad_continuation_reference(client, session, login):
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
version = (
await client.post(
@ -143,55 +191,55 @@ async def test_decision_bad_continuation_reference(client, session):
flow_store.get_version(session, UUID(version["id"])),
input={},
)
task = flow_store.create_approval_task(
session, ApprovalCreate(run_id=run.id, title="t")
)
task = flow_store.create_approval_task(session, ApprovalCreate(run_id=run.id, title="t"))
# plant unknown continuation directly (store allows it pre-validation)
task.continuation_flow_id = uuid4()
session.add(task)
session.commit()
response = await client.post(
f"/api/approvals/{task.id}/decision", json={"decision": "approve"}
)
response = await client.post(f"/api/approvals/{task.id}/decision", json={"decision": "approve"})
assert response.status_code == 422
async def test_tenant_isolation(client, lfx_server):
async def test_tenant_isolation(client, lfx_server, login):
"""Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
await login()
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"])
for tenant in ("pfarramt-a", "pfarramt-b"):
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 1}, "tenant_id": tenant},
)
assert response.status_code == 200
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 200
run_a = response.json()
assert run_a["tenant_id"] == "pfarramt-a"
response = await client.get(
f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"}
)
runs_a = response.json()
assert len(runs_a) == 1
assert runs_a[0]["tenant_id"] == "pfarramt-a"
await login(email="b@example.com", role="member", tenant_id="pfarramt-b")
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 200
response = await client.get(f"/api/flows/{flow['id']}/runs")
assert len(response.json()) == 2
# A forged tenant_id query param is ignored: b sees only b's runs.
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
runs_b = response.json()
assert len(runs_b) == 1
assert runs_b[0]["tenant_id"] == "pfarramt-b"
# Cross-tenant read with scoping 404s.
response = await client.get(
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"}
)
# Cross-tenant run read 404s with or without the forged param.
response = await client.get(f"/api/flows/runs/{run_a['id']}")
assert response.status_code == 404
response = await client.get(
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"}
f"/api/flows/runs/{run_a['id']}", params={"tenant_id": "pfarramt-a"}
)
assert response.status_code == 404
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
response = await client.get(f"/api/flows/runs/{run_a['id']}")
assert response.status_code == 200
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
from app.settings import settings
await login()
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
await client.post(
f"/api/flows/{flow['id']}/versions",
@ -206,28 +254,26 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
assert deployed.status_code == 200
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 503
async def test_lfx_drift_maps_to_502(client, lfx_server):
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
from app.flows.lfx_client import LfxClient
from app.settings import settings
await login()
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
deployed = await _version_and_deploy(client, flow["id"])
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
deployed["lfx_flow_id"]
)
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 502
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
await login()
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
bogus = {
"name": "bogus",
@ -247,9 +293,7 @@ async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
"edges": [],
},
}
response = await client.post(
f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus}
)
response = await client.post(f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus})
assert response.status_code == 201, response.text
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
assert response.status_code == 422

326
backend/tests/test_auth.py Normal file
View file

@ -0,0 +1,326 @@
"""Phase 1 tests: magic-link auth, access requests, RBAC, cookie flags."""
import logging
from datetime import timedelta
from uuid import uuid4
import pytest
from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE, resolve_tenant
from app.models import MagicLinkToken, User
from app.settings import settings
ADMIN = "admin@example.com"
MEMBER = "member@example.com"
@pytest.fixture(autouse=True)
def _no_smtp_many_admins(monkeypatch):
# Never touch the network: dev fallback logs instead of mailing.
# (The real repo .env configures SMTP; tests must not use it.)
monkeypatch.setattr(settings, "smtp_host", "")
monkeypatch.setattr(settings, "admin_emails", ADMIN)
def _get_or_make_user(session, email, role="member"):
user = service.get_user_by_email(session, email)
if user is None:
user = User(email=email, name="Test", role=role)
session.add(user)
session.commit()
session.refresh(user)
return user
async def _login(client, session, email, role="member"):
"""Consume a fresh magic token; persist the session cookie manually.
httpx honours the Secure cookie flag and would withhold it over the
http://test transport, so the session id is re-set as a plain cookie.
"""
user = _get_or_make_user(session, email, role)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200, response.text
sid = response.cookies[SESSION_COOKIE]
client.cookies.set(SESSION_COOKIE, sid)
return response.json()
async def test_request_approve_consume_me_logout(client, session):
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 201
admin = await _login(client, session, ADMIN, role="admin")
assert admin["role"] == "admin"
created = await client.post(
"/api/access/requests",
json={"name": "Maria", "email": MEMBER, "reason": "choir planning"},
)
assert created.status_code == 201, created.text
assert created.json()["status"] == "pending"
queue = await client.get("/api/access/requests")
assert queue.status_code == 200
assert any(r["email"] == MEMBER for r in queue.json())
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["request"]["status"] == "approved"
assert body["email_sent"] is False # no SMTP in tests
# New client (no admin cookie) logs in as the approved member.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as member:
user = service.get_user_by_email(session, MEMBER)
assert user is not None and user.role == "member"
token = service.issue_magic_token(session, user)
consumed = await member.post("/api/auth/magic/consume", json={"token": token})
assert consumed.status_code == 200
assert consumed.json()["email"] == MEMBER
member.cookies.set(SESSION_COOKIE, consumed.cookies[SESSION_COOKIE])
me = await member.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200
assert (await member.get("/api/auth/me")).status_code == 401
async def test_consume_unknown_token_400(client):
response = await client.post("/api/auth/magic/consume", json={"token": "nope"})
assert response.status_code == 400
async def test_token_expiry_boundary(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == service.hash_token(token))
).one()
record.expires_at = service.utcnow() - timedelta(seconds=1)
session.add(record)
session.commit()
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 400
assert "expired" in response.json()["detail"]
async def test_token_reuse_400(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
assert (await client.post("/api/auth/magic/consume", json={"token": token})).status_code == 200
again = await client.post("/api/auth/magic/consume", json={"token": token})
assert again.status_code == 400
assert "used" in again.json()["detail"]
async def test_admin_only_endpoints(client, session):
await _login(client, session, MEMBER, role="member")
created = await client.post(
"/api/access/requests",
json={"name": "X", "email": "x@example.com", "reason": ""},
)
assert created.status_code == 201
assert (await client.get("/api/access/requests")).status_code == 403
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
).status_code == 401
assert (await anon.get("/api/auth/me")).status_code == 401
async def test_approve_unknown_and_double_decide(client, session):
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin")
assert (await client.post(f"/api/access/requests/{uuid4()}/approve")).status_code == 404
created = await client.post(
"/api/access/requests",
json={"name": "Y", "email": "y@example.com", "reason": ""},
)
rid = created.json()["id"]
assert (await client.post(f"/api/access/requests/{rid}/reject")).status_code == 200
again = await client.post(f"/api/access/requests/{rid}/approve")
assert again.status_code == 409
async def test_bootstrap_once_and_forbidden(client):
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": "stranger@example.com"})
).status_code == 403
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 201
assert (
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
).status_code == 409
async def test_session_cookie_flags(client, session):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
raw = " ".join(response.headers.get_list("set-cookie")).lower()
assert "httponly" in raw
assert "secure" in raw
assert "samesite=lax" in raw
assert "path=/" in raw
async def test_no_token_or_session_in_logs(client, session, caplog):
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
with caplog.at_level(logging.INFO):
caplog.clear()
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
assert token not in caplog.text
assert response.cookies[SESSION_COOKIE] not in caplog.text
assert service.hash_token(token) not in caplog.text
async def test_access_request_rate_limit(client):
email = f"impatient-{uuid4()}@example.com"
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR):
response = await client.post(
"/api/access/requests",
json={"name": "I", "email": email, "reason": ""},
)
assert response.status_code == 201, response.text
limited = await client.post(
"/api/access/requests", json={"name": "I", "email": email, "reason": ""}
)
assert limited.status_code == 429
def test_resolve_tenant():
assert resolve_tenant(None, None) == "default"
assert resolve_tenant(None, "acme") == "acme"
user = User(email="a@b.c", tenant_id="parish-7")
assert resolve_tenant(user, None) == "parish-7"
assert resolve_tenant(user, "other") == "parish-7"
def _broken_smtp(monkeypatch):
import smtplib
from app.auth import mail
class ExplodingSMTP:
def __init__(self, *args, **kwargs):
raise smtplib.SMTPException("relay down")
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
def test_magic_link_delivery_failure_returns_false(monkeypatch):
from app.auth import mail
monkeypatch.setattr(settings, "smtp_host", "dummy")
_broken_smtp(monkeypatch)
assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False
assert mail.send_access_notification("req-1", "A", "a@example.com") is False
async def test_approve_survives_delivery_failure(client, session, monkeypatch):
monkeypatch.setattr(settings, "smtp_host", "dummy")
_broken_smtp(monkeypatch)
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
await _login(client, session, ADMIN, role="admin")
created = await client.post(
"/api/access/requests",
json={"name": "M", "email": "m@example.com", "reason": ""},
)
assert created.status_code == 201, created.text
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approved.status_code == 200, approved.text
body = approved.json()
assert body["request"]["status"] == "approved"
assert body["email_sent"] is False
async def test_magic_request_for_known_user(client, session):
_get_or_make_user(session, MEMBER)
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert response.status_code == 200, response.text
body = response.json()
assert body["message"] == "login link sent"
assert body["email_sent"] is False # no SMTP in tests
assert "token" not in response.text
tokens = session.exec(
select(MagicLinkToken).where(
MagicLinkToken.user_id == service.get_user_by_email(session, MEMBER).id
)
).all()
assert len(tokens) == 1
async def test_magic_request_unknown_email_403(client):
response = await client.post("/api/auth/magic/request", json={"email": "stranger@example.com"})
assert response.status_code == 403
async def test_magic_request_admin_email_creates_admin(client, session):
response = await client.post("/api/auth/magic/request", json={"email": ADMIN})
assert response.status_code == 200, response.text
user = service.get_user_by_email(session, ADMIN)
assert user is not None and user.role == "admin"
async def test_magic_request_rate_limit(client, session):
_get_or_make_user(session, MEMBER)
for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert response.status_code == 200, response.text
limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
assert limited.status_code == 429
def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
from app.auth import mail
monkeypatch.setattr(settings, "smtp_host", "dummy")
message = mail._branded_mail(
subject="Dein Login-Link",
to_email="a@example.com",
headline="Anmeldung bei Kirche Einfach",
intro="Klicke auf den Button.",
button_label="Jetzt anmelden",
button_url="http://localhost:3000/login?token=GEHEIM",
note="Gültig 15 Minuten.",
)
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
assert ">Jetzt anmelden</a>" in html
assert mail.BLUE in html # kirchenblau brand bar
assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]
assert "text/plain" in parts and "text/html" in parts
plain = next(p.get_content() for p in message.walk() if p.get_content_type() == "text/plain")
assert "http://localhost:3000/login?token=GEHEIM" in plain

View file

@ -0,0 +1,79 @@
"""Prod env checker: failures block deploys, warnings don't."""
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
from check_prod import check
def _good() -> dict:
return {
"CALLBACK_TOKEN": "t" * 40,
"LANGFLOW_API_KEY": "k" * 24,
"LFX_API_KEY": "x" * 24,
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
"LANGFLOW_SECRET_KEY": "s" * 40,
"FRONTEND_MODE": "preview",
"ADMIN_EMAILS": "admin@example.com",
"SMTP_HOST": "smtp.example.com",
"SMTP_PASSWORD": "secret",
"CONTACT_TO": "team@example.com",
"FRONTEND_URL": "https://example.com",
}
def test_good_env_passes_without_warnings():
failures, warnings = check(_good())
assert failures == []
assert warnings == []
def test_empty_and_short_secrets_fail():
values = _good()
values["CALLBACK_TOKEN"] = ""
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
failures, _ = check(values)
assert any("CALLBACK_TOKEN" in f for f in failures)
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
def test_placeholders_fail():
values = _good()
values["LANGFLOW_API_KEY"] = "sk-"
values["LFX_API_KEY"] = "lfx-abc"
failures, _ = check(values)
assert any("LANGFLOW_API_KEY" in f for f in failures)
assert any("LFX_API_KEY" in f for f in failures)
def test_insecure_modes_fail():
values = _good()
values["FRONTEND_MODE"] = "dev"
values["APP_ENV"] = "dev"
values["LANGFLOW_AUTO_LOGIN"] = "true"
failures, _ = check(values)
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
assert len([f for f in failures if "APP_ENV" in f]) == 1
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
def test_soft_misconfigurations_warn_only():
values = _good()
values["ADMIN_EMAILS"] = ""
values["SMTP_PASSWORD"] = ""
values["CONTACT_TO"] = ""
values["FRONTEND_URL"] = "http://example.com"
failures, warnings = check(values)
assert failures == []
assert len(warnings) == 4
def test_example_file_would_not_pass():
from check_prod import parse_env
values = parse_env(REPO_ROOT / ".env.example")
failures, _ = check(values)
assert failures, "placeholders must be detectable"

View file

@ -25,9 +25,10 @@ def _export_doc(name="importiert"):
async def test_import_creates_definition_version_and_deploys(
client, lfx_server, override_langflow
client, login, lfx_server, override_langflow
):
override_langflow(_export_doc())
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
@ -37,31 +38,22 @@ async def test_import_creates_definition_version_and_deploys(
assert body["definition"]["name"] == "importiert"
assert body["version"]["version_no"] == 1
assert body["version"]["author"] == "admin"
assert body["version"]["lfx_flow_id"] == lfx_id_for(
UUID(body["definition"]["id"]), 1
)
assert body["version"]["lfx_flow_id"] == lfx_id_for(UUID(body["definition"]["id"]), 1)
assert body["version"]["deployed_at"] is not None
assert body["version"]["langflow_json"]["data"]["nodes"]
# Imported flow actually runs on LFX.
definition_id = body["definition"]["id"]
response = await client.post(
f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}}
)
response = await client.post(f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}})
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
async def test_import_same_content_is_idempotent(
client, lfx_server, override_langflow
):
async def test_import_same_content_is_idempotent(client, login, lfx_server, override_langflow):
override_langflow(_export_doc())
first = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
second = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
await login()
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
@ -69,42 +61,37 @@ async def test_import_same_content_is_idempotent(
assert second.json()["version"]["version_no"] == 1
async def test_import_changed_content_bumps_version(
client, lfx_server, override_langflow
):
async def test_import_changed_content_bumps_version(client, login, lfx_server, override_langflow):
override_langflow(_export_doc())
first = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
await login()
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
changed = _export_doc()
changed["description"] = "neue Beschreibung"
override_langflow(changed)
second = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert second.status_code == 201
assert second.json()["version"]["version_no"] == 2
async def test_import_writes_canonical_file(
client, lfx_server, override_langflow, tmp_path, monkeypatch
client, login, lfx_server, override_langflow, tmp_path, monkeypatch
):
from app.settings import settings
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(_export_doc())
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 201, response.text
written = tmp_path / "importiert.json"
assert written.is_file()
assert json.loads(written.read_text())["name"] == "importiert"
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
async def test_import_with_explicit_name(client, login, lfx_server, override_langflow):
override_langflow(_export_doc(name="langflow-name"))
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
@ -113,8 +100,9 @@ async def test_import_with_explicit_name(client, lfx_server, override_langflow):
assert response.json()["definition"]["name"] == "wunschname"
async def test_import_without_deploy(client, override_langflow):
async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc())
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
@ -125,24 +113,23 @@ async def test_import_without_deploy(client, override_langflow):
assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, override_langflow):
async def test_import_langflow_not_found(client, login, override_langflow):
override_langflow(status=404)
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 404
async def test_import_langflow_unreachable(client, override_langflow):
async def test_import_langflow_unreachable(client, login, override_langflow):
override_langflow(status=0)
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
assert response.status_code == 503
async def test_list_langflow_flows(client, override_langflow):
async def test_list_langflow_flows(client, login, override_langflow):
override_langflow(_export_doc())
await login()
response = await client.get("/api/flows/langflow/available")
assert response.status_code == 200
assert response.json() == [{"id": "fake-1", "name": "fake"}]

View file

@ -7,6 +7,7 @@ from uuid import UUID, uuid4
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
@ -14,9 +15,7 @@ def load_fixture(name: str) -> dict:
async def _create_flow(client, name="regeltest"):
response = await client.post(
"/api/flows", json={"name": name, "description": "test flow"}
)
response = await client.post("/api/flows", json={"name": name, "description": "test flow"})
assert response.status_code == 201, response.text
return response.json()
@ -36,7 +35,8 @@ async def _deploy(client, flow_id, version_no=1):
return response.json()
async def test_flow_crud(client):
async def test_flow_crud(client, login):
await login()
created = await _create_flow(client, "crud-flow")
assert created["status"] == "active"
@ -55,7 +55,63 @@ async def test_flow_crud(client):
assert response.status_code == 404
async def test_version_validation_and_numbering(client):
async def test_management_endpoints_require_admin(client, login):
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
flow_id = uuid4()
probes = [
("POST", "/api/flows", {"name": "x"}),
("POST", "/api/flows/sync", None),
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
("GET", f"/api/flows/{flow_id}/versions", None),
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 401, (method, path, response.text)
await login(email="member@example.com", role="member")
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 403, (method, path, response.text)
async def test_runs_and_reads_require_login(client, login):
"""Run, run reads and approval reads are login-gated (A)."""
await login()
flow = await _create_flow(client, "gated")
run_id = uuid4()
authed_paths = [
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
("GET", f"/api/flows/{flow['id']}/runs", None),
("GET", f"/api/flows/runs/{run_id}", None),
("GET", "/api/approvals", None),
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
]
# Fresh client without a session cookie.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
for method, path, body in authed_paths:
if method == "POST":
response = await anon.post(path, json=body or {})
else:
response = await anon.get(path)
assert response.status_code == 401, (method, path, response.text)
async def test_version_validation_and_numbering(client, login):
await login()
flow = await _create_flow(client, "versioned")
v1 = await _create_version(client, flow["id"])
@ -79,7 +135,8 @@ async def test_version_validation_and_numbering(client):
assert [v["version_no"] for v in response.json()] == [1, 2]
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
await login()
flow = await _create_flow(client, "dachsanierung")
await _create_version(client, flow["id"])
deployed = await _deploy(client, flow["id"])
@ -110,16 +167,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
assert response.json()["id"] == run["id"]
async def test_run_undeployed_version_fails(client):
async def test_run_undeployed_version_fails(client, login):
await login()
flow = await _create_flow(client, "undeployed")
await _create_version(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
)
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 422
async def test_run_explicit_version_no(client, lfx_server):
async def test_run_explicit_version_no(client, lfx_server, login):
await login()
flow = await _create_flow(client, "explicit-version")
await _create_version(client, flow["id"])
await _create_version(client, flow["id"])
@ -132,7 +189,8 @@ async def test_run_explicit_version_no(client, lfx_server):
assert response.json()["status"] == "completed"
async def test_run_stream_events(client, lfx_server):
async def test_run_stream_events(client, lfx_server, login):
await login()
flow = await _create_flow(client, "streamed")
await _create_version(client, flow["id"])
await _deploy(client, flow["id"])
@ -165,17 +223,18 @@ async def test_run_stream_events(client, lfx_server):
assert response.json()["status"] == "completed"
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe")
await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
},
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
run = response.json()
@ -192,17 +251,20 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
assert task["payload"]["facts"]["betrag"] == 250000
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
async def test_approval_handoff_auto_creates_no_task(
client, lfx_server, live_server, login, monkeypatch
):
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe-auto")
await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 50000, "schwelle": 200000},
"callback_base_url": live_server,
},
json={"input": {"betrag": 50000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
@ -212,11 +274,12 @@ async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_se
assert response.json() == []
async def test_approval_callback_contract(client, session):
async def test_approval_callback_contract(client, session, login):
from uuid import UUID
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, "callback-target")
version = await _create_version(client, flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
@ -226,6 +289,7 @@ async def test_approval_callback_contract(client, session):
response = await client.post(
"/api/approvals",
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
headers=CALLBACK_HEADERS,
)
assert response.status_code == 201, response.text
task = response.json()
@ -233,18 +297,46 @@ async def test_approval_callback_contract(client, session):
assert task["tenant_id"] == "default"
assert task["version_id"] == version["id"]
response = await client.get(f"/api/flows/runs/{run.id}")
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS)
assert response.json()["status"] == "pending_approval"
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
response = await client.post(
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
)
assert response.status_code == 409
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post(
"/api/approvals",
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
headers=CALLBACK_HEADERS,
)
assert response.status_code == 422
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
response = await client.post(
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
)
assert response.status_code == 404
async def test_approval_callback_token_required(client, session, login):
"""Forged callbacks without the shared token are rejected (C)."""
from uuid import UUID
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, "callback-gated")
version = await _create_version(client, flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
assert response.status_code == 401
response = await client.post(
"/api/approvals",
json={"run_id": str(run.id)},
headers={"X-Callback-Token": "wrong"},
)
assert response.status_code == 401

View file

@ -0,0 +1,105 @@
"""Phase 2 tests: kirche CustomInput* library + intake fixture flow."""
import json
import sys
from pathlib import Path
from lfx.custom import Component
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
from kirche.form_inputs import CustomInputForm
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
FORM_FIELDS = [
"full_name",
"notes",
"password",
"age",
"amount",
"newsletter",
"department",
"topics",
"satisfaction",
"attachment",
]
def test_component_contract():
assert issubclass(CustomInputForm, Component)
assert CustomInputForm.display_name.startswith("CustomInput")
inputs = {i.name: i for i in CustomInputForm.inputs}
# Ten form fields plus the hidden run-envelope plumbing input.
assert sorted(n for n in inputs if n != "form_values") == sorted(FORM_FIELDS)
assert inputs["form_values"].show is False
outputs = {o.name: o for o in CustomInputForm.outputs}
assert set(outputs) == {"form_data"}
def test_collect_merges_submitted_values_over_defaults():
from lfx.schema.message import Message
form = CustomInputForm()
default = json.loads(form.collect().text)
assert default["full_name"] == ""
assert default["department"] == "Music"
assert isinstance(form.collect(), Message)
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
merged = json.loads(form.collect().text)
assert merged["full_name"] == "Maria"
assert merged["age"] == 41
assert merged["department"] == "Music" # default kept
# Run envelope shape {"payload": {...}} unwraps like the other components.
form.form_values = json.dumps({"payload": {"notes": "hi"}})
assert json.loads(form.collect().text)["notes"] == "hi"
form.form_values = "not json"
assert json.loads(form.collect().text)["full_name"] == ""
def test_intake_fixture_shape():
doc = json.loads((FIXTURES / "intake_form.json").read_text())
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputForm-intake"]
assert form["data"]["type"] == "CustomInputForm"
assert form["data"]["node"]["display_name"].startswith("CustomInput")
template = form["data"]["node"]["template"]
for field in FORM_FIELDS:
assert field in template, field
assert template["form_values"]["show"] is False
assert len(template["code"]["value"]) > 200
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "intake-e2e"})
assert created.status_code == 201, created.text
flow_id = created.json()["id"]
version = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": doc, "author": "test"},
)
assert version.status_code == 201, version.text
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
run = await client.post(
f"/api/flows/{flow_id}/run",
json={"input": {"full_name": "Maria Muster", "age": 41}},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = json.loads(run.json()["output"]["result"])
assert result["full_name"] == "Maria Muster"
assert result["age"] == 41
assert result["department"] == "Music" # default preserved

View file

@ -0,0 +1,194 @@
"""Phase 3 tests: form-schema extraction, sync cache, input-schema endpoint."""
import json
from pathlib import Path
from uuid import uuid4
from app.flows import input_schema as parser
from app.flows import sync
from app.flows.lfx_client import LfxClient
from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
def _field(name, itype, **kw):
base = {
"name": name,
"display_name": name.replace("_", " ").title(),
"info": "",
"value": None,
"required": False,
"advanced": False,
"show": True,
"_input_type": itype,
}
base.update(kw)
return base
def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
template = {f["name"]: f for f in fields}
node = {
"data": {
"type": node_type,
"node": {"display_name": display, "template": template},
}
}
return {"data": {"nodes": [node], "edges": []}}
def test_type_mapping_table():
doc = _doc(
_field("a", "StrInput", value="x"),
_field("b", "MessageTextInput"),
_field("c", "MultilineInput"),
_field("d", "SecretStrInput"),
_field("e", "IntInput", value=3),
_field("f", "FloatInput", value=1.5),
_field("g", "BoolInput", value=True),
_field(
"h",
"DropdownInput",
options=["Music", "Youth"],
value="Youth",
),
_field(
"i",
"MultiselectInput",
options=["Worship", "Courses"],
value=["Worship"],
),
_field(
"j",
"SliderInput",
value=3,
range_spec={"min": 1, "max": 5, "step": 1},
),
_field("k", "FileInput"),
_field("l", "FutureFancyInput"),
)
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
props = schema["json_schema"]["properties"]
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
assert props["b"]["type"] == "string"
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
assert props["f"]["type"] == "number"
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
assert props["h"] == {
"title": "H",
"type": "string",
"enum": ["Music", "Youth"],
"default": "Youth",
}
assert props["i"]["type"] == "array"
assert props["i"]["items"] == {"type": "string", "enum": ["Worship", "Courses"]}
assert props["i"]["uniqueItems"] is True
assert props["j"]["minimum"] == 1
assert props["j"]["maximum"] == 5
assert props["j"]["multipleOf"] == 1
assert props["k"]["type"] == "string"
assert props["l"]["type"] == "string"
ui = schema["ui_schema"]
assert ui["c"] == {"ui:widget": "textarea"}
assert ui["d"] == {"ui:widget": "password"}
assert ui["j"] == {"ui:widget": "range"}
def test_dropdown_default_outside_enum_omitted():
doc = _doc(_field("h", "DropdownInput", options=["Music"], value=""))
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
assert props["h"] == {"title": "H", "type": "string", "enum": ["Music"]}
def test_required_advanced_hidden_code():
doc = _doc(
_field("a", "StrInput", required=True),
_field("b", "StrInput", advanced=True),
_field("c", "StrInput", show=False),
)
doc["data"]["nodes"][0]["data"]["node"]["template"]["code"] = {"value": "x"}
schema = parser.extract_form_schema(doc)
assert schema["json_schema"]["required"] == ["a"]
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
def test_display_name_match_and_fallback():
doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X")
assert set(parser.extract_form_schema(doc)["json_schema"]["properties"]) == {"a"}
plain = {
"data": {
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
"edges": [],
}
}
schema = parser.extract_form_schema(plain)
assert schema["fallback"] == "textarea"
assert schema["json_schema"]["properties"] == {}
def test_real_intake_fixture_parses():
doc = json.loads((FIXTURES / "intake_form.json").read_text())
schema = parser.extract_form_schema(doc)
props = schema["json_schema"]["properties"]
assert "fallback" not in schema
assert set(props) == {
"full_name",
"notes",
"password",
"age",
"amount",
"newsletter",
"department",
"topics",
"satisfaction",
"attachment",
}
assert "full_name" in schema["json_schema"]["required"]
assert props["department"]["enum"] == ["Music", "Youth", "Seniors", "Admin"]
assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"}
def _lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
assert not report.errors, report.errors
from app.flows import store
defs = store.list_definitions(session)
definition = next(d for d in defs if d.name == "intake_form")
versions = store.list_versions(session, definition.id)
assert len(versions) == 1
cached = versions[0].input_schema
assert cached["json_schema"]["properties"]["full_name"]["type"] == "string"
assert "form_values" not in cached["json_schema"]["properties"]
async def test_input_schema_endpoint(client, lfx_server, login):
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "schema-e2e"})
flow_id = created.json()["id"]
version = await client.post(f"/api/flows/{flow_id}/versions", json={"langflow_json": doc})
assert version.status_code == 201, version.text
assert version.json()["input_schema"]["json_schema"]["properties"]["age"]["type"] == "integer"
# Created but never deployed → 404.
assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 404
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
response = await client.get(f"/api/flows/{flow_id}/input-schema")
assert response.status_code == 200, response.text
body = response.json()
assert body["version_no"] == 1
assert body["fallback"] is None
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
assert body["ui_schema"]["password"] == {"ui:widget": "password"}

View file

@ -20,9 +20,7 @@ def _client():
@respx.mock
async def test_export_flow_sends_api_key_and_returns_doc():
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(
return_value=httpx.Response(200, json=doc)
)
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(return_value=httpx.Response(200, json=doc))
result = await _client().export_flow("abc")
assert result == doc
assert route.called

View file

@ -0,0 +1,33 @@
"""Canonical flows must pass lfx's strict validator (level 4).
Guards the source-of-truth directory against authoring mistakes: the
input_schema parser and the LFX runtime both assume well-formed exports.
Runs `lfx validate --level 4` (the dev dep, same minor as Langflow).
"""
import subprocess
import sys
from pathlib import Path
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
def test_canonical_flows_pass_lfx_validate():
files = sorted(FLOW_DIR.glob("*.json"))
assert files, "no canonical flows found"
process = subprocess.run(
[
sys.executable,
"-m",
"lfx",
"validate",
"--level",
"4",
*[str(f) for f in files],
],
capture_output=True,
text=True,
timeout=300,
check=False,
)
assert process.returncode == 0, f"lfx validate failed:\n{process.stdout}\n{process.stderr}"

View file

@ -0,0 +1,85 @@
"""Edge hardening: docs off, headers, body cap, run throttle, mail scrub."""
import pytest
from app.middleware import reset_throttle
@pytest.fixture(autouse=True)
def _clean_throttle():
reset_throttle()
yield
reset_throttle()
async def test_docs_disabled_outside_debug(client):
assert (await client.get("/api/docs")).status_code == 404
assert (await client.get("/api/redoc")).status_code == 404
assert (await client.get("/api/openapi.json")).status_code == 404
async def test_security_headers_present(client):
response = await client.get("/health")
assert response.status_code == 200
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
async def test_oversize_body_rejected(client):
# Default cap is 2 MiB (settings.max_body_bytes); the middleware binds
# it at startup, so this test exceeds the real default.
big = "x" * (3 * 1024 * 1024)
response = await client.post("/api/auth/magic/consume", json={"token": big})
assert response.status_code == 413
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
from app.settings import settings
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
await login()
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
await client.post(
f"/api/flows/{flow['id']}/versions",
json={
"langflow_json": {
"name": "t",
"data": {
"nodes": [
{
"id": "ChatInput-x",
"data": {
"id": "ChatInput-x",
"type": "ChatInput",
"node": {"template": {}, "outputs": []},
},
"type": "genericNode",
}
],
"edges": [],
},
}
},
)
# Undeployed: 422s still pass the throttle (only 429 matters here).
for _ in range(2):
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 422
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 429
assert response.headers["Retry-After"]
def test_magic_link_never_logged(caplog, monkeypatch):
from app.auth import mail
from app.settings import settings
monkeypatch.setattr(settings, "smtp_host", "")
with caplog.at_level("INFO", logger="app.auth.mail"):
assert mail.send_magic_link("a@example.com", "http://x/login?token=SECRET") is False
assert "SECRET" not in caplog.text
assert "http://x/login" not in caplog.text
assert "a@example.com" in caplog.text

View file

@ -5,7 +5,6 @@ import json
import shutil
import time
from pathlib import Path
from uuid import UUID
import pytest
@ -119,7 +118,8 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
assert definition.name == "umbenannt"
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
_seed_dir(tmp_path)
response = await client.post("/api/flows/sync")

View file

@ -12,10 +12,17 @@ services:
context: ./backend
dockerfile: Dockerfile
env_file:
- .env
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
volumes:
# No venv volume by design: the venv lives at /opt/venv (image-local,
# see backend/Dockerfile). A named volume here used to shadow the image
# content with an empty dir on first start (split-brain with the host
# checkout, plus root-owned interpreters).
- .:/srv/kirche-einfach
- kirche-venv:/srv/kirche-einfach/backend/.venv
ports:
- "8000:8000" # FastAPI
depends_on:
@ -40,13 +47,20 @@ services:
context: ./frontend
dockerfile: Dockerfile
env_file:
- .env
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
environment:
# Dev default (HMR); prod .env sets `preview` (build + serve bundle).
FRONTEND_MODE: ${FRONTEND_MODE:-dev}
# Fail closed: unset means the production preview server. Dev sets
# FRONTEND_MODE=dev in .env (provisioned by .devcontainer/setup.sh).
FRONTEND_MODE: ${FRONTEND_MODE:-preview}
volumes:
# No node_modules volume by design: deps live in the bind-mount and the
# entrypoint runs `npm ci` on first start (a volume used to shadow the
# image install with an empty dir and diverge from the host checkout).
- .:/srv/kirche-einfach
- kirche-node-modules:/srv/kirche-einfach/frontend/node_modules
ports:
- "3000:3000" # Vite
depends_on:
@ -55,9 +69,16 @@ services:
restart: unless-stopped
langflow:
image: langflowai/langflow:latest
# Pinned (never :latest): Langflow and LFX are versioned together, their
# major.minor must match. Bump via LANGFLOW_VERSION in .env (see
# .env.example) together with Dockerfile.lfx + backend `lfx~=` pin.
image: langflowai/langflow:${LANGFLOW_VERSION:-1.12.0}
env_file:
- .env
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
environment:
# Static values only; secrets/URLs come from .env via env_file.
LANGFLOW_CONFIG_DIR: /app/langflow
@ -68,12 +89,17 @@ services:
# SQLite file inside the langflow-data volume (absolute path required
# by Langflow); overrides any postgres URL that may linger in .env.
LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db
# Custom form-input components (backend/components, category folders).
LANGFLOW_COMPONENTS_PATH: /app/components
# Seed the authoring UI with the canonical flows (read-only: the files
# stay the source of truth, the API watcher is the only sync path).
LANGFLOW_LOAD_FLOWS_PATH: /app/flows
volumes:
- langflow-data:/app/langflow
- ./backend/components:/app/components:ro
- ./backend/flows:/app/flows:ro
ports:
- "7860:7860"
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped
healthcheck:
test:
@ -91,7 +117,17 @@ services:
context: .
dockerfile: Dockerfile.lfx
env_file:
- .env
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
environment:
# lfx resolves its serve key via get_api_key(), which prefers
# LANGFLOW_API_KEY over LFX_API_KEY. Our backend talks to LFX with
# LFX_API_KEY, so blank the former here (empty counts as unset) —
# otherwise every flow deploy 401s and /flows stays empty.
LANGFLOW_API_KEY: ""
volumes:
- lfx-data:/flows
ports:
@ -110,6 +146,8 @@ services:
# Nightly auto-updates of :latest images (prod). Never started in dev
# (not in devcontainer.json runServices; also needs a docker socket).
# langflow/lfx carry no watchtower label on purpose: both are version-pinned
# (major.minor must match) and update only via deliberate version bumps.
watchtower:
image: nickfedor/watchtower:latest
volumes:
@ -124,7 +162,5 @@ services:
restart: unless-stopped
volumes:
kirche-venv:
kirche-node-modules:
langflow-data:
lfx-data:

6
frontend/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
# Never bake host build output into the Linux image: the host checkout runs
# on Windows, so a copied node_modules would shadow the image install with
# wrong-platform binaries and diverge from package-lock.json.
node_modules/
dist/
*.tsbuildinfo

View file

@ -3,11 +3,21 @@
# file. DevDeps are included: `npm run dev` must work with zero steps.
FROM node:22-slim
# gosu drops privileges before serving (node user, uid 1000 — same as the
# vscode editor user, so the bind-mounted node_modules stays writable).
RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv/kirche-einfach/frontend
COPY package.json package-lock.json entrypoint.sh ./
RUN npm ci && chmod +x entrypoint.sh
RUN npm ci
COPY . ./
RUN chmod +x entrypoint.sh && chown -R node:node /srv/kirche-einfach/frontend
EXPOSE 3000
ENTRYPOINT ["./entrypoint.sh"]
# Explicit `sh`: the repo bind-mount shadows the image at runtime, and git
# tracks entrypoint.sh as 100644 — relying on the exec bit breaks Windows
# checkouts (and fresh Linux clones). `sh script` needs read-only.
ENTRYPOINT ["sh", "./entrypoint.sh"]

View file

@ -1,14 +1,21 @@
#!/bin/sh
# Frontend entrypoint (dev + prod).
# FRONTEND_MODE=dev (compose default) runs the HMR dev server.
# FRONTEND_MODE=preview (prod .env) builds once and serves the bundle.
# Fail closed: unset means the production preview server; dev sets
# FRONTEND_MODE=dev in .env (see .devcontainer/setup.sh).
set -e
cd /srv/kirche-einfach/frontend
if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then
exec npm run dev -- --host 0.0.0.0 --port 3000
else
npm run build
exec npm run preview -- --host 0.0.0.0 --port 3000
# node_modules lives in the repo bind-mount (no named volume by design):
# install on first start after a fresh checkout (dev and prod).
if [ ! -x "node_modules/.bin/vite" ]; then
gosu node npm ci
fi
if [ "${FRONTEND_MODE:-preview}" = "dev" ]; then
exec gosu node npm run dev -- --host 0.0.0.0 --port 3000
else
# Build as node so dist/ stays editor-writable; preview serves as node.
gosu node npm run build
exec gosu node npm run preview -- --host 0.0.0.0 --port 3000
fi

View file

@ -3,7 +3,30 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1d4a79" />
<meta
name="description"
content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche"
/>
<title>Kirche Einfach</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script>
try {
var s = localStorage.getItem("kirche-color-scheme");
if (
s === "dark" ||
(!s && matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.setAttribute(
"data-mantine-color-scheme",
"dark",
);
}
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>

View file

@ -8,13 +8,21 @@
"name": "kirche-einfach-frontend",
"version": "0.1.0",
"dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
@ -743,6 +751,59 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.20",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.9",
"@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -793,6 +854,50 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mantine/core": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.1.tgz",
"integrity": "sha512-UkcPo8Zq0uSdodvyXV62HVmfIfHCcrjFrFrH0TggTy6Wtp8a78znGP8RJOz8zC9XSPpJmKjBQjg0M+nOvIzftA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.20",
"clsx": "^2.1.1",
"react-number-format": "^5.4.5",
"react-remove-scroll": "^2.7.2",
"type-fest": "^5.8.0"
},
"peerDependencies": {
"@mantine/hooks": "9.6.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/dates": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.1.tgz",
"integrity": "sha512-3NxfqrcwMuYB9wvxIhGhEhhazK3wcA2wG4AuNVFGJDd97ujWWAy+jGHqBF9jOusp/Mk4bn8u+3SuDICtLPDf5w==",
"license": "MIT",
"peer": true,
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"@mantine/core": "9.6.1",
"@mantine/hooks": "9.6.1",
"dayjs": ">=1.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/hooks": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
"integrity": "sha512-pfxM95WozngB0ZALw6h99QV1TQ0qniOkFhzi0+dmSnKpxkVSR0xeNsvPpGDj9Teu+M69fRoE4IkzyMnu5Xld3w==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -810,6 +915,78 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@rjsf/core": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz",
"integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==",
"license": "Apache-2.0",
"dependencies": {
"markdown-to-jsx": "^9.8.2"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0",
"react": ">=18"
}
},
"node_modules/@rjsf/mantine": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/mantine/-/mantine-6.10.0.tgz",
"integrity": "sha512-tqD2v7rCEaI5Dme66MiaIMem/zTuKJBn9+B1d5J8cVCOh77FmEyn1neVvB6rPK+jXNVY6jJD8DRW8vAzK6OPRQ==",
"license": "Apache-2.0",
"dependencies": {
"dayjs": "^1.11.21"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@mantine/core": ">=8",
"@mantine/dates": ">=8",
"@mantine/hooks": ">=8",
"@rjsf/core": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz",
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1",
"react-is": "^18.3.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"react": ">=18"
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz",
"integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^2.1.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.10.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1167,6 +1344,32 @@
"win32"
]
},
"node_modules/@tabler/icons": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
"integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
"integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.46.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1219,11 +1422,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT",
"peer": true
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -1260,6 +1470,65 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@x0k/json-schema-merge": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz",
"integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv/node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
@ -1328,6 +1597,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1335,11 +1613,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
@ -1360,6 +1657,12 @@
}
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
@ -1419,6 +1722,39 @@
"node": ">=6"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz",
"integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-uri": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1462,6 +1798,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -1482,6 +1827,12 @@
"node": ">=6"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -1495,6 +1846,16 @@
"node": ">=6"
}
},
"node_modules/jsonpointer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -1505,6 +1866,34 @@
"yallist": "^3.0.2"
}
},
"node_modules/markdown-to-jsx": {
"version": "9.10.2",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz",
"integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"react": ">= 16.0.0",
"solid-js": ">=1.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-native": {
"optional": true
},
"solid-js": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1561,6 +1950,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@ -1611,6 +2029,23 @@
"react": "^19.2.8"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
},
"node_modules/react-number-format": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
"integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==",
"license": "MIT",
"peerDependencies": {
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -1621,6 +2056,122 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@ -1683,6 +2234,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1693,6 +2250,24 @@
"node": ">=0.10.0"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -1710,6 +2285,27 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@ -1755,6 +2351,49 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",

View file

@ -6,16 +6,26 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"screenshots": "node scripts/capture.mjs",
"icons": "node scripts/icons.mjs"
},
"dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<style>
.bg { fill: #1d4a79; }
.fg { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.bg { fill: #356fae; }
}
</style>
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
<!-- stylized bell tower: spire + shaft + bell arch -->
<path class="fg" d="M32 9 L41 25 H23 Z"/>
<rect class="fg" x="25" y="25" width="14" height="27"/>
<!-- bell arch knockout -->
<path class="bg" d="M28.5 40 v-4 a3.5 3.5 0 0 1 7 0 v4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -0,0 +1,20 @@
# Landing-page photo sources
All files in this directory are self-hosted (no hotlinking, no tracking).
Licensed for free use including commercial purposes, no attribution
required (Pexels License). Re-fetch any time:
docker compose exec api /opt/venv/bin/python \
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
Rejected during visual review (do NOT re-add without checking):
US-megachurch conference screens, dark baroque interior, pandemic masks,
Dutch-language documents.
| File | Motif | Source |
| ---------------------- | ------------------------------ | ------ |
| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ |
| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ |
| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ |
| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ |
| pilot-meeting.webp | Consultation meeting, notes | https://www.pexels.com/photo/two-people-shaking-hands-5668858/ |

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,22 @@
{
"name": "Kirche Einfach",
"short_name": "Kirche",
"description": "KI-gestützte Verwaltungsplattform für Kirche",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1d4a79",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View file

@ -0,0 +1,66 @@
/* Repeatable landing-page screenshots (re-run after UI changes).
*
* Usage (frontend container, dev server running):
* npm run screenshots # light scheme (default)
* npm run screenshots -- --scheme=dark
*
* Captures real app states into public/screenshots/ (committed, so the
* landing page always shows current UI). Authenticated states are out of
* scope (magic-link login is not scriptable without SMTP).
*/
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(root, "public", "screenshots");
const base = process.env.SCREENSHOT_BASE_URL ?? "http://localhost:3000";
const schemeArg = process.argv.find((a) => a.startsWith("--scheme="));
const scheme = schemeArg ? schemeArg.split("=")[1] : "light";
const shots = [
{ name: "hero", path: "/", fullPage: true },
{
name: "workflows",
path: "/app/workflows",
fullPage: false,
selectFlow: "intake_form",
},
];
const browser = await chromium.launch();
try {
mkdirSync(outDir, { recursive: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
colorScheme: scheme === "dark" ? "dark" : "light",
});
await context.addInitScript(
(value) => {
try {
localStorage.setItem("kirche-color-scheme", value);
} catch {
/* private mode */
}
},
scheme === "dark" ? "dark" : "light",
);
const page = await context.newPage();
for (const shot of shots) {
await page.goto(base + shot.path, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
if (shot.selectFlow) {
// Show the generated RJSF form instead of the default first flow.
await page.locator('[role="combobox"]').first().click();
await page.getByRole("option", { name: shot.selectFlow }).click();
await page.waitForTimeout(2000);
}
const file = join(outDir, `${shot.name}-${scheme}.png`);
await page.screenshot({ path: file, fullPage: shot.fullPage });
console.log(`wrote ${file}`);
}
} finally {
await browser.close();
}

View file

@ -0,0 +1,82 @@
"""Fetch landing-page photos (one-time + re-runnable).
Downloads royalty-free originals (see SOURCES.md next to the photos),
resizes to max 1600px wide and stores them as WebP q80 in
frontend/public/photos/ (self-hosted: no hotlinking, no tracking).
Run in the api container (has network + Pillow):
docker compose exec api /opt/venv/bin/python \\
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
"""
from __future__ import annotations
import sys
import urllib.request
from io import BytesIO
from pathlib import Path
try:
from PIL import Image
except ImportError:
sys.exit("Pillow required (present in the api image)")
ROOT = Path(__file__).resolve().parent.parent.parent
OUT = ROOT / "frontend" / "public" / "photos"
def pexels(photo_id: int) -> str:
return (
f"https://images.pexels.com/photos/{photo_id}/"
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600"
)
PHOTOS = {
# target file -> (direct file URL, source page for SOURCES.md)
"hero-church.webp": (
pexels(31630127),
"https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/",
),
"church-pews.webp": (
pexels(7566207),
"https://www.pexels.com/photo/church-pews-7566207/",
),
"paperwork-desk.webp": (
pexels(18687845),
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
),
"team-laptop.webp": (
pexels(7793702),
"https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/",
),
"pilot-meeting.webp": (
pexels(5668858),
"https://www.pexels.com/photo/two-people-shaking-hands-5668858/",
),
}
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"})
with urllib.request.urlopen(req, timeout=120) as response:
return response.read()
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
for filename, (url, _page) in PHOTOS.items():
raw = fetch(url)
with Image.open(BytesIO(raw)) as img:
img = img.convert("RGB")
if img.width > 1600:
img = img.resize(
(1600, round(img.height * 1600 / img.width)), Image.LANCZOS
)
target = OUT / Path(filename).with_suffix(".webp").name
img.save(target, "WEBP", quality=80, method=6)
print(f"wrote {target} ({target.stat().st_size // 1024} KB)")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,44 @@
/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable).
*
* Usage (frontend container, Chromium already installed for screenshots):
* npm run icons
*
* Renders the SVG at each target size (crisp native rasterization) into
* public/. apple-touch-icon keeps the solid kirchenblau background (iOS
* fills transparency black). The 512 icon doubles as maskable: the glyph
* stays inside the inner 80% safe zone.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8");
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`;
const targets = [
["favicon-32x32.png", 32],
["apple-touch-icon.png", 180],
["icon-192.png", 192],
["icon-512.png", 512],
];
const browser = await chromium.launch();
try {
const page = await browser.newPage();
for (const [file, size] of targets) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<body style="margin:0"><img src="${dataUrl}" width="${size}" height="${size}"/></body>`,
);
await page.locator("img").screenshot({
path: join(root, "public", file),
omitBackground: true,
});
console.log(`wrote public/${file}`);
}
} finally {
await browser.close();
}

View file

@ -1,39 +1,161 @@
import { useState } from "react";
import AdminTab from "./components/AdminTab";
import ApprovalsTab from "./components/ApprovalsTab";
import FlowsTab from "./components/FlowsTab";
import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import {
Anchor,
AppShell,
Button,
Center,
Container,
Group,
Loader,
Text,
Title,
} from "@mantine/core";
import type { ReactNode } from "react";
import { AuthProvider, useAuth } from "./auth";
import SchemeToggle from "./components/SchemeToggle";
import StartPage from "./pages/StartPage";
import AccessPage from "./pages/AccessPage";
import LoginPage from "./pages/LoginPage";
import WorkflowsPage from "./pages/WorkflowsPage";
import TasksPage from "./pages/TasksPage";
import AdminPage from "./pages/AdminPage";
type Tab = "flows" | "approvals" | "admin";
function Nav() {
const { me, logout } = useAuth();
const navigate = useNavigate();
const tabStyle = (active: boolean): React.CSSProperties => ({
fontWeight: active ? "bold" : "normal",
textDecoration: active ? "underline" : "none",
});
export default function App() {
const [tab, setTab] = useState<Tab>("flows");
async function onLogout() {
await logout();
navigate("/");
}
return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
<h1>Kirche Einfach</h1>
<p>KI-gestützte Verwaltungsplattform für Kirche</p>
<nav style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<button style={tabStyle(tab === "flows")} onClick={() => setTab("flows")}>
Vorgänge
</button>
<button
style={tabStyle(tab === "approvals")}
onClick={() => setTab("approvals")}
>
Freigaben
</button>
<button style={tabStyle(tab === "admin")} onClick={() => setTab("admin")}>
Administration
</button>
</nav>
{tab === "flows" && <FlowsTab />}
{tab === "approvals" && <ApprovalsTab />}
{tab === "admin" && <AdminTab />}
</div>
<Group gap="md">
<Anchor component={Link} to="/">
Start
</Anchor>
{!me && (
<Anchor component={Link} to="/access">
Zugang
</Anchor>
)}
{me && (
<>
<Anchor component={Link} to="/app/workflows">
Workflows
</Anchor>
<Anchor component={Link} to="/app/tasks">
Aufgaben
</Anchor>
<Anchor component={Link} to="/app/admin">
Verwaltung
</Anchor>
</>
)}
<SchemeToggle />
{me && (
<>
<Text size="sm" c="dimmed">
{me.email}
</Text>
<Button variant="subtle" size="compact-md" onClick={() => void onLogout()}>
Abmelden
</Button>
</>
)}
</Group>
);
}
function RequireAuth({ children }: { children: ReactNode }) {
const { me, loading } = useAuth();
if (loading) {
return (
<Center mt="xl">
<Loader />
</Center>
);
}
// Runs, run history and approvals are login-gated server-side;
// anonymous visitors go to the magic-link login.
if (!me) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function Shell() {
const { loading } = useAuth();
return (
<AppShell header={{ height: 64 }} padding="md">
<AppShell.Header>
<Container
h="100%"
style={{ display: "flex", alignItems: "center", gap: 16 }}
>
<Anchor
component={Link}
to="/"
underline="never"
style={{ color: "inherit" }}
>
<Title order={3}>Kirche Einfach</Title>
</Anchor>
<div style={{ marginLeft: "auto" }}>
<Nav />
</div>
</Container>
</AppShell.Header>
<AppShell.Main>
<Container>
<Text size="sm" c="dimmed" mb="md">
KI-gestützte Verwaltungsplattform für Kirche
</Text>
{loading ? (
<Center mt="xl">
<Loader />
</Center>
) : (
<Routes>
<Route path="/" element={<StartPage />} />
<Route path="/access" element={<AccessPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/app/workflows"
element={
<RequireAuth>
<WorkflowsPage />
</RequireAuth>
}
/>
<Route
path="/app/tasks"
element={
<RequireAuth>
<TasksPage />
</RequireAuth>
}
/>
<Route
path="/app/admin"
element={
<RequireAuth>
<AdminPage />
</RequireAuth>
}
/>
<Route path="*" element={<StartPage />} />
</Routes>
)}
</Container>
</AppShell.Main>
</AppShell>
);
}
export default function App() {
return (
<AuthProvider>
<Shell />
</AuthProvider>
);
}

View file

@ -69,6 +69,33 @@ export interface SyncReport {
errors: Record<string, string>;
}
export interface Me {
id: string;
email: string;
name: string;
role: string;
tenant_id: string;
}
export interface AccessRequest {
id: string;
name: string;
email: string;
reason: string;
status: string;
created_at: string;
decided_at: string | null;
decided_by: string | null;
}
export interface InputSchema {
flow_id: string;
version_no: number;
json_schema: Record<string, unknown>;
ui_schema: Record<string, unknown>;
fallback: string | null;
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
@ -79,6 +106,8 @@ export class ApiError extends Error {
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
// Session cookie (HttpOnly) must travel with every request.
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
@ -114,22 +143,21 @@ export const api = {
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
method: "POST",
}),
runFlow: (flowId: string, input: unknown, tenantId: string) =>
runFlow: (flowId: string, input: unknown) =>
req<Run>(`/api/flows/${flowId}/run`, {
method: "POST",
body: JSON.stringify({ input, tenant_id: tenantId }),
// Tenant comes from the session server-side; no tenant box anymore.
body: JSON.stringify({ input }),
}),
getInputSchema: (flowId: string) =>
req<InputSchema>(`/api/flows/${flowId}/input-schema`),
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
decide: (
taskId: string,
decision: "approve" | "reject",
approver: string,
note: string,
) =>
decide: (taskId: string, decision: "approve" | "reject", note: string) =>
req<Decision>(`/api/approvals/${taskId}/decision`, {
method: "POST",
body: JSON.stringify({ decision, approver, note }),
// Identity comes from the session; the server records the user.
body: JSON.stringify({ decision, note }),
}),
importFlow: (langflowFlowId: string, name?: string) =>
req<{ definition: FlowDefinition; version: FlowVersion }>(
@ -148,4 +176,38 @@ export const api = {
req<SyncReport>("/api/flows/sync", {
method: "POST",
}),
me: async (): Promise<Me | null> => {
try {
return await req<Me>("/api/auth/me");
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
},
consumeMagic: (token: string) =>
req<Me>("/api/auth/magic/consume", {
method: "POST",
body: JSON.stringify({ token }),
}),
requestMagicLink: (email: string) =>
req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", {
method: "POST",
body: JSON.stringify({ email }),
}),
logout: () =>
req<{ message: string }>("/api/auth/logout", { method: "POST" }),
requestAccess: (name: string, email: string, reason: string) =>
req<AccessRequest>("/api/access/requests", {
method: "POST",
body: JSON.stringify({ name, email, reason }),
}),
listAccessRequests: (status?: string) =>
req<AccessRequest[]>(
`/api/access/requests${status ? `?status=${status}` : ""}`,
),
decideAccess: (id: string, approve: boolean) =>
req<{ request: AccessRequest; email_sent: boolean }>(
`/api/access/requests/${id}/${approve ? "approve" : "reject"}`,
{ method: "POST" },
),
};

55
frontend/src/auth.tsx Normal file
View file

@ -0,0 +1,55 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { api, type Me } from "./api";
interface Auth {
me: Me | null;
loading: boolean;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<Auth>({
me: null,
loading: true,
refresh: async () => {},
logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setMe(await api.me());
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
await api.logout();
setMe(null);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<AuthContext.Provider value={{ me, loading, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): Auth {
return useContext(AuthContext);
}

View file

@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
} from "@tabler/icons-react";
import { ApiError, api, type AccessRequest } from "../api";
import StatusBadge from "./StatusBadge";
export default function AccessQueue() {
const [requests, setRequests] = useState<AccessRequest[]>([]);
const [error, setError] = useState("");
const [info, setInfo] = useState("");
const [busyId, setBusyId] = useState("");
const load = useCallback(async () => {
try {
setRequests(await api.listAccessRequests());
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function decide(id: string, approve: boolean) {
setBusyId(id);
setError("");
setInfo("");
try {
const res = await api.decideAccess(id, approve);
setRequests((prev) =>
prev.map((r) => (r.id === id ? res.request : r)),
);
if (approve && !res.email_sent) {
setInfo("Genehmigt — die E-Mail kam nicht an (SMTP prüfen).");
}
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
} finally {
setBusyId("");
}
}
const pending = requests.filter((r) => r.status === "pending");
const done = requests.filter((r) => r.status !== "pending");
return (
<div>
<Title order={3} mb="xs">
Zugangsanfragen
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
{info && (
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
{info}
</Alert>
)}
{pending.length === 0 && (
<Text c="dimmed" mb="md">
Keine offenen Anfragen.
</Text>
)}
<Stack mb="md">
{pending.map((r) => (
<Card withBorder padding="md" key={r.id}>
<Group gap="xs" mb="xs">
<StatusBadge status={r.status} />
<strong>{r.name}</strong>
<Text size="sm" c="dimmed">
{r.email}
</Text>
</Group>
{r.reason && (
<Text size="sm" mb="sm">
{r.reason}
</Text>
)}
<Group>
<Button
color="green"
loading={busyId === r.id}
onClick={() => void decide(r.id, true)}
>
Genehmigen
</Button>
<Button
color="red"
variant="light"
loading={busyId === r.id}
onClick={() => void decide(r.id, false)}
>
Ablehnen
</Button>
</Group>
</Card>
))}
</Stack>
{done.length > 0 && (
<>
<Title order={4} mb="xs">
Entschieden ({done.length})
</Title>
{done.map((r) => (
<Group key={r.id} gap="xs" mb={4}>
<StatusBadge status={r.status} />
<Text size="sm">
{r.name} · {r.email}
</Text>
</Group>
))}
</>
)}
</div>
);
}

View file

@ -1,4 +1,22 @@
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Anchor,
Button,
Card,
Group,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
IconExternalLink,
IconRefresh,
IconUpload,
} from "@tabler/icons-react";
import {
ApiError,
LANGFLOW_URL,
@ -6,6 +24,7 @@ import {
type FlowDefinition,
type FlowVersion,
} from "../api";
import StatusBadge from "./StatusBadge";
export default function AdminTab() {
const [flows, setFlows] = useState<FlowDefinition[]>([]);
@ -86,61 +105,110 @@ export default function AdminTab() {
return (
<div>
<h2>Administration</h2>
{error && <p style={{ color: "crimson" }}>{error}</p>}
{info && <p style={{ color: "green" }}>{info}</p>}
<p>
Flows werden visuell in Langflow entworfen:{" "}
<a href={LANGFLOW_URL} target="_blank" rel="noreferrer">
Langflow öffnen
</a>
</p>
<h3>Import aus Langflow</h3>
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input
value={langflowId}
onChange={(e) => setLangflowId(e.target.value)}
placeholder="Langflow Flow-ID (UUID)"
style={{ flex: 1, fontFamily: "monospace" }}
/>
<input
value={importName}
onChange={(e) => setImportName(e.target.value)}
placeholder="Name (optional)"
/>
<button onClick={() => void onImport()} disabled={!langflowId.trim()}>
Importieren + Deployen
</button>
</div>
<h3>Flows + Versionen</h3>
<div style={{ marginBottom: 12 }}>
<button onClick={() => void onSync()}>
Aus backend/flows synchronisieren
</button>
</div>
{flows.length === 0 && <p>Keine Flows vorhanden.</p>}
<ul>
{flows.map((f) => (
<li key={f.id} style={{ marginBottom: 8 }}>
<strong>{f.name}</strong>{" "}
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>
{f.source_path ? `(${f.source_path})` : "(API)"}
</span>
<ul>
{(versions[f.id] ?? []).map((v) => (
<li key={v.id}>
v{v.version_no} ·{" "}
{v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "}
{v.author || "–"}{" "}
<button onClick={() => void onDeploy(f.id, v.version_no)}>
Deployen
</button>
</li>
))}
</ul>
</li>
))}
</ul>
<Title order={3} mb="xs">
Flows & Versionen
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
{info && (
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
{info}
</Alert>
)}
<Card withBorder padding="md" mb="md">
<Text mb="xs">
Flows werden visuell in Langflow entworfen:{" "}
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
Langflow öffnen <IconExternalLink size={14} />
</Anchor>
</Text>
<Group align="flex-end">
<TextInput
label="Langflow Flow-ID (UUID)"
value={langflowId}
onChange={(e) => setLangflowId(e.target.value)}
style={{ flex: 1 }}
styles={{ input: { fontFamily: "monospace" } }}
/>
<TextInput
label="Name (optional)"
value={importName}
onChange={(e) => setImportName(e.target.value)}
/>
<Button
leftSection={<IconUpload size={16} />}
onClick={() => void onImport()}
disabled={!langflowId.trim()}
>
Importieren + Deployen
</Button>
</Group>
</Card>
<Card withBorder padding="md" mb="md">
<Group>
<Button
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={() => void onSync()}
>
Aus backend/flows synchronisieren
</Button>
</Group>
</Card>
{flows.length === 0 ? (
<Text c="dimmed">Keine Flows vorhanden.</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Flow</Table.Th>
<Table.Th>Version</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Autor</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{flows.map((f) =>
(versions[f.id] ?? []).map((v, i) => (
<Table.Tr key={v.id}>
<Table.Td>
{i === 0 && (
<>
<strong>{f.name}</strong>{" "}
<Text size="xs" c="dimmed" component="span">
{f.source_path ? `(${f.source_path})` : "(API)"}
</Text>
</>
)}
</Table.Td>
<Table.Td>v{v.version_no}</Table.Td>
<Table.Td>
{v.deployed_at ? (
<StatusBadge status="deployed" />
) : (
<StatusBadge status="pending" />
)}
</Table.Td>
<Table.Td>{v.author || "–"}</Table.Td>
<Table.Td>
<Button
size="compact-sm"
variant="light"
onClick={() => void onDeploy(f.id, v.version_no)}
>
Deployen
</Button>
</Table.Td>
</Table.Tr>
)),
)}
</Table.Tbody>
</Table>
)}
</div>
);
}

View file

@ -1,9 +1,23 @@
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Code,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
import { ApiError, api, type ApprovalTask } from "../api";
import { useAuth } from "../auth";
import StatusBadge from "./StatusBadge";
export default function ApprovalsTab() {
const { me } = useAuth();
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
const [approver, setApprover] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [busyId, setBusyId] = useState("");
@ -24,7 +38,7 @@ export default function ApprovalsTab() {
setBusyId(taskId);
setError("");
try {
const result = await api.decide(taskId, decision, approver, note);
const result = await api.decide(taskId, decision, note);
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? result.task : t)),
);
@ -40,56 +54,88 @@ export default function ApprovalsTab() {
return (
<div>
<h2>Freigaben</h2>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<input
value={approver}
onChange={(e) => setApprover(e.target.value)}
placeholder="Name (Bearbeiter)"
/>
<input
<Title order={3} mb="xs">
Freigaben
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
<Text size="sm" c="dimmed" mb="md">
Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem
Konto zugeordnet.
</Text>
<Group mb="md" align="flex-end">
<TextInput
label="Notiz"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Notiz"
style={{ flex: 1 }}
/>
<button onClick={() => void load()}>Aktualisieren</button>
</div>
<h3>Offen ({open.length})</h3>
{open.length === 0 && <p>Keine offenen Freigaben.</p>}
<ul>
<Button
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={() => void load()}
>
Aktualisieren
</Button>
</Group>
<Title order={4} mb="xs">
Offen ({open.length})
</Title>
{open.length === 0 && (
<Text c="dimmed" mb="md">
Keine offenen Freigaben.
</Text>
)}
<Stack mb="md">
{open.map((t) => (
<li key={t.id} style={{ marginBottom: 8 }}>
<strong>{t.title || t.id}</strong> · {t.tenant_id}
<br />
<code>{JSON.stringify(t.payload)}</code>
<br />
<button
onClick={() => void decide(t.id, "approve")}
disabled={busyId === t.id}
>
Freigeben
</button>{" "}
<button
onClick={() => void decide(t.id, "reject")}
disabled={busyId === t.id}
>
Ablehnen
</button>
</li>
<Card withBorder padding="md" key={t.id}>
<Group gap="xs" mb="xs">
<StatusBadge status={t.status} />
<strong>{t.title || t.id}</strong>
<Text size="sm" c="dimmed">
{t.tenant_id}
</Text>
</Group>
<Code block mb="sm">
{JSON.stringify(t.payload)}
</Code>
<Group>
<Button
color="green"
leftSection={<IconCheck size={16} />}
loading={busyId === t.id}
onClick={() => void decide(t.id, "approve")}
>
Freigeben
</Button>
<Button
color="red"
variant="light"
leftSection={<IconX size={16} />}
loading={busyId === t.id}
onClick={() => void decide(t.id, "reject")}
>
Ablehnen
</Button>
</Group>
</Card>
))}
</ul>
<h3>Entschieden ({done.length})</h3>
<ul>
{done.map((t) => (
<li key={t.id}>
<strong>{t.status}</strong> · {t.title || t.id} ·{" "}
{t.approver ?? "–"}
</Stack>
<Title order={4} mb="xs">
Entschieden ({done.length})
</Title>
{done.map((t) => (
<Group key={t.id} gap="xs" mb={4}>
<StatusBadge status={t.status} />
<Text size="sm">
{t.title || t.id} · {t.approver ?? "–"}
{t.decision_note ? ` · „${t.decision_note}“` : ""}
</li>
))}
</ul>
</Text>
</Group>
))}
</div>
);
}

View file

@ -1,105 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, api, type FlowDefinition, type Run } from "../api";
const inputStyle: React.CSSProperties = {
width: "100%",
fontFamily: "monospace",
fontSize: 13,
};
export default function FlowsTab() {
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [flowId, setFlowId] = useState("");
const [inputJson, setInputJson] = useState(
'{\n "betrag": 120000,\n "schwelle": 200000\n}',
);
const [tenant, setTenant] = useState("default");
const [runs, setRuns] = useState<Run[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const loadFlows = useCallback(async () => {
try {
const list = await api.listFlows();
setFlows(list);
const first = list[0];
if (first && !flowId) setFlowId(first.id);
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
}
}, [flowId]);
const loadRuns = useCallback(async (id: string) => {
if (!id) return;
try {
setRuns(await api.listRuns(id));
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
}
}, []);
useEffect(() => {
void loadFlows();
}, [loadFlows]);
useEffect(() => {
void loadRuns(flowId);
}, [flowId, loadRuns]);
async function onRun() {
setBusy(true);
setError("");
try {
const payload: unknown = JSON.parse(inputJson);
const run = await api.runFlow(flowId, payload, tenant);
setRuns((prev) => [run, ...prev]);
} catch (e) {
if (e instanceof SyntaxError) setError("Ungültiges JSON in der Eingabe.");
else setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
} finally {
setBusy(false);
}
}
return (
<div>
<h2>Vorgang starten</h2>
{error && <p style={{ color: "crimson" }}>{error}</p>}
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}>
<option value="">— Flow wählen —</option>
{flows.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
<input
value={tenant}
onChange={(e) => setTenant(e.target.value)}
placeholder="Mandant"
style={{ width: 160 }}
/>
<button onClick={() => void onRun()} disabled={!flowId || busy}>
{busy ? "Läuft …" : "Starten"}
</button>
</div>
<textarea
rows={6}
style={inputStyle}
value={inputJson}
onChange={(e) => setInputJson(e.target.value)}
/>
<h3>Letzte Läufe</h3>
{runs.length === 0 && <p>Noch keine Läufe.</p>}
<ul>
{runs.map((r) => (
<li key={r.id}>
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { ActionIcon, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
export default function SchemeToggle() {
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
return (
<ActionIcon
variant="subtle"
size="lg"
onClick={() => toggleColorScheme()}
aria-label={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
title={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
>
{dark ? <IconSun size={20} /> : <IconMoon size={20} />}
</ActionIcon>
);
}

View file

@ -0,0 +1,21 @@
import { Badge } from "@mantine/core";
/* Single color language for every status in the app. */
const COLORS: Record<string, string> = {
completed: "green",
approved: "green",
genehmigt: "green",
deployed: "green",
running: "blue",
open: "yellow",
pending: "yellow",
pending_approval: "yellow",
failed: "red",
rejected: "red",
abgelehnt: "red",
};
export default function StatusBadge({ status }: { status: string }) {
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>;
}

View file

@ -1,9 +1,22 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import "./photos.css";
import App from "./App";
import { colorSchemeManager, theme } from "./theme";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<MantineProvider
theme={theme}
colorSchemeManager={colorSchemeManager}
defaultColorScheme="light"
>
<BrowserRouter>
<App />
</BrowserRouter>
</MantineProvider>
</StrictMode>,
);

Some files were not shown because too many files have changed in this diff Show more