Compare commits

..

No commits in common. "b1c8d6c66e9d2437b06a93711064838168e0f1d0" and "af993bd43f3755ac1b62fb257844183b52049034" have entirely different histories.

109 changed files with 801 additions and 8748 deletions

View file

@ -11,23 +11,14 @@
}, },
"mounts": [ "mounts": [
"source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly", "source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly",
"source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume", "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind"
"source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume"
], ],
"features": { "features": {
// moby:false — python:3.12-slim is Debian trixie now, whose repos have "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
// 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, "overrideCommand": false,
// Absolute paths: lifecycle commands run in the image WORKDIR "postCreateCommand": "sudo -E bash .devcontainer/setup.sh",
// (/srv/kirche-einfach/backend), not the workspace folder — relative "postStartCommand": "bash .devcontainer/postStart.sh",
// .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": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
@ -38,7 +29,7 @@
"esbenp.prettier-vscode" "esbenp.prettier-vscode"
], ],
"settings": { "settings": {
"python.defaultInterpreterPath": "/opt/venv/bin/python" "python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python"
} }
} }
} }

View file

@ -2,73 +2,31 @@
# Dev-only setup (runs via postCreateCommand in the attached `api` container). # Dev-only setup (runs via postCreateCommand in the attached `api` container).
# Production needs none of this: images carry their dependencies and the # Production needs none of this: images carry their dependencies and the
# api entrypoint migrates on every start. # api entrypoint migrates on every start.
set -eo pipefail set -e
REPO=/srv/kirche-einfach 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 ===" echo "=== Setting up kirche-einfach ==="
# Writable dirs first: this script runs via sudo as root, but the editor and # Backend deps into the venv volume (setup runs via sudo; handed to vscode below).
# 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 ---" echo "--- Installing Python dependencies ---"
cd "$REPO/backend" cd "$REPO/backend"
as_vscode "uv sync" uv sync
# opencode CLI (dev tooling; intentionally not baked into the image). # opencode CLI (dev tooling; intentionally not baked into the image).
if [ ! -x /home/vscode/.opencode/bin/opencode ]; then if [ ! -x /home/vscode/.opencode/bin/opencode ]; then
echo "--- Installing opencode ---" echo "--- Installing opencode ---"
as_vscode "curl -fsSL https://opencode.ai/install | bash" su vscode -c "curl -fsSL https://opencode.ai/install | bash"
ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode
fi fi
# Compose services (langflow, lfx) must be up; fail loudly otherwise. # Compose services (langflow, lfx) must be up; fail loudly otherwise.
# (api/frontend are starting alongside this script.) # (api/frontend are starting alongside this script.)
# 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 "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 ---" echo "--- Waiting for compose services ---"
if [ ! -f "$REPO/.env" ]; then
echo "ERROR: $REPO/.env is missing. Copy .env.example to .env and fill in secrets." >&2
exit 1
fi
python3 - <<'EOF' python3 - <<'EOF'
import socket import socket
import sys import sys
@ -78,13 +36,13 @@ targets = [("langflow", 7860), ("lfx", 6004)]
deadline = time.time() + 180 deadline = time.time() + 180
pending = dict(targets) pending = dict(targets)
while pending and time.time() < deadline: while pending and time.time() < deadline:
for host, port in list(pending.items()): for host, port in list(pending):
s = socket.socket() s = socket.socket()
s.settimeout(2) s.settimeout(2)
try: try:
s.connect((host, port)) s.connect((host, port))
print(f"OK {host}:{port}", flush=True) print(f"OK {host}:{port}", flush=True)
del pending[host] del pending[(host, port)]
except OSError: except OSError:
pass pass
finally: finally:
@ -99,18 +57,13 @@ EOF
# entrypoint on every start). # entrypoint on every start).
echo "--- Syncing canonical flows ---" echo "--- Syncing canonical flows ---"
cd "$REPO/backend" cd "$REPO/backend"
as_vscode "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)" 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 ""
echo "=== Setup complete ===" echo "=== Setup complete ==="
echo "Forgejo username: $FORGEJO_USERNAME" 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,21 +1,12 @@
# Copy to `.env` and fill in values. `.env` is gitignored. # Copy to `.env` and fill in values. `.env` is gitignored.
# Generate secrets with: openssl rand -hex 32 # It holds only ubiquitous variables (identical value in dev and prod).
# Validate before deploying: uv run python scripts/check_prod.py --env ../.env # Dev-only variables travel via devcontainer.json `containerEnv`
# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas). # (e.g. APP_ENV=dev); paths resolve from the repo root in code.
# Prod overrides via this .env file (e.g. APP_ENV=prod).
# Paths resolve from the repo root in code.
FORGEJO_TOKEN= FORGEJO_TOKEN=
# --- langflow (authoring UI) + lfx (flow runtime): pinned in lockstep --- # --- langflow (authoring UI) ---
# 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_URL=http://langflow:7860
LANGFLOW_SUPERUSER=langflow 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_SUPERUSER_PASSWORD=
LANGFLOW_SECRET_KEY= LANGFLOW_SECRET_KEY=
# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. # Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope.
@ -28,27 +19,9 @@ LFX_API_KEY=lfx-
# --- API self-reference for flow callbacks (must be reachable FROM lfx) --- # --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
CALLBACK_BASE_URL=http://api:8000 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: unset/preview serves the production bundle --- # --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it. FRONTEND_MODE=dev
# 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 (optional; tests mock the provider without it) ---
# LLM_API_KEY= # LLM_API_KEY=

View file

@ -19,6 +19,4 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: cd backend && uv sync - 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 - run: cd backend && uv run pytest

4
.gitignore vendored
View file

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

View file

@ -7,8 +7,6 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/
Backend (run from `backend/`): Backend (run from `backend/`):
- `uv sync` — install dependencies - `uv sync` — install dependencies
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed) - `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 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 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) - `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)
@ -34,11 +32,6 @@ profiles, or override files. Containers: `api` (FastAPI :8000, attached),
- Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via - Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via
`git pull` + restart, image rebuilds only for dependency changes `git pull` + restart, image rebuilds only for dependency changes
(`docker compose up -d --build`). (`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 ## Definition of done
@ -69,25 +62,3 @@ report = await sync_from_directory(session, lfx, settings.flows_dir)
## Git ## 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`). 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.

