Files
FabledScribe/src/scribe/app.py
T
bvandeusen 143b968c5d
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 26s
feat(design-systems): REST + MCP surfaces, at parity
Milestone #254 step 4 (#2290). Eleven capabilities, both surfaces, one service.

Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do not nest under /api/projects/ the way systems do —
routes/rulebooks.py was the closer shape. The one exception is the project
pointer, which is genuinely about a project: PUT /api/projects/<id>/design-system,
PUT rather than PATCH because clearing it is a first-class outcome and not an
omission.

`/resolved` and `/tokens` are deliberately separate endpoints. One answers "what
does this system CHANGE", the other "what does it end up BEING", and a system
that overrides nothing has an empty token list and a full resolved set. Shipping
only one would have made the other a client-side computation of exactly the kind
the record model exists to remove.

ResolvedToken.to_dict carries the SHADOWED contributions, not just the winner.
Dropping them at the serialisation boundary would have discarded the one thing
step 2 was built to preserve, and it would have been invisible — the payload
still looks complete.

Three sentinel translations on the MCP side, each tested, because an agent
cannot omit an argument and a wrong mapping here is silent:

  - parent_id: 0 = unchanged, -1 = clear (become a family system), positive =
    set. Renaming a system must not silently re-root it.
  - order_index: -1 = unchanged, since 0 is a valid position.
  - value_by_mode: guarded on `is not None`, not truthiness, so `{}` can strip
    every mode from a token instead of being unreachable.

DesignSystemCycle maps to 400 on REST and to a ValueError carrying the message
on MCP — kept apart from 404 throughout. An agent told "not found" retries the
same call; one told what the loop is can fix it.

Two structural guards beyond the parity list: every endpoint must be reachable
on the app (catching a decorator copied without its path, where the second
handler silently never runs), and every public coroutine in the tools module
must be registered (a tool written but never registered is invisible to an
agent, and nothing else would notice).
2026-07-30 17:09:25 -04:00

303 lines
12 KiB
Python

