Files
FabledScribe/src/scribe/routes/settings.py
T
bvandeusenandClaude Fable 5 64c641ce80
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
refactor(routes): one supersession seam for REST and MCP; PUT/PATCH notes share a handler; shared mask/not-found/caller helpers (#2829, milestone 296 area 5)
Reading the 28 route modules against each other and against the MCP tools:
- routes/notes.py carried a PUT and a PATCH handler that were the same
  function minus the supersedes contract on one of them — one handler now
  serves both verbs, so both carry it.
- The two _attach_supersession copies (REST + MCP) become
  supersession_svc.attach_relations(uid, note_id, data, hint=) — the seam
  the two surfaces must agree through; only the agent surface adds the
  one-sentence reading hint.
- Three local _uid() wrappers over g.user.id → scribe.auth.get_current_user_id
  like every other module; design_systems' private _not_found → routes.utils.
  not_found; the four "********" literals → settings_svc.SECRET_MASK with the
  read/write contract written once.
- routes/plugin.py: the project_id/repo resolution block and the
  comma-separated id parse were copied into three endpoints — _project_scope()
  and _int_list() now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:23:47 -04:00

178 lines
6.3 KiB
Python

"""User settings + integrations (SearXNG status).
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
hooks were removed in Phase 8 alongside the chat/journal subsystems.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.config import Config
from scribe.services.settings import (
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
)
logger = logging.getLogger(__name__)
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# Keys whose values are credentials. The admin endpoints that own them mask on
# read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw.
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
def _masked(settings: dict) -> dict:
return {
k: (SECRET_MASK if k in _SECRET_KEYS and v else v)
for k, v in settings.items()
}
@settings_bp.route("", methods=["GET"])
@login_required
async def get_settings_route():
uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(_masked(settings))
@settings_bp.route("", methods=["PUT"])
@login_required
async def update_settings_route():
uid = get_current_user_id()
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
to_save = {}
for k, v in data.items():
str_v = str(v)
# A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == SECRET_MASK:
continue
if not str_v:
await delete_setting(uid, k)
else:
to_save[k] = str_v
if to_save:
await set_settings_batch(uid, to_save)
settings = await get_all_settings(uid)
return jsonify(_masked(settings))
@settings_bp.route("/search", methods=["GET"])
@login_required
async def test_search():
"""Report SearXNG configuration status (used by the Integrations tab)."""
if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
# another user's keyring. Tokens never leave the server: the model's to_dict
# omits them, and the routes never echo the submitted value back.
@settings_bp.route("/forge-connections", methods=["GET"])
@login_required
async def list_forge_connections_route():
from scribe.services.forge import FORGE_KINDS
from scribe.services.forge_connections import list_connections
uid = get_current_user_id()
rows = await list_connections(uid)
return jsonify({
"connections": [r.to_dict() for r in rows],
"kinds": list(FORGE_KINDS),
})
@settings_bp.route("/forge-connections", methods=["POST"])
@login_required
async def create_forge_connection_route():
from scribe.services.forge_connections import create_connection
uid = get_current_user_id()
data = await request.get_json() or {}
try:
row = await create_connection(
uid,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=str(data.get("token", "")),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(row.to_dict()), 201
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["PUT"])
@login_required
async def update_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import update_connection
uid = get_current_user_id()
data = await request.get_json() or {}
token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection.
if token == SECRET_MASK:
token = ""
try:
row = await update_connection(
uid, connection_id,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=token,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if row is None:
return jsonify({"error": "Connection not found"}), 404
return jsonify(row.to_dict())
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["DELETE"])
@login_required
async def delete_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import delete_connection
uid = get_current_user_id()
if not await delete_connection(uid, connection_id):
return jsonify({"error": "Connection not found"}), 404
return "", 204
@settings_bp.route("/forge-connections/<int:connection_id>/test", methods=["POST"])
@login_required
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied per keyring row)."""
from scribe.services.forge import ForgeError, build_adapter
from scribe.services.forge_connections import get_connection
uid = get_current_user_id()
row = await get_connection(uid, connection_id)
if row is None:
return jsonify({"error": "Connection not found"}), 404
adapter = build_adapter(row.kind, row.base_url, row.token)
if adapter is None:
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
try:
return jsonify(await adapter.check())
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502