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
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>
178 lines
6.3 KiB
Python
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
|