v0.2 foundation — Authentik OIDC + sulkta-mariadb DB + Fernet crypto

Adds the multi-user plumbing layer underneath v0.1's batch-only API:

- DB module (db.py) — PyMySQL against sulkta-mariadb, in-process migrations.
  Tables: cauldron_users, cauldron_user_mealie_tokens, cauldron_chat_log,
  schema_migrations.
- Crypto module (crypto.py) — thin Fernet wrapper. Master key in env,
  per-row encryption of stored Mealie tokens, decrypt only in-process.
- OIDC module (oidc.py) — Authlib-based Authentik integration. Issuer
  https://auth.sulkta.com/application/o/cauldron/, sub_mode=user_email,
  scopes openid+email+profile. App gated to 'Sulkta Family' group.
- Two-tier Mealie shape — system_mealie (env token, admin batch) +
  current_user_mealie() helper that loads + decrypts the calling user's
  token from DB. Per the v0.2 design (memory/spec-cauldron-v0.2.md).
- Connect flow — /connect-mealie pages walk users through minting their
  own Mealie API token and pasting it back. Validated against
  /api/users/self before encryption + storage.
- Routes — /, /login, /auth/callback, /logout, /me, /connect-mealie,
  /disconnect-mealie. v0.1 admin endpoints kept under bearer auth.
- Mealie.who_am_i() helper added.
- Auth flow uses Authentik subject (sub) as the canonical user key.