View file

@ -1,49 +0,0 @@
# 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,21 +8,12 @@ ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
# lfx = flow runtime; requests = convenience for custom-component code. # lfx = flow runtime; requests = convenience for custom-component code.
# Pinned to the Langflow minor (contract: major.minor must match Langflow; RUN pip install lfx requests
# patches independent). Bump together with LANGFLOW_VERSION (.env.example).
ARG LFX_VERSION=1.12.0
RUN pip install "lfx~=${LFX_VERSION}" requests
# Non-root runtime: uploads persist into the mounted volume. RUN mkdir -p /flows
RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows
VOLUME /flows VOLUME /flows
EXPOSE 6004 EXPOSE 6004
USER lfx
# Serve the /flows dir (uploaded flows persist there via --flow-dir). # Serve the /flows dir (uploaded flows persist there via --flow-dir).
# No positional path: with one, lfx>=1.12.1 exits when the dir holds no # Flags verified against `lfx serve --help` (lfx 1.12.0).
# .json yet ("No .json files found"), restart-looping the container on CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"]
# 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,28 +22,8 @@ needs judgment. Every step is documented and traceable.
deploy to the lightweight LFX runtime and can run synchronously or streamed. 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 - **Approvals** — a flow can pause and ask a human for a decision. Approving
can automatically start a follow-up ("continuation") flow. can automatically start a follow-up ("continuation") flow.
- **Access management** — public landing page (German) with an access-request - **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben*
form (request + approval). Approved users get a passwordless magic-link (approvals), *Administration* (import, sync, deploy).
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 ## Infrastructure
@ -126,32 +106,15 @@ Flow workflow: design in Langflow → export into `backend/flows/<name>.json`
(`--pull`, or UI download) → commit → the watcher deploys it automatically. (`--pull`, or UI download) → commit → the watcher deploys it automatically.
Deleting a file removes the flow including its runs (git keeps the JSON). Deleting a file removes the flow including its runs (git keeps the JSON).
## Production (self-hosted — TLS/reverse proxy required, not included) ## Production (self-hosted, internal use — no TLS/reverse proxy included)
1. Check out the repo on the host, create `.env` from `.env.example` with 1. Check out the repo on the host, create `.env` from `.env.example` with
real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`. real secrets and `FRONTEND_MODE=preview`.
2. Validate it: `uv run python scripts/check_prod.py --env ../.env` 2. `docker compose up -d --build`.
(run from `backend/`; blocks on weak secrets or dev modes). 3. Code updates via `git pull` + `docker compose restart api frontend`
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 (the checkout is bind-mounted); image rebuilds only for dependency
changes (`docker compose up -d --build`). 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`) Container updates: registry images (`langflowai/langflow:latest`)
auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built
images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`. images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`.

View file

@ -1,20 +0,0 @@
# 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).

View file

@ -1 +0,0 @@
0.1.0

View file

@ -1,9 +0,0 @@
# 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,28 +4,17 @@
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \ 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 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, # Foundational, inert in prod: non-root user for the editor, sudo for setup,
# openssh-client for the devcontainer post-start hook, curl for the opencode # openssh-client for the devcontainer post-start hook.
# 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \ && apt-get install -y --no-install-recommends sudo openssh-client \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd -m -s /bin/bash vscode \ && 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 WORKDIR /srv/kirche-einfach/backend
COPY pyproject.toml alembic.ini entrypoint.sh ./ COPY pyproject.toml alembic.ini entrypoint.sh ./
@ -34,12 +23,9 @@ COPY migrations ./migrations
COPY scripts ./scripts COPY scripts ./scripts
# No lockfile is committed; dependencies resolve at build time. # No lockfile is committed; dependencies resolve at build time.
# Full groups (incl. dev): pytest and friends must work in dev with zero steps. # Full groups (incl. dev): pytest and friends must work in dev with zero steps.
# a+rX: the image builds as root, but the attached devcontainer runs as vscode. RUN uv sync && chmod +x entrypoint.sh
RUN uv sync && chmod +x entrypoint.sh && chmod -R a+rX /opt/python /opt/venv
ENV FLOWS_WATCH_ENABLED=true ENV FLOWS_WATCH_ENABLED=true
EXPOSE 8000 EXPOSE 8000
# Explicit interpreter: the repo bind-mount shadows the image at runtime and ENTRYPOINT ["./entrypoint.sh"]
# git tracks entrypoint.sh as 100644, so the exec bit cannot be relied on.
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]

View file

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

View file

@ -1,192 +0,0 @@
"""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")

View file

@ -1,54 +0,0 @@
"""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

View file

@ -1,178 +0,0 @@
"""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

@ -1,58 +0,0 @@
"""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

View file

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

View file

@ -24,7 +24,9 @@ def slugify(name: str) -> str:
def canonical_bytes(doc: dict) -> bytes: def canonical_bytes(doc: dict) -> bytes:
"""Deterministic serialization: stable bytes for stable git diffs.""" """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: def content_hash(doc: dict) -> str:
@ -36,7 +38,9 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
return Path(flows_dir) / f"{stem}.json" 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). """Write a flow document to backend/flows atomically (tmp + rename).
The filename derives from `name` (or the document's own name). Returns The filename derives from `name` (or the document's own name). Returns
@ -46,7 +50,9 @@ def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None
flows_dir.mkdir(parents=True, exist_ok=True) flows_dir.mkdir(parents=True, exist_ok=True)
stem = slugify(name or doc.get("name") or "flow") stem = slugify(name or doc.get("name") or "flow")
target = flow_path(flows_dir, stem) 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: try:
with os.fdopen(fd, "wb") as tmp: with os.fdopen(fd, "wb") as tmp:
tmp.write(canonical_bytes(doc)) tmp.write(canonical_bytes(doc))

View file

@ -1,152 +0,0 @@
"""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,7 +51,9 @@ class LfxRunResult:
class LfxClient: class LfxClient:
"""Thin async wrapper around the LFX serve HTTP API.""" """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.base_url = base_url.rstrip("/")
self.timeout = timeout self.timeout = timeout
self.api_key = api_key self.api_key = api_key

View file

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

View file

