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:
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
Generated
+232
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4973
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.90.0"
|
||||||
|
profile = "minimal"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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 = (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user