UI is minimal — connect-mealie page uses the locked palette
(forest #1f2d1f, panels #2d3a2a, meadow #6b8e5a/#88a87a, parchment text
#f0e6cc/#ddd4ba) and Cormorant Garamond serif headers. Strict palette.
The fuller dashboard / plan / list / recipes views land in subsequent
commits.

Authentik provider PK 24, client_id ZIwEugWWWZinR1KcVC9IT9hpGoTds9ps8XDDHPPN.
Group 'Sulkta Family' (pk 6d0c75e9-...) created with cobb member.

Foundation only — Abby's branded UI and the meal-plan / shopping-list
features land in subsequent v0.2 commits.
This commit is contained in:
Kayos 2026-04-28 19:47:47 -07:00
parent 63cb347222
commit 213801ca70
8 changed files with 508 additions and 23 deletions

View file

@ -19,5 +19,22 @@ CLAWDFORGE_TOKEN=
DEFAULT_MODEL=sonnet DEFAULT_MODEL=sonnet
DEFAULT_TIMEOUT_SECS=120 DEFAULT_TIMEOUT_SECS=120
# Local dev/admin bearer (used until Authentik OIDC lands in v0.2) # Admin bearer for batch ops (sterilize-all, etc.) — separate from user OIDC
ADMIN_BEARER=change-me-this-is-the-cauldron-api-token ADMIN_BEARER=change-me-this-is-the-cauldron-admin-batch-token
# Authentik OIDC (provisioned 2026-04-28; client_id + secret minted by Authentik)
OIDC_ISSUER=https://auth.sulkta.com/application/o/cauldron/
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://192.168.0.5:7790/auth/callback
# DB (sulkta-mariadb on the sulkta bridge)
DB_HOST=sulkta-mariadb
DB_PORT=3306
DB_NAME=cauldron
DB_USER=cauldron_app
DB_PASSWORD=
# Fernet master key for at-rest encryption of per-user Mealie tokens.
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CAULDRON_FERNET_KEY=

View file

@ -10,7 +10,7 @@ class Config:
bind_port: int bind_port: int
mealie_base_url: str mealie_base_url: str
mealie_api_token: str mealie_api_token: str # system token (Cobb's "Cauldron" token, used for admin batch ops)
clawdforge_url: str clawdforge_url: str
clawdforge_token: str clawdforge_token: str
@ -19,6 +19,22 @@ class Config:
admin_bearer: str admin_bearer: str
# OIDC (Authentik)
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: str
oidc_redirect_uri: str
# DB (sulkta-mariadb)
db_host: str
db_port: int
db_name: str
db_user: str
db_password: str
# Per-user token at-rest crypto
fernet_key: str
def load() -> Config: def load() -> Config:
return Config( return Config(
@ -32,4 +48,14 @@ def load() -> Config:
default_model=os.environ.get("DEFAULT_MODEL", "sonnet"), default_model=os.environ.get("DEFAULT_MODEL", "sonnet"),
default_timeout_secs=int(os.environ.get("DEFAULT_TIMEOUT_SECS", "120")), default_timeout_secs=int(os.environ.get("DEFAULT_TIMEOUT_SECS", "120")),
admin_bearer=os.environ["ADMIN_BEARER"], admin_bearer=os.environ["ADMIN_BEARER"],
oidc_issuer=os.environ["OIDC_ISSUER"].rstrip("/") + "/",
oidc_client_id=os.environ["OIDC_CLIENT_ID"],
oidc_client_secret=os.environ["OIDC_CLIENT_SECRET"],
oidc_redirect_uri=os.environ["OIDC_REDIRECT_URI"],
db_host=os.environ["DB_HOST"],
db_port=int(os.environ.get("DB_PORT", "3306")),
db_name=os.environ["DB_NAME"],
db_user=os.environ["DB_USER"],
db_password=os.environ["DB_PASSWORD"],
fernet_key=os.environ["CAULDRON_FERNET_KEY"],
) )

23
cauldron/crypto.py Normal file
View file

@ -0,0 +1,23 @@
"""Fernet wrapper for at-rest encryption of per-user secrets (Mealie tokens).
The Fernet master key lives in env (CAULDRON_FERNET_KEY) and never on disk
in the cauldron container itself it's bind-mounted in via the env_file.
Rotating it requires re-encrypting every cauldron_user_mealie_tokens row;
not implemented yet (would need a rotation flow with old + new key).
"""
from cryptography.fernet import Fernet, InvalidToken
class TokenCrypto:
def __init__(self, master_key: str):
# Fernet expects bytes; ours is a 44-char urlsafe-b64 string in env
self.f = Fernet(master_key.encode() if isinstance(master_key, str) else master_key)
def encrypt(self, plaintext: str) -> bytes:
return self.f.encrypt(plaintext.encode("utf-8"))
def decrypt(self, blob: bytes) -> str:
try:
return self.f.decrypt(blob).decode("utf-8")
except InvalidToken as e:
raise RuntimeError("fernet decrypt failed (bad key or corrupted blob)") from e

200
cauldron/db.py Normal file
View file

@ -0,0 +1,200 @@
"""DB access + migrations against sulkta-mariadb.
Uses PyMySQL with a tiny per-request connection (no pool) Cauldron is
LAN-only family-internal, traffic is single-digit qps. If load ever grows
swap in DBUtils.PooledDB or SQLAlchemy.
"""
from contextlib import contextmanager
from pathlib import Path
import pymysql
import pymysql.cursors
MIGRATIONS = [
# 001 — bookkeeping
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(16) PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 002 — users (Authentik subject is the PK)
"""
CREATE TABLE IF NOT EXISTS cauldron_users (
authentik_sub VARCHAR(190) PRIMARY KEY,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME,
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 003 — per-user encrypted Mealie tokens
"""
CREATE TABLE IF NOT EXISTS cauldron_user_mealie_tokens (
authentik_sub VARCHAR(190) PRIMARY KEY,
encrypted_token BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_validated DATETIME,
last_failure_at DATETIME,
last_failure_reason VARCHAR(500),
FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
# 004 — chat / AI run log (joins to clawdforge runs server-side)
"""
CREATE TABLE IF NOT EXISTS cauldron_chat_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
authentik_sub VARCHAR(190) NOT NULL,
ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
intent VARCHAR(64),
forge_duration_ms INT,
forge_model VARCHAR(64),
prompt_chars INT,
result_chars INT,
ok BOOLEAN NOT NULL DEFAULT TRUE,
error VARCHAR(500),
INDEX idx_user_ts (authentik_sub, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
class DB:
def __init__(self, *, host: str, port: int, name: str, user: str, password: str):
self.kwargs = dict(
host=host,
port=port,
user=user,
password=password,
database=name,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
@contextmanager
def conn(self):
c = pymysql.connect(**self.kwargs)
try:
yield c
c.commit()
except Exception:
c.rollback()
raise
finally:
c.close()
def migrate(self) -> list[str]:
"""Apply pending migrations. Returns list of versions applied."""
applied: list[str] = []
with self.conn() as c:
with c.cursor() as cur:
cur.execute(MIGRATIONS[0]) # bootstrap migrations table
cur.execute("SELECT version FROM schema_migrations")
done = {r["version"] for r in cur.fetchall()}
for i, sql in enumerate(MIGRATIONS, start=1):
ver = f"{i:03d}"
if ver in done:
continue
cur.execute(sql)
cur.execute(
"INSERT INTO schema_migrations (version) VALUES (%s)", (ver,)
)
applied.append(ver)
return applied
# --- user ops -----------------------------------------------------------
def upsert_user(self, *, sub: str, email: str, display_name: str | None) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_users (authentik_sub, email, display_name, last_seen)
VALUES (%s, %s, %s, NOW())
ON DUPLICATE KEY UPDATE
email = VALUES(email),
display_name = COALESCE(VALUES(display_name), display_name),
last_seen = NOW()
""",
(sub, email, display_name),
)
def get_user(self, sub: str) -> dict | None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT authentik_sub, email, display_name, last_seen FROM cauldron_users WHERE authentik_sub=%s",
(sub,),
)
return cur.fetchone()
# --- mealie token ops ---------------------------------------------------
def get_user_mealie_token_blob(self, sub: str) -> bytes | None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"SELECT encrypted_token FROM cauldron_user_mealie_tokens WHERE authentik_sub=%s",
(sub,),
)
row = cur.fetchone()
return row["encrypted_token"] if row else None
def set_user_mealie_token_blob(self, sub: str, blob: bytes) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_user_mealie_tokens (authentik_sub, encrypted_token, last_validated)
VALUES (%s, %s, NOW())
ON DUPLICATE KEY UPDATE
encrypted_token = VALUES(encrypted_token),
last_validated = NOW(),
last_failure_at = NULL,
last_failure_reason = NULL
""",
(sub, blob),
)
def delete_user_mealie_token(self, sub: str) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"DELETE FROM cauldron_user_mealie_tokens WHERE authentik_sub=%s",
(sub,),
)
def mark_user_mealie_token_failure(self, sub: str, reason: str) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
UPDATE cauldron_user_mealie_tokens
SET last_failure_at = NOW(), last_failure_reason = %s
WHERE authentik_sub = %s
""",
(reason[:500], sub),
)
# --- chat log -----------------------------------------------------------
def log_chat(
self,
*,
sub: str,
intent: str,
duration_ms: int,
model: str,
prompt_chars: int,
result_chars: int,
ok: bool,
error: str | None = None,
) -> None:
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
INSERT INTO cauldron_chat_log
(authentik_sub, intent, forge_duration_ms, forge_model,
prompt_chars, result_chars, ok, error)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(sub, intent, duration_ms, model, prompt_chars, result_chars, ok, (error or "")[:500] or None),
)