@ -37,31 +37,19 @@ class FlowVersionRead(BaseModel):
deployed_at: datetime | None deployed_at: datetime | None
author: str author: str
created_at: datetime 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): 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) input: dict = Field(default_factory=dict)
version_no: int | None = None version_no: int | None = None
tenant_id: str = Field(default="default", max_length=200)
session_id: str | None = None 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): class RunRead(BaseModel):
@ -115,10 +103,10 @@ class FlowImportRead(BaseModel):
class DecisionRequest(BaseModel): class DecisionRequest(BaseModel):
"""Decide a task. Identity comes from the session, not the body."""
decision: str = Field(pattern="^(approve|reject)$") decision: str = Field(pattern="^(approve|reject)$")
approver: str = Field(default="", max_length=200)
note: str = Field(default="", max_length=2000) note: str = Field(default="", max_length=2000)
callback_base_url: str | None = None
class DecisionRead(BaseModel): class DecisionRead(BaseModel):

View file

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

View file

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

View file

@ -5,12 +5,9 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI 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.api import approvals_router, flows_router
from .flows.sync import run_sync_blocking, watch_forever from .flows.sync import run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .settings import settings from .settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,8 +29,8 @@ def _blocking_startup_sync() -> None:
logger.warning("startup flow sync: %s: %s", filename, reason) logger.warning("startup flow sync: %s: %s", filename, reason)
except FileNotFoundError as exc: except FileNotFoundError as exc:
logger.error("startup flow sync skipped: %s", exc) logger.error("startup flow sync skipped: %s", exc)
except Exception: except Exception: # noqa: BLE001 - API must start even if sync fails
logger.exception("startup flow sync failed") # never fatal at boot logger.exception("startup flow sync failed")
@asynccontextmanager @asynccontextmanager
@ -52,7 +49,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
task.cancel() task.cancel()
try: try:
await task await task
except asyncio.CancelledError: except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
pass pass
@ -60,25 +57,14 @@ app = FastAPI(
title="Kirche Einfach API", title="Kirche Einfach API",
description="KI-gestützte Verwaltungsplattform für Kirche", description="KI-gestützte Verwaltungsplattform für Kirche",
version="0.1.0", version="0.1.0",
# Interactive docs only in debug: /api/docs maps the whole surface. docs_url="/api/docs",
docs_url="/api/docs" if settings.debug else None, redoc_url="/api/redoc",
redoc_url="/api/redoc" if settings.debug else None, openapi_url="/api/openapi.json",
openapi_url="/api/openapi.json" if settings.debug else None,
lifespan=lifespan, 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(flows_router)
app.include_router(approvals_router) app.include_router(approvals_router)
app.include_router(access_router)
app.include_router(auth_router)
@app.get("/") @app.get("/")

View file

@ -1,92 +0,0 @@
"""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,13 +1,3 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from .user import AccessRequest, MagicLinkToken, User, UserSession
__all__ = [ __all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"]
"AccessRequest",
"ApprovalTask",
"FlowDefinition",
"FlowRun",
"FlowVersion",
"MagicLinkToken",
"User",
"UserSession",
]

View file

@ -1,6 +1,6 @@
"""Flow engine domain models (SQLModel tables).""" """Flow engine domain models (SQLModel tables)."""
from datetime import UTC, datetime from datetime import datetime, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
def utcnow() -> datetime: def utcnow() -> datetime:
return datetime.now(UTC) return datetime.now(timezone.utc)
def _ts_column() -> Column: def _ts_column() -> Column:
@ -33,7 +33,9 @@ class FlowDefinition(SQLModel, table=True):
# (e.g. "simple_rule.json"). None for definitions created via the API. # (e.g. "simple_rule.json"). None for definitions created via the API.
source_path: str | None = Field(default=None, max_length=255) source_path: str | None = Field(default=None, max_length=255)
# Flow to run after a human approval on this flow's runs (nullable). # 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()) created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
@ -52,9 +54,6 @@ class FlowVersion(SQLModel, table=True):
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column()) deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
author: str = Field(default="", max_length=200) author: str = Field(default="", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) 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): class FlowRun(SQLModel, table=True):

View file

@ -1,72 +0,0 @@
"""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,9 +14,7 @@ class Settings(BaseSettings):
debug: bool = False debug: bool = False
# App database (SQLite file). Override via DATABASE_URL for other engines. # App database (SQLite file). Override via DATABASE_URL for other engines.
# Must be a URL (not a bare path): db.py and Alembic hand it to database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db")
# 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. # Canonical flow definitions (source of truth): one Langflow export per file.
flows_dir: str = str(REPO_ROOT / "backend" / "flows") flows_dir: str = str(REPO_ROOT / "backend" / "flows")
@ -31,31 +29,6 @@ class Settings(BaseSettings):
# Base URL flows use for callbacks into this API. Must be reachable # Base URL flows use for callbacks into this API. Must be reachable
# FROM the LFX process (compose: http://api:8000). # FROM the LFX process (compose: http://api:8000).
callback_base_url: str = "http://localhost: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() settings = Settings()

View file

@ -1,58 +0,0 @@
# 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

@ -1,12 +0,0 @@
"""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

@ -1,162 +0,0 @@
"""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,24 +6,10 @@ set -e
cd /srv/kirche-einfach/backend cd /srv/kirche-einfach/backend
# Runtime SQLite file lives in the bind-mount: ensure the dir exists and is .venv/bin/alembic upgrade head
# 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 if [ "${APP_ENV:-prod}" = "dev" ]; then
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
else else
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
fi fi

View file

@ -292,7 +292,7 @@
"list": false, "list": false,
"show": true, "show": true,
"multiline": 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 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", "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",
"fileTypes": [], "fileTypes": [],
"file_path": "", "file_path": "",
"password": false, "password": false,
@ -399,20 +399,6 @@
"allows_loop": false, "allows_loop": false,
"group_outputs": false, "group_outputs": false,
"tool_mode": true "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": [ "field_order": [
@ -423,7 +409,7 @@
"edited": false, "edited": false,
"metadata": { "metadata": {
"module": "custom_components.envelope", "module": "custom_components.envelope",
"code_hash": "02e69613e210", "code_hash": "2b0bd3c3d529",
"dependencies": { "dependencies": {
"total_dependencies": 1, "total_dependencies": 1,
"dependencies": [ "dependencies": [
@ -872,32 +858,6 @@
"node": { "node": {
"template": { "template": {
"_type": "Component", "_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": { "callback_url": {
"tool_mode": false, "tool_mode": false,
"trace_as_input": true, "trace_as_input": true,
@ -931,7 +891,7 @@
"list": false, "list": false,
"show": true, "show": true,
"multiline": 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=\"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", "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",
"fileTypes": [], "fileTypes": [],
"file_path": "", "file_path": "",
"password": false, "password": false,
@ -1080,7 +1040,6 @@
"trigger", "trigger",
"run_id", "run_id",
"callback_url", "callback_url",
"callback_token",
"continuation_flow_id", "continuation_flow_id",
"facts" "facts"
], ],
@ -1089,7 +1048,7 @@
"edited": false, "edited": false,
"metadata": { "metadata": {
"module": "custom_components.approval_callback", "module": "custom_components.approval_callback",
"code_hash": "28b2f5894566", "code_hash": "a334376410f7",
"dependencies": { "dependencies": {
"total_dependencies": 2, "total_dependencies": 2,
"dependencies": [ "dependencies": [
@ -1845,34 +1804,6 @@
"target": "CustomComponent-callback", "target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" "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, "animated": false,
"className": "", "className": "",

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

@ -1,96 +0,0 @@
"""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

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

View file

@ -27,22 +27,9 @@ testpaths = ["tests"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
# Pinned to the Langflow minor (contract: major.minor must match). "lfx",
# Bump together with LANGFLOW_VERSION (.env.example) + Dockerfile.lfx.
"lfx~=1.12.0",
"pytest>=8", "pytest>=8",
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
"pytest-cov>=6", "pytest-cov>=6",
"respx>=0.22", "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

@ -1,111 +0,0 @@
"""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,77 +87,6 @@ def custom_node(
return node_dict 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 # edges
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -206,7 +135,9 @@ def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# flow document # 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 { return {
"data": { "data": {
"nodes": nodes, "nodes": nodes,
@ -230,7 +161,7 @@ def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict])
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# custom component code # 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.io import MessageTextInput, Output
from lfx.schema.message import Message from lfx.schema.message import Message
import json import json
@ -274,9 +205,9 @@ class RuleCheckComponent(Component):
except (TypeError, ValueError): except (TypeError, ValueError):
schwelle = 200000.0 schwelle = 200000.0
return Message(text="PRUEFUNG" if betrag > schwelle else "OK") 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.io import MessageTextInput, Output
from lfx.schema.message import Message from lfx.schema.message import Message
import json import json
@ -302,11 +233,6 @@ class EnvelopeComponent(Component):
name="continuation", name="continuation",
method="get_continuation", method="get_continuation",
), ),
Output(
display_name="Callback Token",
name="callback_token",
method="get_callback_token",
),
] ]
def _text(self, value) -> str: def _text(self, value) -> str:
@ -338,12 +264,9 @@ class EnvelopeComponent(Component):
def get_continuation(self) -> Message: def get_continuation(self) -> Message:
value = self._env().get("continuation_flow_id") value = self._env().get("continuation_flow_id")
return Message(text=str(value) if value else "") return Message(text=str(value) if value else "")
'''
def get_callback_token(self) -> Message: APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
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.io import MessageTextInput, Output
from lfx.schema.message import Message from lfx.schema.message import Message
@ -356,10 +279,6 @@ class ApprovalCallbackComponent(Component):
MessageTextInput(name="trigger", display_name="Trigger"), MessageTextInput(name="trigger", display_name="Trigger"),
MessageTextInput(name="run_id", display_name="Run ID"), MessageTextInput(name="run_id", display_name="Run ID"),
MessageTextInput(name="callback_url", display_name="Callback URL"), MessageTextInput(name="callback_url", display_name="Callback URL"),
MessageTextInput(
name="callback_token",
display_name="Callback Token",
),
MessageTextInput( MessageTextInput(
name="continuation_flow_id", name="continuation_flow_id",
display_name="Continuation Flow ID", display_name="Continuation Flow ID",
@ -396,14 +315,13 @@ class ApprovalCallbackComponent(Component):
"payload": {"verdict": trigger, "facts": facts}, "payload": {"verdict": trigger, "facts": facts},
"continuation_flow_id": self._text(self.continuation_flow_id) or None, "continuation_flow_id": self._text(self.continuation_flow_id) or None,
} }
headers = {"X-Callback-Token": self._text(self.callback_token)}
try: try:
response = httpx.post(url, json=body, headers=headers, timeout=10.0) response = httpx.post(url, json=body, timeout=10.0)
response.raise_for_status() response.raise_for_status()
except Exception as exc: except Exception as exc:
return Message(text=f"CALLBACK_FAILED: {exc}") return Message(text=f"CALLBACK_FAILED: {exc}")
return Message(text="PENDING_HUMAN_APPROVAL") return Message(text="PENDING_HUMAN_APPROVAL")
""" '''
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -466,7 +384,9 @@ def build_approval_handoff() -> dict:
"ChatInput-entry", "ChatInput-entry",
position=(0, 300), 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)) rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
router = builtin_node( router = builtin_node(
"lfx.components.flow_controls.conditional_router", "lfx.components.flow_controls.conditional_router",
@ -500,7 +420,6 @@ def build_approval_handoff() -> dict:
edge(router, "true_result", callback, "trigger"), edge(router, "true_result", callback, "trigger"),
edge(envelope, "run_id", callback, "run_id"), edge(envelope, "run_id", callback, "run_id"),
edge(envelope, "callback_url", callback, "callback_url"), edge(envelope, "callback_url", callback, "callback_url"),
edge(envelope, "callback_token", callback, "callback_token"),
edge(envelope, "continuation", callback, "continuation_flow_id"), edge(envelope, "continuation", callback, "continuation_flow_id"),
edge(envelope, "payload", callback, "facts"), edge(envelope, "payload", callback, "facts"),
edge(callback, "result", out_pending, "input_value"), edge(callback, "result", out_pending, "input_value"),
@ -514,43 +433,12 @@ 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: def main() -> None:
FIXTURE_DIR.mkdir(parents=True, exist_ok=True) FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
FLOW_DIR.mkdir(parents=True, exist_ok=True) FLOW_DIR.mkdir(parents=True, exist_ok=True)
flows = { flows = {
"simple_rule.json": build_simple_rule(), "simple_rule.json": build_simple_rule(),
"approval_handoff.json": build_approval_handoff(), "approval_handoff.json": build_approval_handoff(),
"intake_form.json": build_intake_form(),
} }
for filename, doc in flows.items(): for filename, doc in flows.items():
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"

View file

@ -10,31 +10,18 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent BACKEND = Path(__file__).resolve().parent.parent
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH. VENV_BIN = BACKEND / ".venv" / "bin"
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
FIXTURES = BACKEND / "tests" / "fixtures" / "flows" FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
LFX_PORT = 6004
def _free_port() -> int: API_PORT = 8000
# 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" LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
@ -54,9 +41,6 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
import httpx import httpx
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c: 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) r = c.request(method, path, json=body)
try: try:
return r.status_code, r.json() return r.status_code, r.json()
@ -74,7 +58,9 @@ def main() -> None:
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-")) tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
flow_dir = Path("/tmp/smoke-e2e-flows") flow_dir = Path("/tmp/smoke-e2e-flows")
flow_dir.mkdir(exist_ok=True) 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. # Migrate a throwaway SQLite schema to head before starting the API.
from alembic import command as alembic_command from alembic import command as alembic_command
from alembic.config import Config from alembic.config import Config
@ -83,36 +69,8 @@ def main() -> None:
cfg = Config() cfg = Config()
cfg.set_main_option("script_location", str(BACKEND / "migrations")) cfg.set_main_option("script_location", str(BACKEND / "migrations"))
cfg.set_main_option("sqlalchemy.url", db_url) 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") 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 = { env = {
**os.environ, **os.environ,
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set # lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
@ -123,29 +81,16 @@ def main() -> None:
"FLOWS_WATCH_ENABLED": "false", "FLOWS_WATCH_ENABLED": "false",
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}", "LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}", "CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
"CALLBACK_TOKEN": "smoke-callback-token",
} }
lfx = subprocess.Popen( lfx = subprocess.Popen(
[ [str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1",
str(VENV_BIN / "lfx"), "--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)],
"serve", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env,
"--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( uvicorn = subprocess.Popen(
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)], [str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1",
cwd=str(BACKEND), "--port", str(API_PORT)],
stdout=subprocess.DEVNULL, cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env, env=env,
) )
try: try:
@ -161,8 +106,7 @@ def main() -> None:
fx = json.loads((FIXTURES / "simple_rule.json").read_text()) fx = json.loads((FIXTURES / "simple_rule.json").read_text())
s, ver = api( s, ver = api(
"POST", "POST", f"/api/flows/{fid}/versions",
f"/api/flows/{fid}/versions",
{"langflow_json": fx, "author": "smoke"}, {"langflow_json": fx, "author": "smoke"},
) )
check("create version", s == 201, f"v{ver.get('version_no')}") check("create version", s == 201, f"v{ver.get('version_no')}")
@ -171,25 +115,17 @@ def main() -> None:
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160]) check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
s, run = api( s, run = api(
"POST", "POST", f"/api/flows/{fid}/run",
f"/api/flows/{fid}/run",
{"input": {"betrag": 120000, "schwelle": 200000}}, {"input": {"betrag": 120000, "schwelle": 200000}},
) )
check( check("run standard", s == 200 and run.get("status") == "completed",
"run standard", json.dumps(run.get("output"))[:120])
s == 200 and run.get("status") == "completed",
json.dumps(run.get("output"))[:120],
)
s, run2 = api( s, run2 = api(
"POST", "POST", f"/api/flows/{fid}/run",
f"/api/flows/{fid}/run",
{"input": {"betrag": 999000, "schwelle": 200000}}, {"input": {"betrag": 999000, "schwelle": 200000}},
) )
check( check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""))
"run sonderfall",
s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""),
)
s, tasks = api("GET", "/api/approvals") s, tasks = api("GET", "/api/approvals")
check("approvals empty (no callback flow)", s == 200 and tasks == []) 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.db import get_engine, reset_engine
from app.flows import export, sync from app.flows import export, sync
from app.flows.langflow_client import LangflowClient, LangflowError from app.flows.langflow_client import LangflowClient
from app.flows.lfx_client import LfxClient from app.flows.lfx_client import LfxClient
from app.flows.schemas import SyncReport from app.flows.schemas import SyncReport
from app.settings import settings 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 name = doc.get("name") or flow_id
path = export.write_flow_file(flows_dir, doc, name=name) path = export.write_flow_file(flows_dir, doc, name=name)
pulled.append(path.stem) pulled.append(path.stem)
except (LangflowError, OSError, ValueError) as exc: except Exception as exc: # noqa: BLE001 - pull-all is total
errors[flow_id] = str(exc) errors[flow_id] = str(exc)
return {"pulled": sorted(pulled), "errors": errors} return {"pulled": sorted(pulled), "errors": errors}
@ -73,11 +73,12 @@ async def main_async(args) -> int:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument( parser.add_argument("--pull", action="store_true",
"--pull", action="store_true", help="export all Langflow flows to backend/flows first" help="export all Langflow flows to backend/flows first")
) parser.add_argument("--sync", action="store_true",
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX") help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)") parser.add_argument("--dir", default=None,
help="flows directory (default: settings.flows_dir)")
args = parser.parse_args() args = parser.parse_args()
if not args.pull and not args.sync: if not args.pull and not args.sync:
args.sync = True args.sync = True

View file

@ -17,8 +17,6 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
# Must precede all `app.*` imports: Settings is instantiated at import time. # Must precede all `app.*` imports: Settings is instantiated at import time.
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}" os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows") 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 # The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead. # directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false" os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -83,48 +81,6 @@ async def client():
yield c 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") @pytest.fixture(scope="session")
def lfx_server(tmp_path_factory): def lfx_server(tmp_path_factory):
"""Real `lfx serve` subprocess on a free port; points settings at it. """Real `lfx serve` subprocess on a free port; points settings at it.
@ -151,7 +107,9 @@ def lfx_server(tmp_path_factory):
sock.close() sock.close()
flow_dir = tmp_path_factory.mktemp("lfx-flows") 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) (flow_dir / "seed.json").write_text(seed)
env = { env = {

View file

@ -292,7 +292,7 @@
"list": false, "list": false,
"show": true, "show": true,
"multiline": 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 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", "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",
"fileTypes": [], "fileTypes": [],
"file_path": "", "file_path": "",
"password": false, "password": false,
@ -399,20 +399,6 @@
"allows_loop": false, "allows_loop": false,
"group_outputs": false, "group_outputs": false,
"tool_mode": true "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": [ "field_order": [
@ -423,7 +409,7 @@
"edited": false, "edited": false,
"metadata": { "metadata": {
"module": "custom_components.envelope", "module": "custom_components.envelope",
"code_hash": "02e69613e210", "code_hash": "2b0bd3c3d529",
"dependencies": { "dependencies": {
"total_dependencies": 1, "total_dependencies": 1,
"dependencies": [ "dependencies": [
@ -872,32 +858,6 @@
"node": { "node": {
"template": { "template": {
"_type": "Component", "_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": { "callback_url": {
"tool_mode": false, "tool_mode": false,
"trace_as_input": true, "trace_as_input": true,
@ -931,7 +891,7 @@
"list": false, "list": false,
"show": true, "show": true,
"multiline": 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=\"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", "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",
"fileTypes": [], "fileTypes": [],
"file_path": "", "file_path": "",
"password": false, "password": false,
@ -1080,7 +1040,6 @@
"trigger", "trigger",
"run_id", "run_id",
"callback_url", "callback_url",
"callback_token",
"continuation_flow_id", "continuation_flow_id",
"facts" "facts"
], ],
@ -1089,7 +1048,7 @@
"edited": false, "edited": false,
"metadata": { "metadata": {
"module": "custom_components.approval_callback", "module": "custom_components.approval_callback",
"code_hash": "28b2f5894566", "code_hash": "a334376410f7",
"dependencies": { "dependencies": {
"total_dependencies": 2, "total_dependencies": 2,
"dependencies": [ "dependencies": [
@ -1845,34 +1804,6 @@
"target": "CustomComponent-callback", "target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" "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, "animated": false,
"className": "", "className": "",

File diff suppressed because one or more lines are too long

View file

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

View file

@ -1,326 +0,0 @@
"""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

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

View file

@ -7,7 +7,6 @@ from uuid import UUID, uuid4
from app.flows.sync import lfx_id_for from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows" FIXTURES = Path(__file__).parent / "fixtures" / "flows"
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict: def load_fixture(name: str) -> dict:
@ -15,7 +14,9 @@ def load_fixture(name: str) -> dict:
async def _create_flow(client, name="regeltest"): 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 assert response.status_code == 201, response.text
return response.json() return response.json()
@ -35,8 +36,7 @@ async def _deploy(client, flow_id, version_no=1):
return response.json() return response.json()
async def test_flow_crud(client, login): async def test_flow_crud(client):
await login()
created = await _create_flow(client, "crud-flow") created = await _create_flow(client, "crud-flow")
assert created["status"] == "active" assert created["status"] == "active"
@ -55,63 +55,7 @@ async def test_flow_crud(client, login):
assert response.status_code == 404 assert response.status_code == 404
async def test_management_endpoints_require_admin(client, login): async def test_version_validation_and_numbering(client):
"""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") flow = await _create_flow(client, "versioned")
v1 = await _create_version(client, flow["id"]) v1 = await _create_version(client, flow["id"])
@ -135,8 +79,7 @@ async def test_version_validation_and_numbering(client, login):
assert [v["version_no"] for v in response.json()] == [1, 2] assert [v["version_no"] for v in response.json()] == [1, 2]
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login): async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
await login()
flow = await _create_flow(client, "dachsanierung") flow = await _create_flow(client, "dachsanierung")
await _create_version(client, flow["id"]) await _create_version(client, flow["id"])
deployed = await _deploy(client, flow["id"]) deployed = await _deploy(client, flow["id"])
@ -167,16 +110,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login)
assert response.json()["id"] == run["id"] assert response.json()["id"] == run["id"]
async def test_run_undeployed_version_fails(client, login): async def test_run_undeployed_version_fails(client):
await login()
flow = await _create_flow(client, "undeployed") flow = await _create_flow(client, "undeployed")
await _create_version(client, flow["id"]) 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 assert response.status_code == 422
async def test_run_explicit_version_no(client, lfx_server, login): async def test_run_explicit_version_no(client, lfx_server):
await login()
flow = await _create_flow(client, "explicit-version") flow = await _create_flow(client, "explicit-version")
await _create_version(client, flow["id"]) await _create_version(client, flow["id"])
await _create_version(client, flow["id"]) await _create_version(client, flow["id"])
@ -189,8 +132,7 @@ async def test_run_explicit_version_no(client, lfx_server, login):
assert response.json()["status"] == "completed" assert response.json()["status"] == "completed"
async def test_run_stream_events(client, lfx_server, login): async def test_run_stream_events(client, lfx_server):
await login()
flow = await _create_flow(client, "streamed") flow = await _create_flow(client, "streamed")
await _create_version(client, flow["id"]) await _create_version(client, flow["id"])
await _deploy(client, flow["id"]) await _deploy(client, flow["id"])
@ -223,18 +165,17 @@ async def test_run_stream_events(client, lfx_server, login):
assert response.json()["status"] == "completed" assert response.json()["status"] == "completed"
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch): async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe") flow = await _create_flow(client, "freigabe")
await _create_version(client, flow["id"], fixture="approval_handoff.json") await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"]) await _deploy(client, flow["id"])
response = await client.post( response = await client.post(
f"/api/flows/{flow['id']}/run", f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 250000, "schwelle": 200000}}, json={
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
},
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
run = response.json() run = response.json()
@ -251,20 +192,17 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server, logi
assert task["payload"]["facts"]["betrag"] == 250000 assert task["payload"]["facts"]["betrag"] == 250000
async def test_approval_handoff_auto_creates_no_task( async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
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") flow = await _create_flow(client, "freigabe-auto")
await _create_version(client, flow["id"], fixture="approval_handoff.json") await _create_version(client, flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"]) await _deploy(client, flow["id"])
response = await client.post( response = await client.post(
f"/api/flows/{flow['id']}/run", f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 50000, "schwelle": 200000}}, json={
"input": {"betrag": 50000, "schwelle": 200000},
"callback_base_url": live_server,
},
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert response.json()["status"] == "completed" assert response.json()["status"] == "completed"
@ -274,12 +212,11 @@ async def test_approval_handoff_auto_creates_no_task(
assert response.json() == [] assert response.json() == []
async def test_approval_callback_contract(client, session, login): async def test_approval_callback_contract(client, session):
from uuid import UUID from uuid import UUID
from app.flows import store as flow_store from app.flows import store as flow_store
await login()
flow = await _create_flow(client, "callback-target") flow = await _create_flow(client, "callback-target")
version = await _create_version(client, flow["id"]) version = await _create_version(client, flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"])) version_obj = flow_store.get_version(session, UUID(version["id"]))
@ -289,7 +226,6 @@ async def test_approval_callback_contract(client, session, login):
response = await client.post( response = await client.post(
"/api/approvals", "/api/approvals",
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}}, json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
headers=CALLBACK_HEADERS,
) )
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
task = response.json() task = response.json()
@ -297,46 +233,18 @@ async def test_approval_callback_contract(client, session, login):
assert task["tenant_id"] == "default" assert task["tenant_id"] == "default"
assert task["version_id"] == version["id"] assert task["version_id"] == version["id"]
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS) response = await client.get(f"/api/flows/runs/{run.id}")
assert response.json()["status"] == "pending_approval" assert response.json()["status"] == "pending_approval"
response = await client.post( response = await client.post("/api/approvals", json={"run_id": str(run.id)})
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
)
assert response.status_code == 409 assert response.status_code == 409
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default") run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
response = await client.post( response = await client.post(
"/api/approvals", "/api/approvals",
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())}, json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
headers=CALLBACK_HEADERS,
) )
assert response.status_code == 422 assert response.status_code == 422
response = await client.post( response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
)
assert response.status_code == 404 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

@ -1,105 +0,0 @@
"""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

@ -1,194 +0,0 @@
"""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,7 +20,9 @@ def _client():
@respx.mock @respx.mock
async def test_export_flow_sends_api_key_and_returns_doc(): async def test_export_flow_sends_api_key_and_returns_doc():
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}} 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") result = await _client().export_flow("abc")
assert result == doc assert result == doc
assert route.called assert route.called

View file

@ -1,33 +0,0 @@
"""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

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

View file

@ -12,17 +12,10 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
env_file: env_file:
# Optional: fresh clones and CI checkouts have no `.env` (gitignored); - .env
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
volumes: 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 - .:/srv/kirche-einfach
- kirche-venv:/srv/kirche-einfach/backend/.venv
ports: ports:
- "8000:8000" # FastAPI - "8000:8000" # FastAPI
depends_on: depends_on:
@ -47,20 +40,13 @@ services:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
env_file: env_file:
# Optional: fresh clones and CI checkouts have no `.env` (gitignored); - .env
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
environment: environment:
# Fail closed: unset means the production preview server. Dev sets # Dev default (HMR); prod .env sets `preview` (build + serve bundle).
# FRONTEND_MODE=dev in .env (provisioned by .devcontainer/setup.sh). FRONTEND_MODE: ${FRONTEND_MODE:-dev}
FRONTEND_MODE: ${FRONTEND_MODE:-preview}
volumes: 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 - .:/srv/kirche-einfach
- kirche-node-modules:/srv/kirche-einfach/frontend/node_modules
ports: ports:
- "3000:3000" # Vite - "3000:3000" # Vite
depends_on: depends_on:
@ -69,16 +55,9 @@ services:
restart: unless-stopped restart: unless-stopped
langflow: langflow:
# Pinned (never :latest): Langflow and LFX are versioned together, their image: langflowai/langflow:latest
# 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_file:
# Optional: fresh clones and CI checkouts have no `.env` (gitignored); - .env
# containers must still create so `config`/`build` work everywhere.
# Runtime secrets come from `.env` when present (see setup.sh).
- path: .env
required: false
environment: environment:
# Static values only; secrets/URLs come from .env via env_file. # Static values only; secrets/URLs come from .env via env_file.
LANGFLOW_CONFIG_DIR: /app/langflow LANGFLOW_CONFIG_DIR: /app/langflow
@ -89,17 +68,12 @@ services:
# SQLite file inside the langflow-data volume (absolute path required # SQLite file inside the langflow-data volume (absolute path required
# by Langflow); overrides any postgres URL that may linger in .env. # by Langflow); overrides any postgres URL that may linger in .env.
LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db 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: volumes:
- langflow-data:/app/langflow - langflow-data:/app/langflow
- ./backend/components:/app/components:ro
- ./backend/flows:/app/flows:ro
ports: ports:
- "7860:7860" - "7860:7860"
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@ -117,17 +91,7 @@ services:
context: . context: .
dockerfile: Dockerfile.lfx dockerfile: Dockerfile.lfx
env_file: env_file:
# Optional: fresh clones and CI checkouts have no `.env` (gitignored); - .env
# 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: volumes:
- lfx-data:/flows - lfx-data:/flows
ports: ports:
@ -146,8 +110,6 @@ services:
# Nightly auto-updates of :latest images (prod). Never started in dev # Nightly auto-updates of :latest images (prod). Never started in dev
# (not in devcontainer.json runServices; also needs a docker socket). # (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: watchtower:
image: nickfedor/watchtower:latest image: nickfedor/watchtower:latest
volumes: volumes:
@ -162,5 +124,7 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
kirche-venv:
kirche-node-modules:
langflow-data: langflow-data:
lfx-data: lfx-data:

View file

@ -1,6 +0,0 @@
# 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,21 +3,11 @@
# file. DevDeps are included: `npm run dev` must work with zero steps. # file. DevDeps are included: `npm run dev` must work with zero steps.
FROM node:22-slim 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 WORKDIR /srv/kirche-einfach/frontend
COPY package.json package-lock.json entrypoint.sh ./ COPY package.json package-lock.json entrypoint.sh ./
RUN npm ci RUN npm ci && chmod +x entrypoint.sh
COPY . ./ COPY . ./
RUN chmod +x entrypoint.sh && chown -R node:node /srv/kirche-einfach/frontend
EXPOSE 3000 EXPOSE 3000
# Explicit `sh`: the repo bind-mount shadows the image at runtime, and git ENTRYPOINT ["./entrypoint.sh"]
# 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,21 +1,14 @@
#!/bin/sh #!/bin/sh
# Frontend entrypoint (dev + prod). # Frontend entrypoint (dev + prod).
# Fail closed: unset means the production preview server; dev sets # FRONTEND_MODE=dev (compose default) runs the HMR dev server.
# FRONTEND_MODE=dev in .env (see .devcontainer/setup.sh). # FRONTEND_MODE=preview (prod .env) builds once and serves the bundle.
set -e set -e
cd /srv/kirche-einfach/frontend cd /srv/kirche-einfach/frontend
# node_modules lives in the repo bind-mount (no named volume by design): if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then
# install on first start after a fresh checkout (dev and prod). exec npm run dev -- --host 0.0.0.0 --port 3000
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 else
# Build as node so dist/ stays editor-writable; preview serves as node. npm run build
gosu node npm run build exec npm run preview -- --host 0.0.0.0 --port 3000
exec gosu node npm run preview -- --host 0.0.0.0 --port 3000
fi fi

View file

@ -3,30 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -8,21 +8,13 @@
"name": "kirche-einfach-frontend", "name": "kirche-einfach-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"react-router-dom": "^7.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"typescript": "~5.6.0", "typescript": "~5.6.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
@ -751,59 +743,6 @@
"node": ">=18" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -854,50 +793,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -915,78 +810,6 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1344,32 +1167,6 @@
"win32" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1422,18 +1219,11 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1470,65 +1260,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.21", "version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
@ -1597,15 +1328,6 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1613,30 +1335,11 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": 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" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -1657,12 +1360,6 @@
} }
} }
}, },
"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": { "node_modules/electron-to-chromium": {
"version": "1.5.420", "version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
@ -1722,39 +1419,6 @@
"node": ">=6" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -1798,15 +1462,6 @@
"node": ">=6.9.0" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -1827,12 +1482,6 @@
"node": ">=6" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -1846,16 +1495,6 @@
"node": ">=6" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -1866,34 +1505,6 @@
"yallist": "^3.0.2" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -1950,35 +1561,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@ -2029,23 +1611,6 @@
"react": "^19.2.8" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -2056,122 +1621,6 @@
"node": ">=0.10.0" "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": { "node_modules/rollup": {
"version": "4.63.1", "version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@ -2234,12 +1683,6 @@
"semver": "bin/semver.js" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2250,24 +1693,6 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -2285,27 +1710,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@ -2351,49 +1755,6 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",

View file

@ -6,26 +6,16 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview"
"screenshots": "node scripts/capture.mjs",
"icons": "node scripts/icons.mjs"
}, },
"dependencies": { "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": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"react-router-dom": "^7.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"typescript": "~5.6.0", "typescript": "~5.6.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 B

View file

@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 536 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -1,20 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -1,22 +0,0 @@
{
"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

@ -1,66 +0,0 @@
/* 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

@ -1,82 +0,0 @@
"""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

@ -1,44 +0,0 @@
/* 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,161 +1,39 @@
import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { useState } from "react";
import { import AdminTab from "./components/AdminTab";
Anchor, import ApprovalsTab from "./components/ApprovalsTab";
AppShell, import FlowsTab from "./components/FlowsTab";
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";
function Nav() { type Tab = "flows" | "approvals" | "admin";
const { me, logout } = useAuth();
const navigate = useNavigate();
async function onLogout() { const tabStyle = (active: boolean): React.CSSProperties => ({
await logout(); fontWeight: active ? "bold" : "normal",
navigate("/"); textDecoration: active ? "underline" : "none",
} });
return (
<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() { export default function App() {
const [tab, setTab] = useState<Tab>("flows");
return ( return (
<AuthProvider> <div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
<Shell /> <h1>Kirche Einfach</h1>
</AuthProvider> <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>
); );
} }

View file

@ -69,33 +69,6 @@ export interface SyncReport {
errors: Record<string, string>; 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 { export class ApiError extends Error {
status: number; status: number;
constructor(status: number, message: string) { constructor(status: number, message: string) {
@ -106,8 +79,6 @@ export class ApiError extends Error {
async function req<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
// Session cookie (HttpOnly) must travel with every request.
credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
...init, ...init,
}); });
@ -143,21 +114,22 @@ export const api = {
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, { req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
method: "POST", method: "POST",
}), }),
runFlow: (flowId: string, input: unknown) => runFlow: (flowId: string, input: unknown, tenantId: string) =>
req<Run>(`/api/flows/${flowId}/run`, { req<Run>(`/api/flows/${flowId}/run`, {
method: "POST", method: "POST",
// Tenant comes from the session server-side; no tenant box anymore. body: JSON.stringify({ input, tenant_id: tenantId }),
body: JSON.stringify({ input }),
}), }),
getInputSchema: (flowId: string) =>
req<InputSchema>(`/api/flows/${flowId}/input-schema`),
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`), listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
listApprovals: () => req<ApprovalTask[]>("/api/approvals"), listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
decide: (taskId: string, decision: "approve" | "reject", note: string) => decide: (
taskId: string,
decision: "approve" | "reject",
approver: string,
note: string,
) =>
req<Decision>(`/api/approvals/${taskId}/decision`, { req<Decision>(`/api/approvals/${taskId}/decision`, {
method: "POST", method: "POST",
// Identity comes from the session; the server records the user. body: JSON.stringify({ decision, approver, note }),
body: JSON.stringify({ decision, note }),
}), }),
importFlow: (langflowFlowId: string, name?: string) => importFlow: (langflowFlowId: string, name?: string) =>
req<{ definition: FlowDefinition; version: FlowVersion }>( req<{ definition: FlowDefinition; version: FlowVersion }>(
@ -176,38 +148,4 @@ export const api = {
req<SyncReport>("/api/flows/sync", { req<SyncReport>("/api/flows/sync", {
method: "POST", 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" },
),
}; };

View file

@ -1,55 +0,0 @@
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

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

View file

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

View file

@ -0,0 +1,105 @@
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

@ -1,18 +0,0 @@
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

@ -1,21 +0,0 @@
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,22 +1,9 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; 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 App from "./App";
import { colorSchemeManager, theme } from "./theme";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<MantineProvider <App />
theme={theme}
colorSchemeManager={colorSchemeManager}
defaultColorScheme="light"
>
<BrowserRouter>
<App />
</BrowserRouter>
</MantineProvider>
</StrictMode>, </StrictMode>,
); );

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