Merge origin/main into feat/ynab-mirror

Resolve the Makefile .PHONY conflict (desktop + ynab targets) and
regenerate the YNAB migration with aerich as 17 on top of main's
16_add_oidc_states, replacing the colliding 16_add_ynab_mirror.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:06:47 -04:00
39 changed files with 6854 additions and 359 deletions
+12 -1
View File
@@ -1,4 +1,4 @@
.PHONY: deploy redeploy build up down restart logs migrate migrate-new migrate-history frontend test test-cov reindex reindex-obsidian ynab-sync ynab-resync .PHONY: deploy redeploy build up down restart logs migrate migrate-new migrate-history frontend desktop desktop-dev test test-cov reindex reindex-obsidian ynab-sync ynab-resync
# Blue-green: detect the running color (default blue when nothing is running) # Blue-green: detect the running color (default blue when nothing is running)
ACTIVE_COLOR := $(shell docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 | cut -d- -f2) ACTIVE_COLOR := $(shell docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 | cut -d- -f2)
@@ -77,3 +77,14 @@ test-cov:
# Frontend # Frontend
frontend: frontend:
cd raggr-frontend && yarn install && yarn build cd raggr-frontend && yarn install && yarn build
# Desktop app (Tauri). Point it at the server with SIMBA_URL, e.g.
# make desktop-dev SIMBA_URL=https://simba.example.com
# SIMBA_URL is read at runtime and baked in at build time as the default.
desktop-dev:
cd desktop && npm install && SIMBA_URL=$(SIMBA_URL) npx tauri dev
desktop:
cd desktop && npm install && SIMBA_URL=$(SIMBA_URL) npx tauri build --bundles app
rm -rf /Applications/Simba.app
cp -R desktop/src-tauri/target/release/bundle/macos/Simba.app /Applications/
+3 -2
View File
@@ -13,7 +13,7 @@ from quart import (
request, request,
send_from_directory, send_from_directory,
) )
from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required from quart_jwt_extended import JWTManager, get_jwt_identity
from tortoise import Tortoise from tortoise import Tortoise
import blueprints.conversation import blueprints.conversation
@@ -27,6 +27,7 @@ import blueprints.scheduled_messages
import blueprints.cron import blueprints.cron
import blueprints.beli import blueprints.beli
import blueprints.users.models import blueprints.users.models
from blueprints.users.decorators import jwt_access_required
from config.db import TORTOISE_CONFIG from config.db import TORTOISE_CONFIG
from metrics import render_metrics from metrics import render_metrics
from blueprints.conversation.agents import build_agent from blueprints.conversation.agents import build_agent
@@ -211,7 +212,7 @@ async def health():
@app.route("/api/messages", methods=["GET"]) @app.route("/api/messages", methods=["GET"])
@jwt_refresh_token_required @jwt_access_required
async def get_messages(): async def get_messages():
current_user_uuid = get_jwt_identity() current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid) user = await blueprints.users.models.User.get(id=current_user_uuid)
+9 -7
View File
@@ -3,7 +3,9 @@ import logging
import os import os
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from quart_jwt_extended import get_jwt_identity, jwt_refresh_token_required from quart_jwt_extended import get_jwt_identity
from blueprints.users.decorators import jwt_access_required
from .logic import ( from .logic import (
MAX_PROFILES_PER_USER, MAX_PROFILES_PER_USER,
@@ -60,7 +62,7 @@ def _serialize_restaurant(r: BeliRestaurant) -> dict:
@beli_blueprint.route("/profiles", methods=["GET"]) @beli_blueprint.route("/profiles", methods=["GET"])
@jwt_refresh_token_required @jwt_access_required
async def list_profiles(): async def list_profiles():
user_id = get_jwt_identity() user_id = get_jwt_identity()
profiles = await BeliProfile.filter(user_id=user_id).order_by("created_at").all() profiles = await BeliProfile.filter(user_id=user_id).order_by("created_at").all()
@@ -68,7 +70,7 @@ async def list_profiles():
@beli_blueprint.route("/profiles", methods=["POST"]) @beli_blueprint.route("/profiles", methods=["POST"])
@jwt_refresh_token_required @jwt_access_required
async def create_profile(): async def create_profile():
user_id = get_jwt_identity() user_id = get_jwt_identity()
@@ -112,7 +114,7 @@ async def create_profile():
@beli_blueprint.route("/profiles/<profile_id>", methods=["DELETE"]) @beli_blueprint.route("/profiles/<profile_id>", methods=["DELETE"])
@jwt_refresh_token_required @jwt_access_required
async def delete_profile(profile_id: str): async def delete_profile(profile_id: str):
user_id = get_jwt_identity() user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id) profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
@@ -125,7 +127,7 @@ async def delete_profile(profile_id: str):
@beli_blueprint.route("/profiles/<profile_id>/refresh", methods=["POST"]) @beli_blueprint.route("/profiles/<profile_id>/refresh", methods=["POST"])
@jwt_refresh_token_required @jwt_access_required
async def refresh_profile_endpoint(profile_id: str): async def refresh_profile_endpoint(profile_id: str):
user_id = get_jwt_identity() user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id) profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
@@ -137,7 +139,7 @@ async def refresh_profile_endpoint(profile_id: str):
@beli_blueprint.route("/restaurants", methods=["GET"]) @beli_blueprint.route("/restaurants", methods=["GET"])
@jwt_refresh_token_required @jwt_access_required
async def list_restaurants(): async def list_restaurants():
user_id = get_jwt_identity() user_id = get_jwt_identity()
profile_ids = [ profile_ids = [
@@ -161,7 +163,7 @@ async def list_restaurants():
@beli_blueprint.route("/profiles/<profile_id>/refresh-friends", methods=["POST"]) @beli_blueprint.route("/profiles/<profile_id>/refresh-friends", methods=["POST"])
@jwt_refresh_token_required @jwt_access_required
async def refresh_friends_endpoint(profile_id: str): async def refresh_friends_endpoint(profile_id: str):
user_id = get_jwt_identity() user_id = get_jwt_identity()
profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id) profile = await BeliProfile.get_or_none(id=profile_id, user_id=user_id)
+11 -13
View File
@@ -7,12 +7,10 @@ import time
import uuid import uuid
from quart import Blueprint, jsonify, make_response, request from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import ( from quart_jwt_extended import get_jwt_identity
get_jwt_identity,
jwt_refresh_token_required,
)
import blueprints.users.models import blueprints.users.models
from blueprints.users.decorators import jwt_access_required
from metrics import count_result, observe_response, time_response from metrics import count_result, observe_response, time_response
from utils.image_process import image_content_block from utils.image_process import image_content_block
from utils.strip_markdown import extract_agent_text from utils.strip_markdown import extract_agent_text
@@ -126,7 +124,7 @@ def _build_user_message(
@conversation_blueprint.post("/query") @conversation_blueprint.post("/query")
@jwt_refresh_token_required @jwt_access_required
async def query(): async def query():
current_user_uuid = get_jwt_identity() current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid) user = await blueprints.users.models.User.get(id=current_user_uuid)
@@ -170,7 +168,7 @@ async def query():
@conversation_blueprint.post("/upload-image") @conversation_blueprint.post("/upload-image")
@jwt_refresh_token_required @jwt_access_required
async def upload_image(): async def upload_image():
current_user_uuid = get_jwt_identity() current_user_uuid = get_jwt_identity()
await blueprints.users.models.User.get(id=current_user_uuid) await blueprints.users.models.User.get(id=current_user_uuid)
@@ -202,14 +200,14 @@ async def upload_image():
@conversation_blueprint.get("/image/<path:image_key>") @conversation_blueprint.get("/image/<path:image_key>")
@jwt_refresh_token_required @jwt_access_required
async def serve_image(image_key: str): async def serve_image(image_key: str):
url = await s3_presigned_url(image_key) url = await s3_presigned_url(image_key)
return jsonify({"url": url}) return jsonify({"url": url})
@conversation_blueprint.post("/stream-query") @conversation_blueprint.post("/stream-query")
@jwt_refresh_token_required @jwt_access_required
async def stream_query(): async def stream_query():
current_user_uuid = get_jwt_identity() current_user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=current_user_uuid) user = await blueprints.users.models.User.get(id=current_user_uuid)
@@ -448,7 +446,7 @@ async def stream_query():
@conversation_blueprint.route("/<conversation_id>") @conversation_blueprint.route("/<conversation_id>")
@jwt_refresh_token_required @jwt_access_required
async def get_conversation(conversation_id: str): async def get_conversation(conversation_id: str):
user_uuid = get_jwt_identity() user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid) conversation = await _get_owned_conversation(conversation_id, user_uuid)
@@ -483,7 +481,7 @@ async def get_conversation(conversation_id: str):
@conversation_blueprint.post("/") @conversation_blueprint.post("/")
@jwt_refresh_token_required @jwt_access_required
async def create_conversation(): async def create_conversation():
user_uuid = get_jwt_identity() user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid) user = await blueprints.users.models.User.get(id=user_uuid)
@@ -497,7 +495,7 @@ async def create_conversation():
@conversation_blueprint.get("/") @conversation_blueprint.get("/")
@jwt_refresh_token_required @jwt_access_required
async def get_all_conversations(): async def get_all_conversations():
user_uuid = get_jwt_identity() user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid) user = await blueprints.users.models.User.get(id=user_uuid)
@@ -523,7 +521,7 @@ async def get_all_conversations():
@conversation_blueprint.patch("/<conversation_id>") @conversation_blueprint.patch("/<conversation_id>")
@jwt_refresh_token_required @jwt_access_required
async def rename_conversation(conversation_id: str): async def rename_conversation(conversation_id: str):
user_uuid = get_jwt_identity() user_uuid = get_jwt_identity()
body = await request.get_json() body = await request.get_json()
@@ -543,7 +541,7 @@ async def rename_conversation(conversation_id: str):
@conversation_blueprint.delete("/<conversation_id>") @conversation_blueprint.delete("/<conversation_id>")
@jwt_refresh_token_required @jwt_access_required
async def delete_conversation(conversation_id: str): async def delete_conversation(conversation_id: str):
user_uuid = get_jwt_identity() user_uuid = get_jwt_identity()
conversation = await _get_owned_conversation(conversation_id, user_uuid) conversation = await _get_owned_conversation(conversation_id, user_uuid)
+2 -3
View File
@@ -1,5 +1,4 @@
from quart import Blueprint, jsonify from quart import Blueprint, jsonify
from quart_jwt_extended import jwt_refresh_token_required
from .logic import ( from .logic import (
delete_all_documents, delete_all_documents,
@@ -7,13 +6,13 @@ from .logic import (
index_documents, index_documents,
sync_obsidian_documents, sync_obsidian_documents,
) )
from blueprints.users.decorators import admin_required from blueprints.users.decorators import admin_required, jwt_access_required
rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag") rag_blueprint = Blueprint("rag_api", __name__, url_prefix="/api/rag")
@rag_blueprint.get("/stats") @rag_blueprint.get("/stats")
@jwt_refresh_token_required @jwt_access_required
async def get_stats(): async def get_stats():
"""Get vector store statistics.""" """Get vector store statistics."""
stats = get_vector_store_stats() stats = get_vector_store_stats()
+7 -15
View File
@@ -7,7 +7,8 @@ from quart_jwt_extended import (
) )
from .models import User from .models import User
from .oidc_service import OIDCUserService from .oidc_service import OIDCUserService
from .decorators import admin_required from .oidc_state import consume_oidc_state, save_oidc_state
from .decorators import admin_required, jwt_access_required
from config.oidc_config import oidc_config from config.oidc_config import oidc_config
import os import os
import secrets import secrets
@@ -19,10 +20,6 @@ import base64
user_blueprint = Blueprint("user_api", __name__, url_prefix="/api/user") user_blueprint = Blueprint("user_api", __name__, url_prefix="/api/user")
# In-memory storage for OIDC state/PKCE (production: use Redis or database)
# Format: {state: {"pkce_verifier": str, "redirect_after_login": str}}
_oidc_sessions = {}
@user_blueprint.route("/oidc/login", methods=["GET"]) @user_blueprint.route("/oidc/login", methods=["GET"])
async def oidc_login(): async def oidc_login():
@@ -47,11 +44,8 @@ async def oidc_login():
# Generate state for CSRF protection # Generate state for CSRF protection
state = secrets.token_urlsafe(32) state = secrets.token_urlsafe(32)
# Store PKCE verifier and state for callback validation # Persist PKCE verifier keyed by state for callback validation
_oidc_sessions[state] = { await save_oidc_state(state, code_verifier)
"pkce_verifier": code_verifier,
"redirect_after_login": request.args.get("redirect", "/"),
}
# Get authorization endpoint from discovery # Get authorization endpoint from discovery
discovery = await oidc_config.get_discovery_document() discovery = await oidc_config.get_discovery_document()
@@ -93,12 +87,10 @@ async def oidc_callback():
return jsonify({"error": "Missing code or state"}), 400 return jsonify({"error": "Missing code or state"}), 400
# Validate state and retrieve PKCE verifier # Validate state and retrieve PKCE verifier
session = _oidc_sessions.pop(state, None) pkce_verifier = await consume_oidc_state(state)
if not session: if not pkce_verifier:
return jsonify({"error": "Invalid or expired state"}), 400 return jsonify({"error": "Invalid or expired state"}), 400
pkce_verifier = session["pkce_verifier"]
# Exchange authorization code for tokens # Exchange authorization code for tokens
discovery = await oidc_config.get_discovery_document() discovery = await oidc_config.get_discovery_document()
token_endpoint = discovery.get("token_endpoint") token_endpoint = discovery.get("token_endpoint")
@@ -206,7 +198,7 @@ async def login():
@user_blueprint.route("/me", methods=["GET"]) @user_blueprint.route("/me", methods=["GET"])
@jwt_refresh_token_required @jwt_access_required
async def me(): async def me():
user_id = get_jwt_identity() user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id) user = await User.get_or_none(id=user_id)
+39 -2
View File
@@ -4,10 +4,47 @@ Authentication decorators for role-based access control.
from functools import wraps from functools import wraps
from quart import jsonify from quart import jsonify
from quart_jwt_extended import jwt_refresh_token_required, get_jwt_identity from quart_jwt_extended import get_jwt_identity
from quart_jwt_extended.exceptions import WrongTokenError
from quart_jwt_extended.view_decorators import (
verify_jwt_in_request,
verify_jwt_refresh_token_in_request,
)
from .models import User from .models import User
def jwt_access_required(fn):
"""
Protect an endpoint with the (1h) access token.
TRANSITIONAL: this also accepts the refresh token, so an old frontend
(which sends the refresh token on every call) keeps working against this
backend, including tabs that stay open across deploys.
It does NOT make a new frontend work against an older backend: routes
still on `@jwt_refresh_token_required` answer an access token with 422,
which the frontend does not retry. So this backend must be live on both
colors before the access-token frontend ships (deploy in two releases).
TODO: once the access-token frontend has been live for a while, replace
the body with `await verify_jwt_in_request()` (i.e. plain `@jwt_required`)
and map WrongTokenError to 401, so a stale tab still sending the refresh
token refreshes and retries instead of failing with 422.
"""
@wraps(fn)
async def wrapper(*args, **kwargs):
try:
await verify_jwt_in_request()
except WrongTokenError:
# Legacy client sent the refresh token. Expired/invalid tokens
# still raise from verify_jwt_in_request and are not retried here.
await verify_jwt_refresh_token_in_request()
return await fn(*args, **kwargs)
return wrapper
def admin_required(fn): def admin_required(fn):
""" """
Decorator that requires the user to be an admin (member of lldap_admin group). Decorator that requires the user to be an admin (member of lldap_admin group).
@@ -15,7 +52,7 @@ def admin_required(fn):
""" """
@wraps(fn) @wraps(fn)
@jwt_refresh_token_required @jwt_access_required
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
user_id = get_jwt_identity() user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id) user = await User.get_or_none(id=user_id)
+16
View File
@@ -56,3 +56,19 @@ class User(Model):
if not self.password: if not self.password:
return False return False
return bcrypt.checkpw(plain_password.encode("utf-8"), self.password) return bcrypt.checkpw(plain_password.encode("utf-8"), self.password)
class OIDCState(Model):
"""Pending OIDC login: maps the `state` param to its PKCE verifier.
Stored in Postgres (not process memory) so a login that starts on one
blue/green color and finishes on the other still validates.
"""
state = fields.CharField(max_length=128, primary_key=True)
pkce_verifier = fields.CharField(max_length=128)
expires_at = fields.DatetimeField(index=True)
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "oidc_states"
+36
View File
@@ -0,0 +1,36 @@
"""
Persistent, single-use store for pending OIDC logins (state -> PKCE verifier).
Lives in Postgres so a login started on one blue/green color can finish on
the other.
"""
from datetime import datetime, timedelta, timezone
from .models import OIDCState
OIDC_STATE_TTL = timedelta(minutes=10)
async def save_oidc_state(
state: str, pkce_verifier: str, ttl: timedelta = OIDC_STATE_TTL
) -> None:
now = datetime.now(timezone.utc)
# Opportunistic cleanup of abandoned logins
await OIDCState.filter(expires_at__lte=now).delete()
await OIDCState.create(
state=state, pkce_verifier=pkce_verifier, expires_at=now + ttl
)
async def consume_oidc_state(state: str) -> str | None:
"""Return the PKCE verifier for `state` and delete it, or None if the
state is unknown, expired, or was already consumed."""
row = await OIDCState.get_or_none(state=state)
if row is None:
return None
# The delete is the single-use guard: only one concurrent caller wins.
deleted = await OIDCState.filter(state=state).delete()
if not deleted or row.expires_at <= datetime.now(timezone.utc):
return None
return row.pkce_verifier
+3
View File
@@ -0,0 +1,3 @@
node_modules/
src-tauri/target/
src-tauri/gen/
+232
View File
@@ -0,0 +1,232 @@
{
"name": "simba-desktop",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simba-desktop",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2.12.2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz",
"integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.12.2",
"@tauri-apps/cli-darwin-x64": "2.12.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.12.2",
"@tauri-apps/cli-linux-arm64-musl": "2.12.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-musl": "2.12.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.12.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.12.2",
"@tauri-apps/cli-win32-x64-msvc": "2.12.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz",
"integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz",
"integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz",
"integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz",
"integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz",
"integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz",
"integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz",
"integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz",
"integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz",
"integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz",
"integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz",
"integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "simba-desktop",
"version": "0.1.0",
"private": true,
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build --bundles app"
},
"devDependencies": {
"@tauri-apps/cli": "^2.12.2"
}
}
+4973
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "simba-desktop"
version = "0.1.0"
edition = "2021"
[lib]
name = "simba_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
url = "2"
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Bundled pages only (offline/connect screen). The remote SimbaRAG origin gets no IPC.",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.90.0"
profile = "minimal"
+171
View File
@@ -0,0 +1,171 @@
//! Simba desktop shell.
//!
//! A thin native wrapper around the deployed SimbaRAG web app: the main window
//! starts on a bundled connect page (`ui/index.html`) that checks the server is
//! reachable and then navigates to it. The Rust side owns everything the web
//! app can't do: the menu-bar icon, the global hotkey, and keeping the app
//! alive when the window is closed.
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent,
};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
const MAIN: &str = "main";
const DEFAULT_SERVER_URL: &str = "http://localhost:8080";
/// Server URL: `SIMBA_URL` at runtime, else at build time, else localhost.
fn server_url() -> String {
std::env::var("SIMBA_URL")
.ok()
.or_else(|| option_env!("SIMBA_URL").map(str::to_owned))
.unwrap_or_else(|| DEFAULT_SERVER_URL.to_owned())
.trim_end_matches('/')
.to_owned()
}
/// Bundled connect page, told which server to probe and open.
fn connect_url() -> WebviewUrl {
let mut url = url::Url::parse("tauri://localhost/index.html").expect("static url");
url.query_pairs_mut().append_pair("server", &server_url());
WebviewUrl::App(format!("index.html?{}", url.query().unwrap_or_default()).into())
}
fn main_window(app: &AppHandle) -> Option<WebviewWindow> {
app.get_webview_window(MAIN)
}
fn show(app: &AppHandle) {
if let Some(window) = main_window(app) {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
fn toggle(app: &AppHandle) {
let Some(window) = main_window(app) else { return };
let focused = window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false);
if focused {
let _ = window.hide();
} else {
show(app);
}
}
/// Re-run the connect page (server probe + redirect), e.g. after an outage.
fn reconnect(app: &AppHandle) {
if let Some(window) = main_window(app) {
let WebviewUrl::App(path) = connect_url() else { return };
if let Ok(url) = url::Url::parse(&format!("tauri://localhost/{}", path.display())) {
let _ = window.navigate(url);
}
show(app);
}
}
fn new_chat(app: &AppHandle) {
if let Some(window) = main_window(app) {
if let Ok(url) = url::Url::parse(&format!("{}/", server_url())) {
let _ = window.navigate(url);
}
show(app);
}
}
fn build_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Simba", true, Some("Alt+Space"))?;
let new_chat_item = MenuItem::with_id(app, "new_chat", "New Chat", true, None::<&str>)?;
let reconnect_item = MenuItem::with_id(app, "reconnect", "Reconnect", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit Simba", true, Some("Cmd+Q"))?;
let menu = Menu::with_items(
app,
&[
&show_item,
&new_chat_item,
&PredefinedMenuItem::separator(app)?,
&reconnect_item,
&PredefinedMenuItem::separator(app)?,
&quit_item,
],
)?;
let mut tray = TrayIconBuilder::with_id("simba")
.tooltip("Simba")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show(app),
"new_chat" => new_chat(app),
"reconnect" => reconnect(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(app)?;
Ok(())
}
fn register_hotkey(app: &AppHandle) -> tauri::Result<()> {
let hotkey = Shortcut::new(Some(Modifiers::ALT), Code::Space);
app.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, shortcut, event| {
if shortcut == &hotkey && event.state() == ShortcutState::Pressed {
toggle(app);
}
})
.build(),
)?;
// Another app may already own ⌥Space; the tray still works without it.
if let Err(e) = app.global_shortcut().register(hotkey) {
eprintln!("simba: could not register Alt+Space: {e}");
}
Ok(())
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show(app)))
.setup(|app| {
WebviewWindowBuilder::new(app, MAIN, connect_url())
.title("Simba")
.inner_size(1200.0, 820.0)
.min_inner_size(420.0, 520.0)
.build()?;
build_tray(app.handle())?;
register_hotkey(app.handle())?;
Ok(())
})
.on_window_event(|window, event| {
// Closing the window hides it; Simba lives on in the menu bar.
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == MAIN {
api.prevent_close();
let _ = window.hide();
}
}
})
.build(tauri::generate_context!())
.expect("error while building Simba")
.run(|app, event| {
// Clicking the Dock icon brings the hidden window back.
if let RunEvent::Reopen { .. } = event {
show(app);
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
simba_desktop_lib::run()
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simba",
"version": "0.1.0",
"identifier": "co.torrtle.simba",
"build": {
"frontendDist": "../ui"
},
"app": {
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.png"
]
}
}
+88
View File
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Simba</title>
<style>
:root {
--cream: #FAF8F2;
--leaf: #2A4D38;
--muted: #7a7266;
--card: #ffffff;
--border: #e6e0d4;
}
@media (prefers-color-scheme: dark) {
:root { --cream: #1b1d1a; --leaf: #8fc4a1; --muted: #a39d92; --card: #24271f; --border: #34382f; }
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center;
background: var(--cream); color: var(--leaf);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-user-select: none; user-select: none;
}
main { text-align: center; padding: 16px; max-width: 360px; }
.cat { font-size: 48px; display: inline-block; animation: bob 1.2s ease-in-out infinite; }
.offline .cat { animation: none; filter: grayscale(1); }
@keyframes bob { 50% { transform: translateY(-6px); } }
h1 { font-size: 17px; margin: 12px 0 4px; }
p { margin: 0; color: var(--muted); font-size: 13px; }
code { font-size: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; }
button {
margin-top: 16px; padding: 6px 16px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--border); background: var(--card); color: var(--leaf); font: inherit;
}
.online-only { display: block; }
.offline-only { display: none; }
.offline .online-only { display: none; }
.offline .offline-only { display: block; }
</style>
</head>
<body>
<main id="root">
<div class="cat">🐈</div>
<div class="online-only">
<h1>waking up simba…</h1>
<p>connecting to <code id="server-a"></code></p>
</div>
<div class="offline-only">
<h1>Can't reach Simba</h1>
<p><code id="server-b"></code> isn't responding. Retrying every few seconds.</p>
<button id="retry" type="button">Try now</button>
</div>
</main>
<script>
// The Rust shell passes the server in ?server=. Probe it, then hand the
// whole window over to the real web app.
const server = (new URLSearchParams(location.search).get("server") || "http://localhost:8080").replace(/\/+$/, "");
const root = document.getElementById("root");
document.getElementById("server-a").textContent = server;
document.getElementById("server-b").textContent = server;
let timer = null;
async function probe() {
clearTimeout(timer);
const ctrl = new AbortController();
const abort = setTimeout(() => ctrl.abort(), 5000);
try {
// no-cors: we only care that the server answered, not what it said.
await fetch(`${server}/api/health`, { mode: "no-cors", cache: "no-store", signal: ctrl.signal });
location.replace(`${server}/`);
} catch {
root.classList.add("offline");
timer = setTimeout(probe, 5000);
} finally {
clearTimeout(abort);
}
}
document.getElementById("retry").addEventListener("click", () => {
root.classList.remove("offline");
probe();
});
probe();
</script>
</body>
</html>
+229
View File
@@ -0,0 +1,229 @@
# Cowork UI (phase 2) plan
Status: **proposal, awaiting approval.** Parent doc: [desktop-client.md](desktop-client.md).
Goal: make the agent's work visible and useful. Tool calls appear as a live
activity timeline. Results that are more than text (budget breakdowns, recipes,
notes, documents) open in a side panel. Files can be dropped into the
conversation. This is all built in `raggr-frontend` and the Quart backend, so
the web app gets it too; the Tauri shell needs only a one-line drag-drop
setting.
## Where we are
- `stream-query` SSE sends `tool_start {tool}` and `tool_end {tool}` and nothing
else: no id, inputs, duration or result. The UI shows a one-line pill whose
label comes from a stale `TOOL_MESSAGES` map in `useChat.ts`.
- Tools return formatted strings. `on_tool_end` saves them as `speaker="tool"`
messages, and `GET /conversation/<id>` returns only their first line
(`[tool result]`).
- Only `simba_search` uses `response_format="content_and_artifact"`, and its
artifact is a list of LangChain `Document`s that nothing reads.
- Auth: the backend accepting access tokens is merged (#57). The frontend
half (`fix/access-token-auth`) is waiting on a deploy.
- **#55 (YNAB mirror) is open.** It replaces every YNAB tool (new
`blueprints/ynab/tools.py`: `ynab_budget_summary`, `ynab_account_balances`,
`ynab_search_transactions`, `ynab_spending_report`, `ynab_category_budget`,
`ynab_recent_changes`). ⚠️ Its migration is numbered `16_…`, which clashes
with #57's `16_…_add_oidc_states` already on main, so it must be
regenerated before #55 merges.
## Contract
Every task codes against this. It lands on main with this doc before any
worker starts, so all workers share it.
### SSE events (additive; old clients ignore new fields)
```ts
// tool_start
{ type: "tool_start", run_id: string, tool: string, label: string,
input: Record<string, string | number | boolean> } // values truncated to 200 chars, `config` dropped
// tool_end
{ type: "tool_end", run_id: string, tool: string, label: string,
status: "ok" | "error", ms: number,
artifact: ArtifactRef | null }
// unchanged: thinking, thinking_delta, content, response, error, [DONE]
```
- `label` comes from a server-side registry
(`blueprints/conversation/tool_labels.py`): `{tool_name: "Checking budget…"}`,
with the fallback `"Using <tool>…"`. MCP tools match by prefix (`homebox_`,
`bookstack_`). `TOOL_MESSAGES` in `useChat.ts` is deleted.
- `status="error"` comes from `on_tool_error`. Most tools catch exceptions and
return an error string, so they report `ok`; that's acceptable.
- Parallel tool calls are paired by `run_id`.
### Artifacts
A tool opts in by returning `(text, artifact)` with
`@tool(response_format="content_and_artifact")`. The LLM sees only `text`. The
artifact is a plain JSON-serialisable dict:
```ts
type ToolArtifact = { // what a tool returns
kind: ArtifactKind;
title: string; // "October spending by category"
data: object; // per-kind shape below; ≤ 256 KB serialised or it is dropped
source_ref?: { system: "mealie" | "obsidian" | "paperless" | "ynab" | "web";
id?: string; path?: string; url?: string };
};
type ArtifactRef = { id: string; kind: ArtifactKind; title: string }; // in tool_end + conversation payload
type Artifact = ArtifactRef & { data: object; source_ref: object | null; tool: string;
conversation_id: string; message_id: string | null; created_at: string };
```
V1 kinds and their `data` shapes:
| kind | produced by | data |
|---|---|---|
| `mealie.recipe` | `mealie_get_recipe` | `{slug, name, description, servings, prep_time, cook_time, total_time, ingredients: string[], instructions: {title?, text}[], tags: string[], image_url?}` |
| `mealie.meal_plan` | `mealie_meal_plans`, `mealie_todays_meals` | `{start_date, end_date, days: {date, meals: {entry_type, name, recipe_slug?, note?}[]}[]}` |
| `mealie.shopping_lists` | `mealie_shopping_lists` | `{lists: {name, items: {name, quantity, checked}[]}[]}` |
| `obsidian.note` | `obsidian_read_note`, `journal_get_today` | `{path, title, markdown (≤ 50k chars), tags: string[], frontmatter: object}` |
| `search.documents` | `simba_search`, `obsidian_search_notes` | `{query, source, results: {title, snippet (≤ 400), filename?, path?, document_type?, date?}[]}` |
| `ynab.summary` | `ynab_budget_summary` | `{month, ready_to_assign, income, assigned, spent, age_of_money?, net_worth, overspent: {name, available}[]}` |
| `ynab.transactions` | `ynab_search_transactions` | `{start_date, end_date, count, total, transactions: {date, payee, category, account, memo?, amount}[]}` (≤ 500 rows) |
| `ynab.spending_report` | `ynab_spending_report` | `{start_date, end_date, group_by, rows: {key, amount, count?}[], total}` |
| `ynab.category_budget` | `ynab_category_budget` | `{month, groups: {name, categories: {name, assigned, activity, available, goal_target?}[]}[]}` |
YNAB kinds are built against #55's tools, so they wait for #55.
`source_ref.url` is filled where the backend knows it (Mealie base URL plus the
recipe path). Obsidian gets `obsidian://open?vault=<OBSIDIAN_VAULT_NAME>&file=<path>`
only if that env var is set; it's added to `.env`, `.env.example` and
`docker-compose.yml`.
### Storage and API
- New Tortoise model `Artifact` (`blueprints/artifacts/models.py`, table
`artifacts`): `id UUID, user FK, conversation FK (cascade), message FK →
conversation_messages (nullable, set null), tool, kind, title, data JSON,
source_ref JSON null, version int default 1, created_at, updated_at`.
Migration generated with `make migrate-new` / aerich, adds a table only.
- Created in `stream_query`'s `on_tool_end` when `output.artifact` is a dict
with a `kind`, right after the tool message is saved and linked to it.
Non-web channels (iMessage, WhatsApp, `/query`) don't create artifacts.
- `GET /api/artifacts?conversation_id=…` returns `ArtifactRef[]` plus
`{tool, message_id, created_at}`, without `data`.
`GET /api/artifacts/<id>` returns the full `Artifact`. Both require an access
token and are scoped to the owner (404 otherwise).
- `GET /api/conversation/<id>`: tool messages gain
`tool: string, label: string, artifact: ArtifactRef | null`. `text` keeps
the header line, for older clients.
- Write-back (`PATCH`) is **out of scope** for phase 2.
### Uploads
- `POST /api/conversation/upload` (multipart `file`, `conversation_id`):
- images go through the existing `process_image` and return
`{kind: "image", image_key}`
- `application/pdf`, `text/plain`, `text/markdown`, `text/csv` (by MIME type
or extension) are stored at `conversations/<id>/files/<uuid>.<ext>` and
return `{kind: "file", file_key, name, chars}`. PDF text is extracted with
PyMuPDF (`fitz`, already a dependency).
- max 10 MB; anything else gets a 415
- `upload-image` stays as is
- `stream-query` accepts an optional `file_key`. The extracted text (capped at
12k chars, with a `[truncated]` marker) is appended to the user turn as
`<attached file "name">…</attached>`. The saved user message gets
`📎 name` appended. Saved user messages get no new column.
## Tasks
Each task has its own branch off main (after the contract lands), its own herdr
worktree and pane, and its own Claude session started from a brief.
| # | Branch / pane | Scope | Owns (files) | Depends on |
|---|---|---|---|---|
| **A** | `feat/cowork-stream` | SSE v2 events + label registry; `Artifact` model, migration, API, creation in `on_tool_end`; conversation payload fields | `blueprints/conversation/__init__.py`, new `tool_labels.py`, new `blueprints/artifacts/`, `config/db.py`, `app.py` (register), a migration | contract on main. **Migration last, after rebasing** (see sequencing) |
| **B** | `feat/cowork-tool-artifacts` | `content_and_artifact` for the Mealie, Obsidian and search tools; then a second commit for the YNAB tools once #55 merges | `blueprints/conversation/agents.py`, `blueprints/ynab/tools.py` (2nd commit), tests | contract; #55 for the YNAB part |
| **C** | `feat/cowork-timeline` | Activity timeline replaces `ToolBubble`: grouped "Simba worked · N steps · 4.2 s" block per turn, rows with icon, label, input summary, spinner/✓/✗, duration and an "Open" chip that calls an `onOpenArtifact(ref)` prop; restored from conversation payload on reload | `useChat.ts`, new `ActivityTimeline.tsx`, `ToolBubble.tsx` (delete), `conversationService.ts` types, `ChatScreen.tsx` (message list only) | frontend auth PR merged |
| **D** | `feat/cowork-artifact-panel` | Right-side artifact panel: tabs for opened artifacts, `artifactService.ts`, a renderer per kind (CSS bars, no chart library), "Open in Mealie/Obsidian", a sheet below `lg`; auto-opens the newest artifact of a turn when the window is ≥ 1280 px wide, otherwise just the chip | new `components/artifacts/*`, `api/artifactService.ts`, `ChatScreen.tsx` (layout + wiring `onOpenArtifact`) | C merged first (wires its prop) |
| **E** | `feat/cowork-file-drop` | Upload endpoint + `file_key` in `stream-query`; drop overlay over the whole window, attachment chip in `MessageInput`; Tauri `disable_drag_drop_handler()` so HTML5 drops reach the webview | `blueprints/conversation/__init__.py` (new route + small `stream_query` hunk), new `utils/file_extract.py`, `MessageInput.tsx`, `ChatScreen.tsx` (overlay), `useChat.ts` (pending file), `desktop/src-tauri/src/lib.rs` | rebase after A and C (shared files) |
### Parallelism and sequencing
```
now ──► contract PR (this doc) merged
frontend-auth PR merged (after your deploy)
│
├── A feat/cowork-stream ─────────────┐ (merges 1st)
├── B feat/cowork-tool-artifacts ─────┤ (Mealie/Obsidian/search: merges anytime; YNAB commit after #55)
├── C feat/cowork-timeline ───────────┤ (merges after A)
├── D feat/cowork-artifact-panel ─────┤ (merges after C)
└── E feat/cowork-file-drop ──────────┘ (merges last)
```
- **All five start in parallel.** A and B are pure backend. C and D build
against a mock `/api` server that speaks the contract, so they don't wait for
A.
- **Migration order.** #55's migration needs regenerating (it clashes with
#57). A's migration must be generated *after* whatever lands before it. A
writes the model and code first, then rebases onto the latest main and runs
`make migrate-new` as its final step. If #55 merges while A is in review, A
rebases and regenerates.
- **Shared-file conflicts** (`ChatScreen.tsx`, `useChat.ts`,
`conversation/__init__.py`) are resolved by the merge order A → C → D → E. Each
later branch rebases onto main before its review.
- B's Mealie/Obsidian/search commit is safe to merge before A: without A,
artifacts ride along in `ToolMessage.artifact` and are ignored.
### Verification per task
- **Backend (A, B, E):** unit tests in `tests/unit/`, run with
`TAVILY_API_KEY=dummy .venv/bin/pytest tests/ -q` (create the venv with
`uv sync --all-extras`).
- A: event shaping becomes pure functions (`tool_start_payload(event)`,
`tool_end_payload(event, started_at, artifact_ref)`), which are tested,
along with the 256 KB cap and owner scoping.
- B: each converted tool, with its service mocked, returns `(str, dict)`
matching the kind's shape.
- E: extraction and the 415 case.
- **Migrations:** a throwaway Postgres container + aerich, the same approach
the auth fix used.
- **Frontend (C, D, E):** `yarn build`, plus the mock-server + Playwright recipe
(Node `http` mock serving `dist/` and SSE fixtures, Playwright on installed
Chrome, `addInitScript` to seed tokens). Screenshots are checked by eye.
Fixtures live in `raggr-frontend/test-fixtures/cowork/*.json`, and D owns one
per kind. Don't commit the Playwright devDep or a rewritten
`package-lock.json`.
## Process
1. Approve this plan. I commit it on `plan/cowork-ui`, open the PR and merge it
so the contract is on main.
2. Wait for the frontend-auth PR to merge after your deploy. A and B can start
right away; C, D and E start once it's in, so they use the new fetch helper.
3. For each task: `herdr worktree create … --base origin/main`, a
`.claude-brief.md` with that task's slice of this doc, and
`herdr agent start cowork-<x> … -- claude "Read .claude-brief.md…"`.
Workers commit locally and never push.
4. When a worker goes idle with its task done, I review:
- **pass 1:** I read the diff against the contract, run the tests and
build, and send numbered findings to the worker.
- **pass 2:** same again after its fixes.
- Then I push the branch and open the PR (`tea pr create`).
- I don't merge; you merge, or tell me to.
5. Before each review, the branch rebases on main per the merge order.
## Decisions I made (say if you disagree)
- **No write-back in phase 2.** Editing a recipe or note in the panel and
saving it to Mealie/Obsidian is phase 2.5.
- **No chart library.** CSS bars and tables are enough for budgets. Revisit if
trends need real line charts.
- **The panel auto-opens on wide windows**, otherwise shows a chip.
- **One attached file per message** (like images today). Dropped PDFs aren't
filed into Paperless yet.
- **Out of scope for now:** the bridge, notifications, the Keychain, and
detached runs. They're phases 3–4 in the parent doc.
## Open questions
1. Is **#55** close to merging? It decides when the YNAB artifacts happen, and
its migration needs regenerating against main either way.
2. What's the **Obsidian vault name** (for `obsidian://` links), or is the
link not worth having?
3. What's the **Mealie recipe URL** format on your instance
(`/g/home/r/<slug>` or `/recipe/<slug>`)? Worker B can check against the
API otherwise.
+351
View File
@@ -0,0 +1,351 @@
# Desktop Client (exploration)
Status: **proposal**, no app code yet. Branch `explore/desktop-client`.
Goal: a persistent, native-feeling macOS app where Simba works *alongside* the
user rather than a chat box in a browser tab. "Cowork-like" means:
- visible tool activity and progress while the agent works
- side panels for artifacts: budget reports, recipes, notes, documents
- drag files into the agent
- desktop notifications (scheduled messages, cron jobs, finished runs)
- a menu-bar icon and a global hotkey to summon the window
## Decisions (2026-10-12)
| Question | Decision | Consequence |
|---|---|---|
| Reachable off-LAN? | Yes, publicly via Caddy | Loading the remote origin works anywhere; `offline.html` only covers "no network / server down" |
| Who uses it? | Just the admin (Ryan) | Every desktop-only endpoint is `@admin_required`; no multi-user notification fan-out, no distribution story |
| Act on the Mac (local tools)? | **Yes** | Adds a *local tool bridge* (below). Tauri is still the pick: the bridge lives in the Rust core and can spawn local MCP servers as child processes |
| Artifact storage | Own `artifacts` table | Artifacts can be listed, reopened, edited and written back |
| Stop sending the refresh token on every request? | Yes, for web too | Becomes phase 0 (see "Auth fix") |
| Apple Developer account? | No; a local dev build is fine | No notarization or updater. `make desktop` builds an ad-hoc-signed `.app` locally |
## What exists today
| Area | Current state | Desktop implication |
|---|---|---|
| Frontend | React 19 + Rsbuild + Tailwind, `BrowserRouter`, all API calls use relative `/api/...` | Reusable as-is if the shell loads it from the server origin; a bundled copy needs an API base URL and CORS |
| PWA | `manifest.json` + `sw.js` (static cache, network-only `/api`) | "Install app" already works in Chrome / Safari "Add to Dock" |
| Streaming | `POST /api/conversation/stream-query` → SSE over `fetch` with events `thinking`, `thinking_delta`, `tool_start {tool}`, `tool_end {tool}`, `content`, `response`, `error` | Too thin for Cowork: tool events have no run id, no args, no output, no structured payload |
| Run lifecycle | The agent runs *inside* the HTTP response generator. A client disconnect cancels it (`CancelledError`, partial answer persisted) | You can't close the window and let Simba keep working |
| Tool output | Tools return strings; output is persisted as `speaker="tool"` text (capped at 4000 chars) and the UI only shows the first line. `simba_search` already uses `response_format="content_and_artifact"` | Natural hook for structured artifacts |
| Tool labels | `TOOL_MESSAGES` map in `useChat.ts` is hand-maintained and stale (no Mealie, SimbaKit, Homebox, BookStack, Beli…) | Labels should come from the server |
| Uploads | `POST /api/conversation/upload-image`: images only, 10 MB, stored in S3, attached as an image block | Need generic file upload (PDF, text, markdown, CSV) |
| Auth | Backend is the confidential OIDC client. `/oidc/login` returns an Authelia URL; Authelia redirects to `OIDC_REDIRECT_URI` (the web root); the SPA posts `code`+`state` to `/oidc/callback` and stores JWTs in `localStorage` | In-webview login works unchanged if the shell loads the real origin. A system-browser login needs a handoff (see below) |
| Auth quirks | `_oidc_sessions` is an in-process dict (a login that spans a blue/green flip fails with "Invalid or expired state"); `redirect_after_login` is stored but never used; every API call sends the **30-day refresh token** as the bearer (`@jwt_refresh_token_required` everywhere) | Desktop must keep that token in the Keychain, not plain storage |
| Notifications | Scheduled messages and cron jobs deliver to iMessage / email only (admin-only APIs) | No in-app notification channel exists |
| CORS | None configured | Only matters for a bundled frontend |
## Shell options
### A. PWA ("Install app" / "Add to Dock")
- **Reuse:** 100%. Already shipped.
- **Size:** 0.
- **macOS integration:** own window and Dock icon; Web Push and the Badging API
work for installed PWAs. **No** menu-bar icon, **no** global hotkey, no
quick-ask panel, no Keychain, file access only through the picker/drop.
- **OIDC:** unchanged.
- **Verdict:** a free baseline and the fallback. It can't deliver the
menu-bar or hotkey parts, which are most of what makes it feel like a
desktop app.
### B. Tauri v2 (recommended)
- **Reuse:** high. Either load the deployed frontend from the server origin
(no fork), or bundle `raggr-frontend/dist` (needs an API base URL plus CORS).
- **Size:** ~5–15 MB `.app`, low idle memory (system WKWebView). Rust and
cargo are already installed on this machine.
- **macOS integration:** first-party plugins for tray/menu-bar (core
`TrayIcon`), `global-shortcut`, `notification`, `deep-link`,
`single-instance`, `window-state`, `autostart`, `updater`, `positioner`
(anchor the quick-ask panel under the tray icon), keychain storage, and
window file drag-drop events with real paths. `ActivationPolicy::Accessory`
gives a menu-bar-only mode.
- **Costs:** Safari/WebKit engine (the PWA already targets Safari, so low
risk). Service workers don't run in WKWebView here (the `sw.js` register
call already fails soft). Native code is Rust, but the native layer stays
small: tray, hotkey, notifications, keychain, deep link.
- **Security:** remote content can reach Tauri IPC only through an explicit
`remote.urls` capability. Grant the remote origin only notification and
window commands; register shortcut and tray from Rust, not JS.
### C. Electron
- **Reuse:** same as Tauri.
- **Size:** ~100 MB+ download, ~200 MB+ on disk, 150–300 MB RAM idle.
- **macOS integration:** everything Tauri has, more mature (`menubar`
package, `globalShortcut`, `Notification`, `safeStorage`), plus Node in the
main process. That would matter if the client ran local tools or MCP
servers on the user's machine (see open questions).
- **Verdict:** the better choice only if we want heavy client-side tool
execution in JS/Node. For a thin client to a self-hosted agent, the size
and memory cost buys nothing. Local tools are wanted, but the bridge design
below runs local MCP servers as child processes (Node is already installed),
so Electron's in-process Node isn't needed.
### Decision matrix
| | PWA | Tauri v2 | Electron |
|---|---|---|---|
| Reuse raggr-frontend | ✅ as-is | ✅ remote or bundled | ✅ remote or bundled |
| Bundle / RAM | none | ~10 MB / low | ~100 MB+ / high |
| Menu bar + quick-ask | ❌ | ✅ | ✅ |
| Global hotkey | ❌ | ✅ | ✅ |
| Notifications | Web Push (VAPID) | ✅ native | ✅ native |
| File drag-drop w/ paths | browser `File` only | ✅ | ✅ |
| Secure token storage | ❌ localStorage | ✅ Keychain | ✅ safeStorage |
| Auto-update | free (server) | `updater` plugin, or free with a remote UI | `electron-updater` |
| Local tool execution | ❌ | Rust | Node (easiest) |
## Recommended architecture
**A Tauri v2 shell that loads the deployed SimbaRAG origin, plus a tiny bundled
"offline/connect" page. Cowork features are built in `raggr-frontend` behind
feature detection, so the web app gets them too.**
```
┌──────────────────────── Simba.app (Tauri v2) ─────────────────────────┐
│ Rust core (small) │
│ • tray icon + menu (Ask…, New chat, recent convos, Quit) │
│ • global shortcut ⌥Space → show/hide main window │
│ • notifications (plugin-notification) │
│ • keychain token store, deep-link `simba://`, single-instance │
│ • window-state, autostart │
│ │
│ Main window ──loads──▶ https://<simba-host>/ (full UI) │
│ Fallback ──bundled─▶ offline.html (server unreachable / retry) │
└───────────────────────────────┬────────────────────────────────────────┘
│ HTTPS: REST + SSE (same origin, no CORS)
┌───────────────────────────────▼────────────────────────────────────────┐
│ Caddy → raggr-blue / raggr-green (Quart) │
│ /api/conversation/* (+ structured tool/artifact events) │
│ /api/runs/* (detached agent runs, resumable SSE) [phase 3] │
│ /api/files (generic upload) │
│ /api/notifications/stream (per-user SSE feed) │
└────────────────────────────────────────────────────────────────────────┘
```
Why load the remote origin rather than bundle:
- **One codebase, one deploy.** `make redeploy` updates web, PWA and desktop
together. No version skew between the desktop UI and the SSE protocol, and
no desktop release for UI changes.
- **No CORS, no API-base refactor, and the current OIDC flow works unchanged**
inside the webview (redirect back to the web root on the same origin).
- The agent needs the server anyway, so offline-first buys little. The bundled
`offline.html` covers "server down / not on VPN".
The frontend detects the shell with `window.__TAURI_INTERNALS__` (or a
`?shell=desktop` query param) and turns on the native bits: notifications
and drag-drop paths. Everything else renders
the same in a browser.
### Cowork UI (in raggr-frontend, shared by web and desktop)
- **Three panes:** conversations | chat | **artifact panel** (collapsible, a
tab per artifact). On mobile/web the artifact panel becomes a sheet.
- **Activity timeline** in place of single-line `ToolBubble` pills: one row per
tool call with a server-provided label, args summary, a spinner, duration,
and an expandable result. Clicking a row with an artifact opens it in the
panel.
- **Artifact renderers:** `budget` (YNAB summary/category table + chart),
`transactions` (table), `recipe` (Mealie: ingredients/steps, "add to meal
plan"), `note` (Obsidian markdown, "open in Obsidian" via
`obsidian://` deep link), `document` (Paperless hit: title, snippet, link to
Paperless), `photo` (Immich), `device` (SimbaKit status/weight chart).
- **Drop zone** over the whole window: images go to the existing vision path;
PDFs and text go to the new upload endpoint (below).
### Local tool bridge (Simba acts on the Mac)
The backend can't reach the Mac (it's behind NAT), so the desktop app dials
out and the backend calls tools back over that connection.
```
Mac Server
┌─ Simba.app Rust core ────────────┐ wss ┌─ Quart ────────────────────────┐
│ bridge client ───────────────────┼─────────▶│ /api/desktop/bridge (websocket)│
│ │ JSON-RPC (MCP messages) │ │ │ │
│ ▼ │ │ ▼ │
│ local MCP servers (child procs) │ │ MCP ClientSession over the ws │
│ • filesystem (scoped folders) │ │ → langchain-mcp-adapters tools│
│ • later: screenshot, clipboard, │ │ → mcp_tools.select_tools( │
│ open app/URL, Shortcuts │ │ prefix="mac", admin-only) │
│ confirm dialog for write tools │ │ → injected per run only while │
└──────────────────────────────────┘ │ the bridge is connected │
└────────────────────────────────┘
```
- **The protocol is MCP**, so the backend reuses the existing pattern in
`utils/mcp_tools.py` (allowlist, `prefix_` rename, admin re-check). Only the
transport is new: an MCP `ClientSession` over the websocket's read/write
streams instead of stdio.
- **The bridge runs in Rust, not in the webview.** The webview loads remote
content, so giving page JS file access would turn any XSS into local file
access. The Rust core owns the socket, the child processes, and the
allowlist of exposed folders (picked in a native settings window).
- **Writes need consent:** tools tagged as writes (save file, move, run a
Shortcut) show a native confirm dialog before running. Reads inside the
scoped folders are allowed without asking.
- **Availability:** the agent is compiled once at startup, so `mac_*` tools are
added per run, e.g. via `get_agent_for(user)` returning a variant with
bridge tools when a bridge session is connected. If the bridge drops
mid-call, the tool returns "Mac is offline" rather than hanging.
- **Auth:** the websocket authenticates with the same JWT, so this depends on
the auth fix landing first. A blue/green flip drops the socket and the app
reconnects to the new color.
- **Open spike question:** do the `mcp` Python SDK and `langchain-mcp-adapters`
accept a custom transport cleanly? Fallback: a small hand-rolled JSON-RPC
tool-call protocol with tools defined server-side.
## Auth fix (web + desktop)
Today the frontend sends the **refresh** token as the bearer on every call,
and 20 routes use `@jwt_refresh_token_required` (conversation ×9, beli ×6,
users ×2 incl. `/refresh`, rag ×1, app ×1, plus `admin_required` in
`blueprints/users/decorators.py`).
Change:
1. Backend: every route except `/api/user/refresh` uses `@jwt_required`
(access token, 1h). The same goes for the inner decorator of
`admin_required`.
2. Frontend: collapse `fetchWithRefreshToken` and `fetchWithAuth` into one
function that sends the access token and, on 401, refreshes once and
retries. `validateToken` keeps using the refresh token.
3. Desktop: the refresh token lives in the Keychain. The webview keeps only
the access token (memory or `localStorage`).
This is blue/green-safe in both directions. An *old* frontend talking to the
new backend sends the refresh token, gets a 401, calls `/refresh`, and retries
with an access token, so it works. A *new* frontend talking to the old backend
sends an access token where a refresh token is required, gets a 401, and the
retry with a fresh access token also fails. That window lasts only as long as
the deploy flip. To avoid it, deploy the backend change first: for one release,
accept either token type on the protected routes (a small custom decorator),
then flip the frontend.
SSE streams are authenticated once at request start, so a stream running past
the 1h access-token expiry is fine.
Also in scope: move `_oidc_sessions` into Postgres (or signed, stateless
`state`) so a login spanning a deploy doesn't fail, and either honour or
delete the unused `redirect_after_login`.
## Backend changes
Ordered by how much the client depends on them. All are additive, so
blue/green stays safe: the old frontend ignores the new fields.
1. **Richer SSE events** (`blueprints/conversation/__init__.py`):
- `tool_start {run_id, tool, label, input}`: `input` comes from
`event["data"]["input"]`, trimmed/redacted; `label` comes from a
server-side map that replaces `TOOL_MESSAGES`.
- `tool_end {run_id, tool, ms, summary, artifact?}`: `run_id` lets parallel
tool calls pair up correctly.
- optional `event: id` lines so a client can resume (needed for phase 3).
2. **Structured artifacts.** Move high-value tools to
`response_format="content_and_artifact"` (as `simba_search` already does): the
LLM still sees compact text, and the UI gets
`{kind, title, data}`. Persist them in an **`artifacts` table**:
`id, user_id, conversation_id, message_id (nullable FK to the tool row),
kind, title, data JSON, source_ref (e.g. Mealie slug, Obsidian path,
Paperless id), version, created_at, updated_at`. Generate the migration with
`make migrate-new` (never hand-written); a new table is backward-compatible.
Endpoints: `GET /api/artifacts?conversation_id=` and
`GET/PATCH /api/artifacts/<id>`. `PATCH` writes back to the source
integration where one exists (edit a recipe → Mealie, edit a note →
Obsidian) and bumps `version`. `GET /api/conversation/<id>` includes artifact
ids on tool messages.
3. **Generic file upload** `POST /api/conversation/upload` (supersedes
`upload-image`, which stays for compatibility): accept images, PDF, md/txt,
CSV. Store in S3 under `conversations/<id>/`, extract text (reuse
`utils/cleaner.py` for PDFs), and attach it as a document block capped to the
context budget (32k slots). Possible extra: a "file it in Paperless" action
that pushes the PDF into Paperless so it gets indexed. Revisit
`MAX_CONTENT_LENGTH` (10 MB).
4. **Notifications feed.** A `notifications` table (user, kind, title, body,
link, created_at, read_at) written by the scheduler (`process_due_messages`),
cron runs, and completed detached runs. Add
`GET /api/notifications/stream` (per-user SSE with `Last-Event-ID` replay
from the table, so a blue/green flip only causes a reconnect) and
`POST /api/notifications/<id>/read`. Also consider a `desktop` value on
`MessageChannel` so a scheduled message can target the app instead of
iMessage.
5. **Detached agent runs** (phase 3): `POST /api/runs` starts the agent in a
background task and returns `run_id`; `GET /api/runs/<id>/events` streams
with replay. This decouples work from the window. Constraint: an in-process
run dies on a blue/green stop. Either persist events (Postgres) and mark
runs `interrupted` on startup, or have `deploy.sh` drain active runs before
stopping the old color.
6. **Native auth handoff** (phase 3, optional): sign in through the system
browser (password manager, passkeys, existing Authelia session) without
changing Authelia config:
1. The app generates a PKCE verifier and opens
`https://<simba-host>/api/user/oidc/login?client=desktop&challenge=…` in
the default browser.
2. The normal web OIDC round-trip runs against the existing redirect URI.
3. The backend sees `client=desktop` on the stored session and, instead of
returning JWTs to the page, issues a one-time 60-second handoff code and
redirects to `simba://auth?code=…`.
4. The app exchanges `code + verifier` at `POST /api/user/desktop/token` for
JWTs and stores them in the Keychain.
This also requires moving `_oidc_sessions` into Postgres or signed state so
it survives blue/green. The alternative, registering a loopback or
custom-scheme redirect URI in Authelia, depends on Authelia's redirect
matching rules (exact match, ports?). That needs checking, and the client
secret would have to stay on the server anyway.
7. **Local tool bridge:** `/api/desktop/bridge` websocket, an MCP-over-websocket
client, and per-run injection of `mac_*` tools (see above). Admin-only.
8. **CORS:** only needed if we ever bundle the frontend (`quart-cors`
allowing `tauri://localhost` / `http://tauri.localhost`). Not needed
with the recommendation.
## Phased plan
| Phase | Scope | Backend | Exit criteria |
|---|---|---|---|
| **0a. Auth fix** (own PR) | access token on every call, single fetch helper, OIDC state in Postgres | auth fix (+ migration if state goes in a table) | Web works through a blue/green flip; the refresh token is only ever sent to `/refresh` |
| **0b. Spike** (½ day) | `desktop/` Tauri v2 app: one window loading `<simba-host>`, tray icon with Show/Quit, ⌥Space toggles the window, `offline.html` fallback | none | Log in through Authelia inside the webview, stream an answer, survive a `make redeploy` |
| **1. Native shell** | notifications, window-state, autostart, refresh token in the Keychain (no quick-ask window: dropped 2026-10-12) | none | A scheduled-message notification arrives; the login survives an app restart |
| **2. Cowork UI** ([plan](cowork-ui-plan.md)) | activity timeline, artifact panel + renderers (budget, recipe, note, document first), whole-window drop zone | items 1–3 (`artifacts` table) | Ask "how's the grocery budget" → YNAB artifact opens in the side panel; drop a PDF → summarised |
| **3. Local tools** | Rust bridge client, filesystem MCP scoped to chosen folders, write-confirm dialog, folder picker | item 7 | "Summarise the PDFs in ~/Downloads/taxes" works; a write prompts first; quitting the app removes the tools |
| **4. Background agent** | detached runs, notifications feed, desktop channel for scheduled messages, system-browser auth handoff | items 4–6 | Close the window mid-run → notification when done → reopen shows the full timeline |
**0b status:** scaffolded in `desktop/`. It has a bundled connect page that
probes `/api/health` and hands off to `$SIMBA_URL`, a menu-bar icon
(Show / New Chat / Reconnect / Quit), ⌥Space to toggle, close-to-hide,
single-instance, and Dock-click reopen. The remote origin gets no IPC.
`desktop/src-tauri/rust-toolchain.toml` pins Rust 1.90, the minimum
Tauri 2.12 accepts, so the global toolchain is untouched. Run it with
`make desktop-dev SIMBA_URL=…` or `make desktop`.
Verified 2026-10-12: Authelia login inside the webview works, and the
conversation list loads.
Phases 1–3 are independent once 0a/0b land. Phase 2 also improves the web
UI. There's no distribution phase: `make desktop` runs `tauri build` and
copies the ad-hoc-signed `.app` to `/Applications`. A locally built app isn't
quarantined, so Gatekeeper won't complain. Because the UI loads from the
server, rebuilds are only needed when the Rust shell changes.
## Open questions
1. **Keychain and ad-hoc signing:** the ad-hoc signature changes on every
rebuild, so macOS may ask again for Keychain access after each
`make desktop`. Acceptable, or use a self-signed local code-signing
certificate to keep the identity stable?
2. **Local tool scope for v1:** filesystem (read + confirmed writes) only, or
also screenshot, clipboard, "open URL/app", and running Shortcuts?
3. **MCP over websocket:** does `langchain-mcp-adapters` take a custom session
or transport cleanly? Settle this in a short spike before phase 3;
otherwise fall back to a hand-rolled tool-call protocol.
4. **Engine risk:** does anything in the UI rely on Chromium-only behaviour?
The spike should check markdown rendering, streaming `fetch`, and the
image picker in WKWebView.
5. **Artifact write-back conflicts:** if a recipe or note changed in
Mealie/Obsidian after the artifact was captured, should we refuse, show a
diff, or let the last write win?
@@ -1,239 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "ynab_accounts" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL,
"type" VARCHAR(50) NOT NULL,
"on_budget" BOOL NOT NULL,
"closed" BOOL NOT NULL,
"note" TEXT,
"balance" DECIMAL(15,3) NOT NULL,
"cleared_balance" DECIMAL(15,3) NOT NULL,
"uncleared_balance" DECIMAL(15,3) NOT NULL,
"last_reconciled_at" TIMESTAMPTZ,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "ynab_categories" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"category_group_id" VARCHAR(36) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"hidden" BOOL NOT NULL,
"internal" BOOL NOT NULL DEFAULT False,
"note" TEXT,
"goal_type" VARCHAR(20),
"goal_target" DECIMAL(15,3),
"goal_target_month" DATE,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_catego_categor_2fc4cc" ON "ynab_categories" ("category_group_id");
CREATE TABLE IF NOT EXISTS "ynab_category_groups" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL,
"hidden" BOOL NOT NULL,
"internal" BOOL NOT NULL DEFAULT False,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "ynab_changes" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"synced_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"server_knowledge" BIGINT NOT NULL,
"entity_type" VARCHAR(30) NOT NULL,
"entity_id" VARCHAR(64) NOT NULL,
"action" VARCHAR(10) NOT NULL,
"label" VARCHAR(500) NOT NULL,
"changes" JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_ynab_change_synced__77ae75" ON "ynab_changes" ("synced_at");
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__c12b5b" ON "ynab_changes" ("entity_type");
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__9fb442" ON "ynab_changes" ("entity_id");
COMMENT ON TABLE "ynab_changes" IS 'Append-only log of what each sync changed, built by diffing against the mirror.';
CREATE TABLE IF NOT EXISTS "ynab_months" (
"id" VARCHAR(10) NOT NULL PRIMARY KEY,
"month" DATE NOT NULL,
"note" TEXT,
"income" DECIMAL(15,3) NOT NULL,
"budgeted" DECIMAL(15,3) NOT NULL,
"activity" DECIMAL(15,3) NOT NULL,
"to_be_budgeted" DECIMAL(15,3) NOT NULL,
"age_of_money" INT,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_months_month_483dcb" ON "ynab_months" ("month");
CREATE TABLE IF NOT EXISTS "ynab_month_categories" (
"id" VARCHAR(47) NOT NULL PRIMARY KEY,
"month" DATE NOT NULL,
"category_id" VARCHAR(36) NOT NULL,
"budgeted" DECIMAL(15,3) NOT NULL,
"activity" DECIMAL(15,3) NOT NULL,
"balance" DECIMAL(15,3) NOT NULL,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_month__month_f13a79" ON "ynab_month_categories" ("month");
CREATE INDEX IF NOT EXISTS "idx_ynab_month__categor_a62299" ON "ynab_month_categories" ("category_id");
COMMENT ON TABLE "ynab_month_categories" IS 'Per-month category amounts. ``budgeted`` is what YNAB calls \"Assigned\".';
CREATE TABLE IF NOT EXISTS "ynab_payees" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(500) NOT NULL,
"transfer_account_id" VARCHAR(36),
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "ynab_subtransactions" (
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
"transaction_id" VARCHAR(64) NOT NULL,
"amount" DECIMAL(15,3) NOT NULL,
"memo" TEXT,
"payee_id" VARCHAR(36),
"category_id" VARCHAR(36),
"transfer_account_id" VARCHAR(36),
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_transac_0571ba" ON "ynab_subtransactions" ("transaction_id");
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_categor_c16c94" ON "ynab_subtransactions" ("category_id");
COMMENT ON TABLE "ynab_subtransactions" IS 'Lines of a split transaction; the parent''s category is \"Split\".';
CREATE TABLE IF NOT EXISTS "ynab_sync_state" (
"budget_id" VARCHAR(64) NOT NULL PRIMARY KEY,
"budget_name" VARCHAR(255),
"server_knowledge" BIGINT,
"last_synced_at" TIMESTAMPTZ,
"last_full_sync_at" TIMESTAMPTZ,
"last_error" TEXT
);
CREATE TABLE IF NOT EXISTS "ynab_transactions" (
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
"date" DATE NOT NULL,
"amount" DECIMAL(15,3) NOT NULL,
"memo" TEXT,
"cleared" VARCHAR(20) NOT NULL,
"approved" BOOL NOT NULL,
"flag_color" VARCHAR(20),
"account_id" VARCHAR(36) NOT NULL,
"payee_id" VARCHAR(36),
"category_id" VARCHAR(36),
"transfer_account_id" VARCHAR(36),
"import_payee_name" VARCHAR(500),
"account_name" VARCHAR(255),
"payee_name" VARCHAR(500),
"category_name" VARCHAR(255),
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_date_a71ead" ON "ynab_transactions" ("date");
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_account_414dfe" ON "ynab_transactions" ("account_id");
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_payee_i_4146bc" ON "ynab_transactions" ("payee_id");
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_categor_232d47" ON "ynab_transactions" ("category_id");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP TABLE IF EXISTS "ynab_months";
DROP TABLE IF EXISTS "ynab_categories";
DROP TABLE IF EXISTS "ynab_transactions";
DROP TABLE IF EXISTS "ynab_payees";
DROP TABLE IF EXISTS "ynab_subtransactions";
DROP TABLE IF EXISTS "ynab_accounts";
DROP TABLE IF EXISTS "ynab_sync_state";
DROP TABLE IF EXISTS "ynab_category_groups";
DROP TABLE IF EXISTS "ynab_changes";
DROP TABLE IF EXISTS "ynab_month_categories";"""
MODELS_STATE = (
"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"
)
@@ -0,0 +1,101 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "oidc_states" (
"state" VARCHAR(128) NOT NULL PRIMARY KEY,
"pkce_verifier" VARCHAR(128) NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS "idx_oidc_states_expires_c461d9" ON "oidc_states" ("expires_at");
COMMENT ON TABLE "oidc_states" IS 'Pending OIDC login: maps the `state` param to its PKCE verifier.';
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
DROP TABLE IF EXISTS "oidc_states";"""
MODELS_STATE = (
"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"
"wS81"
)
@@ -0,0 +1,249 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "ynab_accounts" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL,
"type" VARCHAR(50) NOT NULL,
"on_budget" BOOL NOT NULL,
"closed" BOOL NOT NULL,
"note" TEXT,
"balance" DECIMAL(15,3) NOT NULL,
"cleared_balance" DECIMAL(15,3) NOT NULL,
"uncleared_balance" DECIMAL(15,3) NOT NULL,
"last_reconciled_at" TIMESTAMPTZ,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "ynab_categories" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"category_group_id" VARCHAR(36) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"hidden" BOOL NOT NULL,
"internal" BOOL NOT NULL DEFAULT False,
"note" TEXT,
"goal_type" VARCHAR(20),
"goal_target" DECIMAL(15,3),
"goal_target_month" DATE,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_catego_categor_2fc4cc" ON "ynab_categories" ("category_group_id");
CREATE TABLE IF NOT EXISTS "ynab_category_groups" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL,
"hidden" BOOL NOT NULL,
"internal" BOOL NOT NULL DEFAULT False,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "ynab_changes" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"synced_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"server_knowledge" BIGINT NOT NULL,
"entity_type" VARCHAR(30) NOT NULL,
"entity_id" VARCHAR(64) NOT NULL,
"action" VARCHAR(10) NOT NULL,
"label" VARCHAR(500) NOT NULL,
"changes" JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_ynab_change_synced__77ae75" ON "ynab_changes" ("synced_at");
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__c12b5b" ON "ynab_changes" ("entity_type");
CREATE INDEX IF NOT EXISTS "idx_ynab_change_entity__9fb442" ON "ynab_changes" ("entity_id");
COMMENT ON TABLE "ynab_changes" IS 'Append-only log of what each sync changed, built by diffing against the mirror.';
CREATE TABLE IF NOT EXISTS "ynab_months" (
"id" VARCHAR(10) NOT NULL PRIMARY KEY,
"month" DATE NOT NULL,
"note" TEXT,
"income" DECIMAL(15,3) NOT NULL,
"budgeted" DECIMAL(15,3) NOT NULL,
"activity" DECIMAL(15,3) NOT NULL,
"to_be_budgeted" DECIMAL(15,3) NOT NULL,
"age_of_money" INT,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_months_month_483dcb" ON "ynab_months" ("month");
CREATE TABLE IF NOT EXISTS "ynab_month_categories" (
"id" VARCHAR(47) NOT NULL PRIMARY KEY,
"month" DATE NOT NULL,
"category_id" VARCHAR(36) NOT NULL,
"budgeted" DECIMAL(15,3) NOT NULL,
"activity" DECIMAL(15,3) NOT NULL,
"balance" DECIMAL(15,3) NOT NULL,
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_month__month_f13a79" ON "ynab_month_categories" ("month");
CREATE INDEX IF NOT EXISTS "idx_ynab_month__categor_a62299" ON "ynab_month_categories" ("category_id");
COMMENT ON TABLE "ynab_month_categories" IS 'Per-month category amounts. ``budgeted`` is what YNAB calls \"Assigned\".';
CREATE TABLE IF NOT EXISTS "ynab_payees" (
"id" VARCHAR(36) NOT NULL PRIMARY KEY,
"name" VARCHAR(500) NOT NULL,
"transfer_account_id" VARCHAR(36),
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "ynab_subtransactions" (
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
"transaction_id" VARCHAR(64) NOT NULL,
"amount" DECIMAL(15,3) NOT NULL,
"memo" TEXT,
"payee_id" VARCHAR(36),
"category_id" VARCHAR(36),
"transfer_account_id" VARCHAR(36),
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_transac_0571ba" ON "ynab_subtransactions" ("transaction_id");
CREATE INDEX IF NOT EXISTS "idx_ynab_subtra_categor_c16c94" ON "ynab_subtransactions" ("category_id");
COMMENT ON TABLE "ynab_subtransactions" IS 'Lines of a split transaction; the parent''s category is \"Split\".';
CREATE TABLE IF NOT EXISTS "ynab_sync_state" (
"budget_id" VARCHAR(64) NOT NULL PRIMARY KEY,
"budget_name" VARCHAR(255),
"server_knowledge" BIGINT,
"last_synced_at" TIMESTAMPTZ,
"last_full_sync_at" TIMESTAMPTZ,
"last_error" TEXT
);
CREATE TABLE IF NOT EXISTS "ynab_transactions" (
"id" VARCHAR(64) NOT NULL PRIMARY KEY,
"date" DATE NOT NULL,
"amount" DECIMAL(15,3) NOT NULL,
"memo" TEXT,
"cleared" VARCHAR(20) NOT NULL,
"approved" BOOL NOT NULL,
"flag_color" VARCHAR(20),
"account_id" VARCHAR(36) NOT NULL,
"payee_id" VARCHAR(36),
"category_id" VARCHAR(36),
"transfer_account_id" VARCHAR(36),
"import_payee_name" VARCHAR(500),
"account_name" VARCHAR(255),
"payee_name" VARCHAR(500),
"category_name" VARCHAR(255),
"deleted" BOOL NOT NULL DEFAULT False
);
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_date_a71ead" ON "ynab_transactions" ("date");
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_account_414dfe" ON "ynab_transactions" ("account_id");
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_payee_i_4146bc" ON "ynab_transactions" ("payee_id");
CREATE INDEX IF NOT EXISTS "idx_ynab_transa_categor_232d47" ON "ynab_transactions" ("category_id");
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "users" ALTER COLUMN "ldap_groups" TYPE JSONB USING "ldap_groups"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "tags" TYPE JSONB USING "tags"::JSONB;
ALTER TABLE "beli_restaurants" ALTER COLUMN "cuisine" TYPE JSONB USING "cuisine"::JSONB;
DROP TABLE IF EXISTS "ynab_payees";
DROP TABLE IF EXISTS "ynab_sync_state";
DROP TABLE IF EXISTS "ynab_subtransactions";
DROP TABLE IF EXISTS "ynab_changes";
DROP TABLE IF EXISTS "ynab_categories";
DROP TABLE IF EXISTS "ynab_month_categories";
DROP TABLE IF EXISTS "ynab_accounts";
DROP TABLE IF EXISTS "ynab_transactions";
DROP TABLE IF EXISTS "ynab_months";
DROP TABLE IF EXISTS "ynab_category_groups";"""
MODELS_STATE = (
"eJztXQuP2zYS/iuEUeBSYLu376R7hwO8u2667b6Qda6XZgOFlmibtzKl02O3RtH/fiQl2X"
"pQsmlLtugQaJpY5FDSR4qc+WY4/LMzcSxk+/uXDnlBng8D7JDOOfizQ+AE0X8Iy/dAB7ru"
"vJRdCODA5gJmqiYvgQM/8KAZ0MIhtH1EL1nINz3sxjcjoW2zi45JK2Iyml8KCf5fiIzAGa"
"FgjDxa8PkLvYyJhf5AfvLTfTaGGNlW5rmxxe7NrxvB1OXXPn68vvqJ12S3GximY4cTMq/t"
"ToOxQ2bVwxBb+0yGlY0QQR4MkJV6DfaU8Wsnl6InphcCL0SzR7XmFyw0hKHNwOj8cxgSk2"
"EA+J3Y/07+1ZGAh0LNoMUkYFj8+Vf0VvN35lc77FaXP3c/vDk++56/peMHI48XckQ6f3FB"
"GMBIlOM6B5L/XYDycgw9MZRJ/RyY9EFXgTG5MMdxPoYSIBOAVkOtM4F/GDYio2BMfx6dnl"
"bA+O/uB44krcWhdOi4jkb9XVx0FJUxSOcQmh5ir2zAoAjkFS0J8ASJwcxK5iC1YtH95B8t"
"BZi+g3VP7Gn8EVTg27++7T32u7cP7E0mvv8/m0PU7fdYyRG/Os1dfXOW64pZI+C36/7PgP"
"0Ev9/f9fJjf1av/3uHPRMMA8cgzqsBrdT3mlxNgMl0bOhaK3ZsVlJ37FY7Nn741Ac7hoQg"
"W2baS4msNPPFTyXVfZ1XNFhjvcjNfAfLTHwH5fPeQX7aC33kGXLrcEpkjcV4FSzX+xRWX3"
"qZAjN8Fq68DIwieD85HsIj8iuacgiv6XNAYoqW3Fh1+xg301bQ5lfnT+HB15lOlx4U9O3o"
"O6Eg+hi7j5fdq16HYziA5vMr9CyjBMwJ8n04Qn4R0ItY8qdfPyB7puCKsUyrwbdRi+2cm8"
"uw5Vg5R04Kowx6xaLJ0SR/BRL63lZ8b3anCnQW2BIpEJczKYx0R2rTQmnTIkB/CBSnPr0q"
"hjKpr4ppUaUQ9f7Tz+hCyUL65rb7n+8z+tDN/d37pHpq4b28ub/QdsYuqqNFO8N3EXwWKQ"
"NMH+2RcFJQBTK9mxLf8qfT+fjY+3AO2Ir+RB6vby+658DHkwF8Iv37+xuKhuNwHVpWez1c"
"Rns9LNdeDwvaK57QVcZ4RlMZKyAjtDE7oE4C5PTwaAkkaa1SKHlZFst4zTaojWTZUmxSUV"
"JJVJuhldJqkZxSIhCtU0NR1OAycyT3moZXnjNvLYoLLTDBcJG1xJq0O5iFe4smjjftCMyN"
"VGmllcHNzAmriLV1sQPWBb1JgIiUgZES0TaGtjG+KRtD+zJ2omMLvoytsfDtmgxVoeFbrA"
"WuwcM3qf3dX19dPgYwEHLN88JK3c/Blmn4rN5yml/nARGLYgpY+8B2Rpicgwl0fUDVPfCV"
"t/QVuNCDExA4AAc+ePj1sgeoFo3pa3n7eY5j7QafyBN5DOigtAAm4CEe4eANcQLgeo5J7W"
"jAldvp98B3AIzuQVuHAaCte7RBqs85BD2RgR2iv1NpRACdExwPUNsbDDHB/hjxWuyRHKbX"
"Ukls2+AF2phN7z5/rTV0Xz/pqWXpgZlAPeraYt23TlLg8OjdMqTV0bty1oqVVWvB7rOJjG"
"SUyEBbEFRFI24M5Dmo6A8X069rBV0pK7kZXanmQayIapTgUKn0amtmJ5TeqGMLOtz22CiR"
"KpIogNUMlGae1GeeWD/Khs2mZdRcaRvxcbjQ918dTzAyLzCB3rREd0lJ5cAcTGMdv3Ueow"
"rwLhgPl554KWQX13fdD5/EVF5SPz1uLz71e928HjOBWCrKcSagpL59sJyTuMpLXHATv1ID"
"yqdTukHCyUBOwxaI1uLc3Cyqx8uAelyO6bHA8x47feUhFYhqSJPv1kCEvZ5oLnUcG0FS8c"
"2nZfMzahyy0cT6JKv9SEyp9/c3mSn14jrv/fh4e9Gj8wGHmVbCEdt1fdcXgjueQJOqXc9I"
"4DpeMKHmZBUcsYdny8yseXsgNbGe5UdsxMiFg/8iU2CblQOal1MQzEYUKWotjQ3Xc16wJT"
"elFgQ3p512bMeE9hrqfS60aZlp9bR8Wj0tTKu2BV1j5DmhK4gr/+Xx/k4MaU4sB+hHQt/y"
"s4XNYA/Y2A++NAbv5y/NmE7szasdz3kfc05XZQ1ox/MOUzXa8bxzHTvbXbIkBScOu6txf0"
"6bjfzivpxsuOw8CG11LLJxb+0b+UtBEenH0DSdkImtBUiPtdWNmlIYEt8cIyuktphRz562"
"x6Q9NTe0paEZIBszXXWI7XVRuaBNPUQtKQZIk74N/gl1BM6NXkIMlns3+Ke8ZHgFbw7E45"
"v+HUBGtvNAhDhGsxhBsYTMLEjCZ6EUPrIAfyjAK77iYAyOD36w4BR4iNWnD7MPbjB59lnM"
"xeXYcybw6uKJDB0PvFD7kv7lI+iZY2BCFw6wjQO8fhCEdsPU5IZJuEARoIt3nAiBVYLnbo"
"Q/WIGKWZeF2bpH63Qph8FphcPgtOgwGNJZhCnRdBwLlqhyNPNyakLayNikNkgpoBW7iR0V"
"4dx0vL8ljMirtssTGTUtckUs8KXimwaONTVkt9lnhBTZbrnpz4JjNA4mAu/5AmATIQ2seH"
"8R17LpHemjSWpuAlFFQNY56DTLuip9rlYssl6sy3syZjklN2tlpfR+rQSQIobSW7bUJYz3"
"clu3soOkTbu3MhiXEYupPljAL2ZcBcvyjLEMowmHeBR6nBoGjOW7vu0+sMsEcbrLL2MdJV"
"pIcZAzKQ9ZjG6kzxixkIiY3tSlAw0kkax7qTaAj4KA3t7feyKM3UyaYZuhQn8fPIau67AN"
"XRM6xLBro6SGD1zk8eQ7mqNsCUcZD1p5OqggqJnKhLHAvmvDqSEbgZ+X07ZDKhWWa4zpwJ"
"aBMyOkCru2KTDZ/FwE85qUEAcZmRyWuDnd5Mcfj9eAkif//OHo8OTtybvjs5N3tAp/lNmV"
"txXgFqNsOQar7KwpCOrBmBuMpXtseokeUs5pFVpQBd1NE1uYGtpUH3kRjN3K8PuM3AZD72"
"cLf4sj723oBwbyPEcQ0Vw+ZLNSiizzG+dhNV+4m3yhDrfdiY7VeZ50nqf25XnKERt1BOyq"
"hWU2ZmpKoqxSYR1APNLWHmeNKQRJ4/RxCpkyBjkL3iISOddxi3nkPi1+9gGTY9m22KTj88"
"hTnqCK064oTRQXeWTpFhiPfMtgpn98wHTaSJitvVRm4u5FF+PkX8iahcbSmTBmjoe0wdBD"
"gL3dM8s+FjjAj9hjgKnthyaItm9HDTMBrjUDnoybVtcscktYZG7R8EG7SrhWUboG3a5dFl"
"KLVLmlXMG8S5JI5FD0nZRyhSLRzVGGB2uo5jUThgkGJd7gUgALct8keqx5ZIaMeDLiZUKg"
"xJSCWCb+TWLJP8kYhdVn6HwDepLe8iStmZTdZFJ0HNaOxWE1HdSgfhRWYQ+wwI4W7RMuN6"
"TFu5R1Ykul7UwPmdjFwkNVyv3vGSFVvMObiMivOmR68aF+6x44XR+oHbbiPnbf985Bkn/v"
"ifRuu9c352CWMHL7x1Hr84Ca8BNvYV/wTsbYzddLeXMiL6umQaGIAbGUaVjm9FnyvNaZ9A"
"Zz7cVaXeQk2X/o3V1d370vaiCduOQcuNGJJU/ksXfXPwc+nSifyE901u9dnXPPArKeyGX3"
"7rJ3wy+Z7F3tOIfo9pcDqpaEnodi/FfppmwLG+yqD7Mb79/FAzjXSewyBZYf53JFu+TTOb"
"Boj0yfyG+93q/s5ytCz+z37f1d/2d2YULXurE9bUfnhD4y6GAUrdaVwXIZOZ2nNpenljnv"
"EnNMRg0qCCqybOuYOU0I6pg53bGlTG/y2Q2msudn5wU135vCpAbKV/0ousIQaRPfe+k55B"
"dn0BHQvEnRXhW7a9JKxn+dgSZ11Sd1ZfdT6W1UxS2nqSeT0KtzYlqrFmrVrudMXCnadi6h"
"yhjdNKbakaMdOco6clY8xmgrBxgps4vSC8kK5m1OVIektSFuuMr5UBUBvp7XYdteuQZO99"
"G7izVTqgk1zZR+ex0re5hPk1xV+iQOAV+VO6ijnLMqHA5SL2/1ebaBmN8o0pe/aDZrlTVn"
"DTYrBb6EApSV0rZt7kSddRIEFVpQBd1N60QcqNIsDgvGbnkqh7aqnJsavB4a0tljXHY077"
"IjuNCMIjBvehRHjEAE1kp6oLABTSy0gVjQtnATX4ymM+ulMzW3sBMmqOYWdrRjdeYynbms"
"zZnLPJY+KfTg2ucNM2bqw6wxtZBtNGlXDpgSUi8L3QJeL9dpdVN77pxg5LcbhD4myPfjwZ"
"byuHkYEcvI2+T8t0l7fsSO49as4FZYwVynSfErOVlVOKwNkCwlI35ZdEvEN7ibao3Bujl4"
"ZYM0c2KKmN6b4gRnM7HsNJAWVHGIHi4TJHFYHiRxWAiS+Laihxs5EdrEgdRITOrrj3o2CK"
"mhMx443thxpBafvJxGdDYmQ8wUniKYvzze35UMy7lIDsePhL7gZwubwR6wsR98aWyinGuj"
"gxDbASb+PrthQwopw6KaDs4zvznNlTVQDHfHJjKoJSPao1s+lHNiSo7k+lcn9fIwiAOzL3"
"q9u3MwQIg8kd+6d32jf2/0P7Bt/NTipbaqEXgt2blPR+CzwV+wiHppttCs0Eo5QrcwdmvO"
"EuqbjifKDIpMPIG2GLiZTJ7TjoT2Y+FW4lcBzlXv8vq2e/PmdO8o5+5JxuRJURF1AlGG2n"
"Ln5ExAkbly037JAI4EeJav/0l9vfivvvgPUWCuFjaRldQurna5uLRPeif6teiTjh0Dku7L"
"rJT2YKb9K2s6MXPh4K3Fb6EvMztG2pQ84hOBg4oT29PFe1WOuymtKHdeu5TXbh0nW8Vpsj"
"W6JzZ72Pbx2RK23nF+Lp3beqxIZ5JomHfjGEhAmNRXE8L692xSDAahRSeBIoiV4ZQZuQ0G"
"VDa2FtcZUGk7vnR86lxIo1ngKWRpCs1SlLIUA2hDYQ7XSt4sJbU2c9aukZpQZ4ene8dLc2"
"cUX+ixXGUrgSmQ1qCyR18PVqG8Bna2Q4jexcSrJUwXt6A3GW15k1Fk4cpqGikpnfFZwLJs"
"jyS4TKKlSliCWfneQpogDrzCbTtGSBMFqxAFSRidMfKc0JWMGBUKN2X/KgG0ZmBqZGDG2L"
"JEG8Ur16C5kLZ2M2jSuyGPQEEyjko802J6UdcEQoMEwsiBtiFLu2aEFAG26civCBPoCcnX"
"Sks3J7mbsUxSJm4KEYMfzCO2cBfCOReuMm6Vg5Qaq4UM3Np03EXT8T3T8TsL7Meo0t6yRm"
"RsOWhLUhkDR7uctcHT5IKiDZ42Q6rX9h1b28d8l1LZoj5O9jAtWs15xeVW8U7XZceV/uAQ"
"ewpsZwScIXgdwwAgaI6BPyUmiJqz9qLgcTCYAgsPh7SzABzR/vEDQFd5MMEsm9l+J9ebdb"
"f/RJ7Ip7vuxd980H24BmPoA+KAMfYDqr7sAd+hlbEP6H9MiN+VubM8i90YWTjwwRu0P9oH"
"kDYEfR+PyASRAIwcdsOh50zAdycHByBwwHdsR+o+uA6AT20GKgijJxlijz4Te3T+tjVrOx"
"d4VLobSKjvCHYBxarLVtWdaA/Qj0dHx8dvjw6Oz96dnrx9e/ruYLYZqFhUtSvo4vo9+1wz"
"n3UyIZYrQayTVjs4Oi24mdjyujtgd0PLfeS9IM94puU2skS7Qas+IpH0ShvrtqH0bvCrSu"
"dhDHAwlaYCc2JqOqKWIQOPy8nAY8EZPRwVOUM3I6QkkGcnSwB5dlIKJCvKAglN8aly5SjO"
"JdQ0cuvfBU5frex4rrL4oEErDuRaOay6iSwlc3U7C2NFRoi5yLY2hTZGGdS2/bNFhtktdx"
"aU2GW3iSdhgVnGPQ6aW218qalrmiw3K6QdT3U5mza/iss5m7QHvlYPPCamI2LxK33GcyEd"
"Ek3XAL53ScSRVm+DSIlpGCPV+UWYja0SxrSYhpEqBI4xQMaKY7IorCGlQ2yEDGfIlCskGJ"
"2lLFBe7BtNraQ9SJ2d8iBxW2TR7oJspb3lDBfZjQadB+T9wCVBEl0C4IRnNNgHX78m09jX"
"r8xXwz1CzK1D69q2D546Xe6aQdZTp+hSqrVlbVAtNKgq55TZzPy21KBiRdqgWsmgmgVmrb"
"gTRGHGuP49INoW0LZAe2DU+/NrAFHrr7ulvz7AKUJlemtUuFhfdVk9TbQrs0LrIOYGvZP0"
"bsQfIi/JJyepSZaIK0LYN61R6uVnt5afx3DAB3wcKVKyDuVqLV6Q/IzAkvzJDTtWi0WtQu"
"C7FDGQauIfPBDVhR4iwd/8OQ2CGcHxyGqLeJNaWtTr4sZincrXxVTHrTKfZySVJAcaCCeb"
"JHlZZWzamZA2xuj0hyaOjPc/qa+IMrFp7z83YyQ/77SMIrA2raNtkUJdDKYS9pk2JrQxoY"
"0JeWNiSszHAAalfNa8whImBK1r+LPKW+G1IkeI7CHBaSGtzYu0+RgiWbIrJ6bkLNrIvv2W"
"7A/bWnTQhreH8ZyxK++0LErrbLNbzjbLu2RIXzNadVbs03wDulvb0K2I7aKXMdKzUoosMp"
"sw1VukafYXc9Z9KcJamq3WftRWa5hJNNyy4XVJ/R2PrtOcr+Z8t7+QCM4bkSIq5yJqhj3U"
"n3CVrm6e8yJNpaXFdPK2DKJDG44YUiLdsXxoZqUU+eQbH5wrUec1MObb9tnWz5i31EOmGI"
"raQaYdZEqAiyeu4wVReK80Wy4UVhLYRsJEkwEnC2teTklEG/FCrDZM9fgsSbKUrDaygBYE"
"lcS0kRGqAw4661tJLaGBu8jDpjBFVVyyV0X6wnmd1jC9O5QFd810DeW87gvyfMkkiCkRRe"
"miJqZC9mnIaD1RdTUBPFxqfT6sWJ8PBeuzQwIk4tIrkiDORXQSxLzDtxVJEP/6P34G008="
)
+2
View File
@@ -23,3 +23,5 @@ nav:
- Home: index.md - Home: index.md
- Architecture: - Architecture:
- Authentication: authentication.md - Authentication: authentication.md
- Desktop Client (proposal): desktop-client.md
- Cowork UI plan: cowork-ui-plan.md
+10 -10
View File
@@ -41,7 +41,7 @@ class ConversationService {
conversation_id: string, conversation_id: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<QueryResponse> { ): Promise<QueryResponse> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/query`, `${this.conversationBaseUrl}/query`,
{ {
method: "POST", method: "POST",
@@ -58,7 +58,7 @@ class ConversationService {
} }
async getMessages(): Promise<Conversation> { async getMessages(): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.baseUrl}/messages`, `${this.baseUrl}/messages`,
{ {
method: "GET", method: "GET",
@@ -73,7 +73,7 @@ class ConversationService {
} }
async getConversation(conversationId: string): Promise<Conversation> { async getConversation(conversationId: string): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`, `${this.conversationBaseUrl}/${conversationId}`,
{ {
method: "GET", method: "GET",
@@ -88,7 +88,7 @@ class ConversationService {
} }
async createConversation(): Promise<Conversation> { async createConversation(): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/`, `${this.conversationBaseUrl}/`,
{ {
method: "POST", method: "POST",
@@ -106,7 +106,7 @@ class ConversationService {
conversationId: string, conversationId: string,
name: string, name: string,
): Promise<Conversation> { ): Promise<Conversation> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`, `${this.conversationBaseUrl}/${conversationId}`,
{ {
method: "PATCH", method: "PATCH",
@@ -122,7 +122,7 @@ class ConversationService {
} }
async deleteConversation(conversationId: string): Promise<void> { async deleteConversation(conversationId: string): Promise<void> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`, `${this.conversationBaseUrl}/${conversationId}`,
{ {
method: "DELETE", method: "DELETE",
@@ -145,7 +145,7 @@ class ConversationService {
if (params?.search) search.set("search", params.search); if (params?.search) search.set("search", params.search);
const qs = search.toString(); const qs = search.toString();
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`, `${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`,
{ {
method: "GET", method: "GET",
@@ -167,7 +167,7 @@ class ConversationService {
formData.append("file", file); formData.append("file", file);
formData.append("conversation_id", conversationId); formData.append("conversation_id", conversationId);
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/upload-image`, `${this.conversationBaseUrl}/upload-image`,
{ {
method: "POST", method: "POST",
@@ -185,7 +185,7 @@ class ConversationService {
} }
async getPresignedImageUrl(imageKey: string): Promise<string> { async getPresignedImageUrl(imageKey: string): Promise<string> {
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/image/${imageKey}`, `${this.conversationBaseUrl}/image/${imageKey}`,
); );
if (!response.ok) { if (!response.ok) {
@@ -207,7 +207,7 @@ class ConversationService {
body.image_key = imageKey; body.image_key = imageKey;
} }
const response = await userService.fetchWithRefreshToken( const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/stream-query`, `${this.conversationBaseUrl}/stream-query`,
{ {
method: "POST", method: "POST",
+5 -8
View File
@@ -24,14 +24,11 @@ class OIDCService {
* Initiate OIDC login flow * Initiate OIDC login flow
* Returns authorization URL to redirect user to * Returns authorization URL to redirect user to
*/ */
async initiateLogin(redirectAfterLogin: string = "/"): Promise<string> { async initiateLogin(): Promise<string> {
const response = await fetch( const response = await fetch(`${this.baseUrl}/login`, {
`${this.baseUrl}/login?redirect=${encodeURIComponent(redirectAfterLogin)}`, method: "GET",
{ headers: { "Content-Type": "application/json" },
method: "GET", });
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to initiate OIDC login"); throw new Error("Failed to initiate OIDC login");
@@ -29,13 +29,13 @@ class ScheduledMessageService {
private baseUrl = "/api/scheduled-messages"; private baseUrl = "/api/scheduled-messages";
async list(): Promise<ScheduledMessage[]> { async list(): Promise<ScheduledMessage[]> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`); const response = await userService.fetchWithAuth(`${this.baseUrl}/`);
if (!response.ok) throw new Error("Failed to list scheduled messages"); if (!response.ok) throw new Error("Failed to list scheduled messages");
return response.json(); return response.json();
} }
async create(data: CreateScheduledMessage): Promise<ScheduledMessage> { async create(data: CreateScheduledMessage): Promise<ScheduledMessage> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`, { const response = await userService.fetchWithAuth(`${this.baseUrl}/`, {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -47,7 +47,7 @@ class ScheduledMessageService {
} }
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> { async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, { const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -59,7 +59,7 @@ class ScheduledMessageService {
} }
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, { const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
method: "DELETE", method: "DELETE",
}); });
if (!response.ok) { if (!response.ok) {
+50 -55
View File
@@ -29,7 +29,22 @@ class UserService {
return await response.json(); return await response.json();
} }
private refreshInFlight: Promise<string> | null = null;
/**
* Exchange the refresh token for a new access token. The refresh token is
* only ever sent here. Concurrent callers share one in-flight request.
*/
async refreshToken(): Promise<string> { async refreshToken(): Promise<string> {
if (!this.refreshInFlight) {
this.refreshInFlight = this.doRefresh().finally(() => {
this.refreshInFlight = null;
});
}
return this.refreshInFlight;
}
private async doRefresh(): Promise<string> {
const refreshToken = localStorage.getItem("refresh_token"); const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) { if (!refreshToken) {
@@ -71,85 +86,65 @@ class UserService {
} }
} }
/**
* Authenticated fetch: sends the access token and, on a 401 (expired or
* missing access token), refreshes once and retries. With no stored access
* token it refreshes up front instead of making a doomed request.
*/
async fetchWithAuth( async fetchWithAuth(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<Response> {
const accessToken = localStorage.getItem("access_token");
// Add authorization header
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
let response = await fetch(url, { ...options, headers });
// If unauthorized, try refreshing the token
if (response.status === 401) {
try {
const newAccessToken = await this.refreshToken();
// Retry the request with new token
headers.Authorization = `Bearer ${newAccessToken}`;
response = await fetch(url, { ...options, headers });
} catch (error) {
// Refresh failed, redirect to login or throw error
throw new Error("Session expired. Please log in again.");
}
}
return response;
}
async fetchWithRefreshToken(
url: string,
options: RequestInit = {},
{ skipContentType = false }: { skipContentType?: boolean } = {}, { skipContentType = false }: { skipContentType?: boolean } = {},
): Promise<Response> { ): Promise<Response> {
const refreshToken = localStorage.getItem("refresh_token"); const buildHeaders = (token: string | null): Record<string, string> => ({
// Add authorization header
const headers: Record<string, string> = {
...(skipContentType ? {} : { "Content-Type": "application/json" }), ...(skipContentType ? {} : { "Content-Type": "application/json" }),
...((options.headers as Record<string, string>) || {}), ...((options.headers as Record<string, string>) || {}),
...(refreshToken ? { Authorization: `Bearer ${refreshToken}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; });
let response = await fetch(url, { ...options, headers }); const refreshOrExpire = async (): Promise<string> => {
// If unauthorized, try refreshing the token
if (response.status === 401) {
try { try {
const newAccessToken = await this.refreshToken(); return await this.refreshToken();
} catch {
// Retry the request with new token
headers.Authorization = `Bearer ${newAccessToken}`;
response = await fetch(url, { ...options, headers });
} catch (error) {
// Refresh failed, redirect to login or throw error
throw new Error("Session expired. Please log in again."); throw new Error("Session expired. Please log in again.");
} }
};
let accessToken = localStorage.getItem("access_token");
if (!accessToken) {
accessToken = await refreshOrExpire();
}
let response = await fetch(url, {
...options,
headers: buildHeaders(accessToken),
});
if (response.status === 401) {
accessToken = await refreshOrExpire();
response = await fetch(url, {
...options,
headers: buildHeaders(accessToken),
});
} }
return response; return response;
} }
async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> { async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> {
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/me`); const response = await this.fetchWithAuth(`${this.baseUrl}/me`);
if (!response.ok) throw new Error("Failed to fetch user profile"); if (!response.ok) throw new Error("Failed to fetch user profile");
return response.json(); return response.json();
} }
async adminListUsers(): Promise<AdminUserRecord[]> { async adminListUsers(): Promise<AdminUserRecord[]> {
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/admin/users`); const response = await this.fetchWithAuth(`${this.baseUrl}/admin/users`);
if (!response.ok) throw new Error("Failed to list users"); if (!response.ok) throw new Error("Failed to list users");
return response.json(); return response.json();
} }
async adminSetWhatsapp(userId: string, number: string): Promise<AdminUserRecord> { async adminSetWhatsapp(userId: string, number: string): Promise<AdminUserRecord> {
const response = await this.fetchWithRefreshToken( const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/whatsapp`, `${this.baseUrl}/admin/users/${userId}/whatsapp`,
{ method: "PUT", body: JSON.stringify({ whatsapp_number: number }) }, { method: "PUT", body: JSON.stringify({ whatsapp_number: number }) },
); );
@@ -162,7 +157,7 @@ class UserService {
} }
async adminUnlinkWhatsapp(userId: string): Promise<void> { async adminUnlinkWhatsapp(userId: string): Promise<void> {
const response = await this.fetchWithRefreshToken( const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/whatsapp`, `${this.baseUrl}/admin/users/${userId}/whatsapp`,
{ method: "DELETE" }, { method: "DELETE" },
); );
@@ -170,7 +165,7 @@ class UserService {
} }
async adminToggleEmail(userId: string): Promise<AdminUserRecord> { async adminToggleEmail(userId: string): Promise<AdminUserRecord> {
const response = await this.fetchWithRefreshToken( const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/email`, `${this.baseUrl}/admin/users/${userId}/email`,
{ method: "PUT" }, { method: "PUT" },
); );
@@ -179,7 +174,7 @@ class UserService {
} }
async adminDisableEmail(userId: string): Promise<void> { async adminDisableEmail(userId: string): Promise<void> {
const response = await this.fetchWithRefreshToken( const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/email`, `${this.baseUrl}/admin/users/${userId}/email`,
{ method: "DELETE" }, { method: "DELETE" },
); );
+119
View File
@@ -0,0 +1,119 @@
"""Tests for jwt_access_required / admin_required in blueprints/users/decorators.py."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from quart import Quart, jsonify
from quart_jwt_extended import (
JWTManager,
create_access_token,
create_refresh_token,
get_jwt_identity,
jwt_refresh_token_required,
)
from blueprints.users.decorators import admin_required, jwt_access_required
@pytest.fixture
def app():
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
JWTManager(app)
@app.route("/protected")
@jwt_access_required
async def protected():
return jsonify(user=get_jwt_identity())
@app.route("/admin")
@admin_required
async def admin():
return jsonify(ok=True)
@app.route("/refresh", methods=["POST"])
@jwt_refresh_token_required
async def refresh():
return jsonify(user=get_jwt_identity())
return app
async def _token(app, kind, identity="user-1", expires_delta=None):
async with app.app_context():
if kind == "access":
return create_access_token(identity=identity, expires_delta=expires_delta)
return create_refresh_token(identity=identity, expires_delta=expires_delta)
def _auth(token):
return {"Authorization": f"Bearer {token}"}
class TestJwtAccessRequired:
async def test_accepts_access_token(self, app):
token = await _token(app, "access")
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 200
assert (await resp.get_json())["user"] == "user-1"
async def test_transitionally_accepts_refresh_token(self, app):
token = await _token(app, "refresh")
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 200
assert (await resp.get_json())["user"] == "user-1"
async def test_missing_token_is_401(self, app):
resp = await app.test_client().get("/protected")
assert resp.status_code == 401
async def test_expired_access_token_is_401(self, app):
token = await _token(app, "access", expires_delta=timedelta(seconds=-10))
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 401
async def test_expired_refresh_token_is_401(self, app):
token = await _token(app, "refresh", expires_delta=timedelta(seconds=-10))
resp = await app.test_client().get("/protected", headers=_auth(token))
assert resp.status_code == 401
async def test_garbage_token_rejected(self, app):
resp = await app.test_client().get("/protected", headers=_auth("not.a.jwt"))
assert resp.status_code == 422
async def test_refresh_endpoint_still_rejects_access_token(self, app):
token = await _token(app, "access")
resp = await app.test_client().post("/refresh", headers=_auth(token))
assert resp.status_code == 422
class TestAdminRequired:
def _user(self, is_admin):
user = MagicMock()
user.is_admin.return_value = is_admin
return user
async def test_admin_with_access_token(self, app):
token = await _token(app, "access")
with patch(
"blueprints.users.decorators.User.get_or_none",
AsyncMock(return_value=self._user(True)),
):
resp = await app.test_client().get("/admin", headers=_auth(token))
assert resp.status_code == 200
async def test_non_admin_is_403(self, app):
token = await _token(app, "access")
with patch(
"blueprints.users.decorators.User.get_or_none",
AsyncMock(return_value=self._user(False)),
):
resp = await app.test_client().get("/admin", headers=_auth(token))
assert resp.status_code == 403
async def test_admin_without_token_is_401(self, app):
resp = await app.test_client().get("/admin")
assert resp.status_code == 401
+57
View File
@@ -0,0 +1,57 @@
"""Tests for the persistent OIDC state store in blueprints/users/oidc_state.py."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from tortoise import Tortoise
from blueprints.users.models import OIDCState
from blueprints.users.oidc_state import consume_oidc_state, save_oidc_state
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={"models": ["blueprints.users.models"]},
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
class TestOIDCStateStore:
async def test_roundtrip(self, db):
await save_oidc_state("state-1", "verifier-1")
assert await consume_oidc_state("state-1") == "verifier-1"
async def test_single_use(self, db):
await save_oidc_state("state-1", "verifier-1")
assert await consume_oidc_state("state-1") == "verifier-1"
assert await consume_oidc_state("state-1") is None
async def test_unknown_state(self, db):
assert await consume_oidc_state("nope") is None
async def test_expired_state_rejected_and_removed(self, db):
await save_oidc_state("state-1", "verifier-1", ttl=timedelta(seconds=-1))
assert await consume_oidc_state("state-1") is None
assert await OIDCState.filter(state="state-1").count() == 0
async def test_save_purges_expired_rows(self, db):
await OIDCState.create(
state="old",
pkce_verifier="v",
expires_at=datetime.now(timezone.utc) - timedelta(minutes=1),
)
await save_oidc_state("new", "verifier")
assert await OIDCState.filter(state="old").count() == 0
assert await OIDCState.filter(state="new").count() == 1
async def test_concurrent_consume_only_one_wins(self, db):
await save_oidc_state("state-1", "verifier-1")
results = await asyncio.gather(
consume_oidc_state("state-1"), consume_oidc_state("state-1")
)
assert sorted(results, key=str) == [None, "verifier-1"]