View file

@ -52,6 +52,13 @@ class Mealie:
raise MealieError(f"POST {path} -> {r.status_code}: {r.text[:300]}") raise MealieError(f"POST {path} -> {r.status_code}: {r.text[:300]}")
return r.json() return r.json()
# --- auth / self --------------------------------------------------------
def who_am_i(self) -> dict:
"""GET /api/users/self — returns the authenticated user's profile.
Used by cauldron to validate user-supplied tokens before storing."""
return self._get("/api/users/self")
# --- recipes ------------------------------------------------------------ # --- recipes ------------------------------------------------------------
def list_recipes(self, *, page: int = 1, per_page: int = 50) -> dict: def list_recipes(self, *, page: int = 1, per_page: int = 50) -> dict:

23
cauldron/oidc.py Normal file
View file

@ -0,0 +1,23 @@
"""Authentik OIDC integration via Authlib.
Cauldron is gated to the Authentik 'Cauldron' application, which is bound
to the 'Sulkta Family' group. Authentik enforces the group membership; we
just trust the userinfo response.
We use 'sub_mode=user_email' on the Authentik provider, so the OIDC `sub`
claim is the user's email — stable, human-readable, and matches our
existing Sulkta SSO pattern.
"""
from authlib.integrations.flask_client import OAuth
def init_oauth(app, *, issuer: str, client_id: str, client_secret: str) -> OAuth:
oauth = OAuth(app)
oauth.register(
name="cauldron",
server_metadata_url=f"{issuer.rstrip('/')}/.well-known/openid-configuration",
client_id=client_id,
client_secret=client_secret,
client_kwargs={"scope": "openid email profile"},
)
return oauth