import logging
import time
import traceback as tb_module
from pathlib import Path
from quart import Quart, g, jsonify, make_response, request, send_from_directory
from scribe.config import Config
from scribe.routes.admin import admin_bp
from scribe.routes.api import api
from scribe.routes.auth import auth_bp
from scribe.routes.export import export_bp
from scribe.routes.notes import notes_bp
from scribe.routes.milestones import milestones_bp
from scribe.routes.task_logs import task_logs_bp
from scribe.routes.projects import projects_bp
from scribe.routes.settings import settings_bp
from scribe.routes.tasks import tasks_bp
from scribe.routes.groups import groups_bp
from scribe.routes.shares import shares_bp
from scribe.routes.in_app_notifications import notifications_bp
from scribe.routes.users import users_bp
from scribe.routes.api_keys import api_keys_bp
from scribe.routes.search import search_bp
from scribe.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
from scribe.routes.design import design_bp
from scribe.routes.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
from scribe.routes.snippets import snippets_bp
from scribe.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
def create_app() -> Quart:
# Configure logging
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO),
)
# Validate config early so misconfigurations surface immediately with clear messages
try:
Config.validate()
except ValueError as exc:
logging.getLogger(__name__).error("Invalid configuration:\n%s", exc)
raise
app = Quart(__name__, static_folder=None)
app.secret_key = Config.SECRET_KEY
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = Config.SECURE_COOKIES
if Config.SECRET_KEY == "dev-secret-change-me":
logger.warning(
"SECRET_KEY is set to the default value — session cookies are insecure. "
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
)
if not Config.TRUST_PROXY_HEADERS:
logger.warning(
"TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy "
"(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses "
"real client IPs rather than the proxy IP."
)
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(export_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(task_logs_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(shares_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(search_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(plugin_bp)
app.register_blueprint(design_bp)
app.register_blueprint(design_systems_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
app.register_blueprint(snippets_bp)
@app.before_request
async def before_request():
g.request_start = time.monotonic()
@app.after_request
async def after_request(response):
duration = time.monotonic() - getattr(g, "request_start", time.monotonic())
duration_ms = round(duration * 1000, 1)
# Downgrade noisy high-frequency / static paths to DEBUG
_quiet = (
request.path in {"/api/health", "/api/chat/status"}
or request.path.startswith(("/static/", "/assets/", "/sw.js", "/manifest.json"))
)
log_fn = logger.debug if _quiet else logger.info
log_fn(
"%s %s %s %.1fms",
request.method,
request.path,
response.status_code,
duration_ms,
)
# Log usage for API requests (skip logs endpoint to avoid recursion)
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
try:
from scribe.services.logging import log_usage
user = getattr(g, "user", None)
await log_usage(
user_id=user.id if user else None,
username=user.username if user else None,
endpoint=request.path,
method=request.method,
status_code=response.status_code,
duration_ms=duration_ms,
)
except Exception:
logger.debug("Failed to log usage", exc_info=True)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"font-src 'self' data: https://fonts.gstatic.com; "
"object-src 'none'; "
"base-uri 'self'; worker-src 'self' blob:;"
)
return response
@app.before_serving
async def startup():
import asyncio
from scribe.services.auth import start_auth_token_retention_loop
from scribe.services.embeddings import backfill_note_embeddings
from scribe.services.logging import start_log_retention_loop
from scribe.services.notifications import start_notification_loop
start_log_retention_loop()
start_notification_loop()
start_auth_token_retention_loop()
# Backfill embeddings for any notes that don't have one. Runs in the
# background so it never blocks the server from accepting requests.
async def _delayed_backfill() -> None:
try:
await backfill_note_embeddings()
except Exception:
logger.warning("Embedding backfill failed", exc_info=True)
# Snippets written before migration 0070 have no `notes.data` mirror,
# and the location reverse lookup queries that column — an unfilled
# row would read as "no snippet here" rather than as a gap. Separate
# try block so neither backfill can skip the other.
try:
from scribe.services.snippets import backfill_snippet_data
await backfill_snippet_data()
except Exception:
logger.warning("Snippet data backfill failed", exc_info=True)
asyncio.create_task(_delayed_backfill())
# Recurrence scheduler (recurring-task spawn every 15m)
from scribe.services.recurrence_scheduler import start_recurrence_scheduler
start_recurrence_scheduler(asyncio.get_running_loop())
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
from scribe.services.version_pinning_scheduler import (
start_version_pinning_scheduler,
)
start_version_pinning_scheduler(asyncio.get_running_loop())
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
from scribe.services.trash_scheduler import start_trash_scheduler
start_trash_scheduler(asyncio.get_running_loop())
# DB maintenance scheduler (daily targeted VACUUM ANALYZE, default 04:00 UTC)
from scribe.services.db_maintenance_scheduler import (
get_maintenance_hour,
start_db_maintenance_scheduler,
)
start_db_maintenance_scheduler(
asyncio.get_running_loop(), await get_maintenance_hour()
)
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
# exception hook. Cheap (~1 log line/min), high diagnostic value when
# the app crashes mysteriously. See services/diagnostics.py.
from scribe.services.diagnostics import start_diagnostics
start_diagnostics(asyncio.get_running_loop())
@app.after_serving
async def shutdown():
from scribe.services.recurrence_scheduler import stop_recurrence_scheduler
stop_recurrence_scheduler()
from scribe.services.version_pinning_scheduler import (
stop_version_pinning_scheduler,
)
stop_version_pinning_scheduler()
from scribe.services.trash_scheduler import stop_trash_scheduler
stop_trash_scheduler()
from scribe.services.db_maintenance_scheduler import (
stop_db_maintenance_scheduler,
)
stop_db_maintenance_scheduler()
from scribe.services.diagnostics import stop_diagnostics
stop_diagnostics()
@app.route("/")
async def serve_index():
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
# File extensions that belong to the SPA or its assets.
# Anything else with an extension is not a valid app path and gets a hard 404.
_SPA_EXTENSIONS = {
"", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg",
".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json",
}
@app.errorhandler(404)
async def handle_404(error):
# Return JSON 404 for API routes
if request.path.startswith("/api/"):
return jsonify({"error": "Not found"}), 404
# Try to serve a real static file first
path = request.path.lstrip("/")
file_path = STATIC_DIR / path
if path and file_path.is_file():
return await send_from_directory(STATIC_DIR, path)
# Reject paths with file extensions the app doesn't serve.
# This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s
# instead of serving them the Vue SPA with a misleading 200.
from pathlib import PurePosixPath
suffix = PurePosixPath(request.path).suffix.lower()
if suffix not in _SPA_EXTENSIONS:
return "", 404
# SPA fallback for clean client-side routes (/notes/123, /chat, etc.)
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.errorhandler(500)
async def handle_500(error):
logger.exception("Internal server error on %s %s", request.method, request.path)
try:
from scribe.services.logging import log_error
user = getattr(g, "user", None)
await log_error(
user_id=user.id if user else None,
username=user.username if user else None,
endpoint=request.path,
method=request.method,
error_type=type(error).__name__,
error_message=str(error),
traceback=tb_module.format_exc(),
)
except Exception:
logger.debug("Failed to log error", exc_info=True)
if request.path.startswith("/api/"):
return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500
mount_mcp(app)
return app