View file

@ -1,50 +1,111 @@
"""Flask app — v0.1 surface. """Flask app — v0.2 foundation.
Auth is a simple shared bearer in env (ADMIN_BEARER) until Authentik OIDC Adds Authentik OIDC + sulkta-mariadb DB + Fernet crypto for per-user Mealie
lands in v0.2. LAN-only deploy means the bearer is the only gate. tokens. v0.1 admin endpoints stay (still bearer-gated for now); user-facing
routes start using OIDC sessions.
Routes: Routes (current):
GET /healthz liveness, no auth GET /healthz liveness, no auth
GET /api/recipes proxy Mealie list (paginated) GET / redirects to /login if no session,
POST /api/sterilize/preview/<slug> dry-run a recipe through Sonnet else /me
POST /api/sterilize/apply/<slug> write the parses back to Mealie GET /login start OIDC flow
GET /auth/callback OIDC callback
POST /logout clear session
GET /me "who am I" page (json for now)
GET /connect-mealie prompt for Mealie token
POST /connect-mealie store encrypted token
POST /disconnect-mealie delete encrypted token
GET /api/recipes (admin bearer) proxy Mealie list
POST /api/sterilize/preview/<slug> (admin bearer) v0.1 sterilizer
POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
""" """
from functools import wraps from functools import wraps
from flask import Flask, jsonify, request from flask import Flask, jsonify, redirect, render_template_string, request, session, url_for
from .config import load from .config import load
from .crypto import TokenCrypto
from .db import DB
from .forge import Forge from .forge import Forge
from .mealie import Mealie from .mealie import Mealie, MealieError
from .oidc import init_oauth
from .sterilizer import Sterilizer from .sterilizer import Sterilizer
cfg = load() cfg = load()
db = DB(host=cfg.db_host, port=cfg.db_port, name=cfg.db_name, user=cfg.db_user, password=cfg.db_password)
crypto = TokenCrypto(cfg.fernet_key)
forge = Forge( forge = Forge(
base_url=cfg.clawdforge_url, base_url=cfg.clawdforge_url,
token=cfg.clawdforge_token, token=cfg.clawdforge_token,
default_model=cfg.default_model, default_model=cfg.default_model,
default_timeout=cfg.default_timeout_secs, default_timeout=cfg.default_timeout_secs,
) )
mealie = Mealie(base_url=cfg.mealie_base_url, api_token=cfg.mealie_api_token) # System-tier Mealie client (Cobb's "Cauldron" token; admin batch ops only)
sterilizer = Sterilizer(mealie=mealie, forge=forge, model=cfg.default_model) system_mealie = Mealie(base_url=cfg.mealie_base_url, api_token=cfg.mealie_api_token)
system_sterilizer = Sterilizer(mealie=system_mealie, forge=forge, model=cfg.default_model)
def create_app() -> Flask: def create_app() -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.secret_key = cfg.secret_key app.secret_key = cfg.secret_key
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
# NOT setting SESSION_COOKIE_SECURE=True — LAN is plain HTTP for now.
# If we ever front this with TLS, flip secure=True.
)
# Apply migrations on startup
applied = db.migrate()
if applied:
app.logger.info("applied migrations: %s", applied)
oauth = init_oauth(
app,
issuer=cfg.oidc_issuer,
client_id=cfg.oidc_client_id,
client_secret=cfg.oidc_client_secret,
)
# ---------- helpers --------------------------------------------------
def require_bearer(fn): def require_bearer(fn):
@wraps(fn) @wraps(fn)
def wrapper(*args, **kwargs): def w(*a, **kw):
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "): if not auth.startswith("Bearer "):
return jsonify({"error": "missing bearer"}), 401 return jsonify({"error": "missing bearer"}), 401
token = auth[7:].strip() tok = auth[7:].strip()
if not _const_eq(token, cfg.admin_bearer): if not _const_eq(tok, cfg.admin_bearer):
return jsonify({"error": "forbidden"}), 403 return jsonify({"error": "forbidden"}), 403
return fn(*args, **kwargs) return fn(*a, **kw)
return wrapper return w
def require_session(fn):
@wraps(fn)
def w(*a, **kw):
if not session.get("user"):
return redirect(url_for("login", next=request.path))
return fn(*a, **kw)
return w
def current_user_mealie() -> Mealie | None:
u = session.get("user")
if not u:
return None
blob = db.get_user_mealie_token_blob(u["sub"])
if not blob:
return None
try:
tok = crypto.decrypt(blob)
except Exception:
return None
return Mealie(base_url=cfg.mealie_base_url, api_token=tok)
# ---------- public ---------------------------------------------------
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
@ -53,20 +114,111 @@ def create_app() -> Flask:
upstream["clawdforge"] = forge.healthz() upstream["clawdforge"] = forge.healthz()
except Exception as e: except Exception as e:
upstream["clawdforge_error"] = str(e) upstream["clawdforge_error"] = str(e)
try:
with db.conn() as c, c.cursor() as cur:
cur.execute("SELECT 1 AS ok")
cur.fetchone()
upstream["db"] = "ok"
except Exception as e:
upstream["db_error"] = str(e)
return jsonify({"ok": True, "upstream": upstream}) return jsonify({"ok": True, "upstream": upstream})
@app.get("/")
def index():
if not session.get("user"):
return redirect(url_for("login"))
return redirect(url_for("me"))
@app.get("/login")
def login():
# Stash where to go after login
nxt = request.args.get("next") or "/me"
session["post_login_next"] = nxt
return oauth.cauldron.authorize_redirect(cfg.oidc_redirect_uri)
@app.get("/auth/callback")
def auth_callback():
token = oauth.cauldron.authorize_access_token()
userinfo = token.get("userinfo") or oauth.cauldron.userinfo(token=token)
sub = userinfo.get("sub") or userinfo.get("email")
email = userinfo.get("email") or sub
name = userinfo.get("name") or userinfo.get("preferred_username")
if not sub or not email:
return ("missing sub/email in userinfo", 400)
db.upsert_user(sub=sub, email=email, display_name=name)
session["user"] = {"sub": sub, "email": email, "name": name}
return redirect(session.pop("post_login_next", "/me"))
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.get("/me")
@require_session
def me():
u = session["user"]
connected = db.get_user_mealie_token_blob(u["sub"]) is not None
return jsonify(
{
"user": u,
"mealie_connected": connected,
}
)
# ---------- mealie connect flow --------------------------------------
@app.get("/connect-mealie")
@require_session
def connect_mealie_get():
u = session["user"]
return render_template_string(
CONNECT_TEMPLATE,
user=u,
mealie_url=cfg.mealie_base_url,
)
@app.post("/connect-mealie")
@require_session
def connect_mealie_post():
u = session["user"]
token = (request.form.get("mealie_token") or "").strip()
if not token:
return ("empty token", 400)
# Validate against Mealie before storing — don't persist a bad token
test = Mealie(base_url=cfg.mealie_base_url, api_token=token)
try:
test.who_am_i()
except MealieError as e:
return (f"token rejected by Mealie: {e}", 400)
blob = crypto.encrypt(token)
db.set_user_mealie_token_blob(u["sub"], blob)
return redirect(url_for("me"))
@app.post("/disconnect-mealie")
@require_session
def disconnect_mealie():
u = session["user"]
db.delete_user_mealie_token(u["sub"])
return redirect(url_for("me"))
# ---------- v0.1 admin endpoints (carry over) ------------------------
@app.get("/api/recipes") @app.get("/api/recipes")
@require_bearer @require_bearer
def list_recipes(): def list_recipes():
page = int(request.args.get("page", "1")) page = int(request.args.get("page", "1"))
per_page = min(int(request.args.get("per_page", "50")), 200) per_page = min(int(request.args.get("per_page", "50")), 200)
return jsonify(mealie.list_recipes(page=page, per_page=per_page)) return jsonify(system_mealie.list_recipes(page=page, per_page=per_page))
@app.post("/api/sterilize/preview/<slug>") @app.post("/api/sterilize/preview/<slug>")
@require_bearer @require_bearer
def sterilize_preview(slug: str): def sterilize_preview(slug: str):
try: try:
return jsonify(sterilizer.preview_recipe(slug)) return jsonify(system_sterilizer.preview_recipe(slug))
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 502 return jsonify({"error": str(e)}), 502
@ -75,13 +227,47 @@ def create_app() -> Flask:
def sterilize_apply(slug: str): def sterilize_apply(slug: str):
create_missing = request.args.get("create_missing", "true").lower() == "true" create_missing = request.args.get("create_missing", "true").lower() == "true"
try: try:
return jsonify(sterilizer.apply_recipe(slug, create_missing=create_missing)) return jsonify(system_sterilizer.apply_recipe(slug, create_missing=create_missing))
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 502 return jsonify({"error": str(e)}), 502
return app return app
CONNECT_TEMPLATE = """<!doctype html>
<html><head><meta charset="utf-8"><title>Connect Mealie Cauldron</title>
<style>
body { font-family: system-ui, sans-serif; background: #1f2d1f; color: #f0e6cc; max-width: 640px; margin: 4em auto; padding: 0 1em; line-height: 1.6; }
h1 { color: #88a87a; font-family: 'Cormorant Garamond', Georgia, serif; font-weight: 500; font-size: 2.4em; margin-bottom: 0.2em; }
.lede { color: #ddd4ba; }
a { color: #88a87a; }
input[type=text] { width: 100%; padding: 0.6em; background: #2d3a2a; border: 1px solid #4d5d3a; color: #f0e6cc; font-family: monospace; font-size: 0.9em; box-sizing: border-box; }
button { padding: 0.6em 1.4em; background: #6b8e5a; color: #1f2d1f; border: none; font-weight: 600; cursor: pointer; }
button:hover { background: #88a87a; }
ol li { margin: 0.4em 0; }
code { background: #2d3a2a; padding: 0.1em 0.4em; border-radius: 2px; }
</style>
</head><body>
<h1>Connect your Mealie</h1>
<p class="lede">Hi {{ user.name or user.email }}. Cauldron acts on your Mealie account using your own API token. One-time setup, ~30 seconds.</p>
<ol>
<li>Open <a href="{{ mealie_url }}/group/api-tokens" target="_blank">Mealie API Tokens</a></li>
<li>Click <strong>New API Token</strong>, name it <code>cauldron</code>, integration <code>generic</code></li>
<li>Copy the long token string</li>
<li>Paste below and click Connect</li>
</ol>
<form method="post" action="/connect-mealie">
<p><input type="text" name="mealie_token" placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." required></p>
<p><button type="submit">Connect</button></p>
</form>
<p style="color: #6b8e5a; font-size: 0.9em; margin-top: 2em;">Stored encrypted at rest. Revoke anytime in Mealie's UI; cauldron will detect and re-prompt.</p>
</body></html>
"""
def _const_eq(a: str, b: str) -> bool: def _const_eq(a: str, b: str) -> bool:
if len(a) != len(b): if len(a) != len(b):
return False return False

View file

@ -1,3 +1,6 @@
Flask==3.0.3 Flask==3.0.3
requests==2.32.3 requests==2.32.3
gunicorn==23.0.0 gunicorn==23.0.0
Authlib==1.3.2
PyMySQL==1.1.1
cryptography==43.0.3