rgboards/rg-boards

Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.

A remix template · 0 remixes · Remix rgboards/rg-boards

app/boards/main.py

"""Wires the layers together: one FastAPI app with the web routes, /mcp, and the sign-in
metadata Claude.ai and ChatGPT use to find where a person signs in (RFC 9728)."""
import json
import logging
import os
import threading
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from mcp.server.transport_security import TransportSecuritySettings
from starlette.routing import Route

from boards.application.use_cases import Boards
from boards.doors.identity import person_from
from boards.doors.mcp import build_mcp
from boards.doors.web import build_router
from boards.infrastructure.dodil import ModelsAssistant
from boards.infrastructure.memory import MemoryBoards

log = logging.getLogger("boards")

APPID = os.environ.get("APPID_PUBLIC_URL", "https://appid.dodil.io").rstrip("/")
POOL = os.environ.get("DODIL_USER_POOL", "")  # "<org>/<pool>", set by .dodil/deploy.yaml
METADATA = "/.well-known/oauth-protected-resource"


def _safe(fn):
    def run():
        t = time.time()
        try:
            fn()
            log.info("start-up work done in %.1fs", time.time() - t)
        except Exception:  # never crash-loop on a migration; POST / self-check shows it
            log.exception("start-up work failed")
    return run


def create_app(boards: Boards, on_start=None) -> FastAPI:
    gate = {"mcp": None}

    @asynccontextmanager
    async def lifespan(_app):
        if on_start:
            # Off the request path: the pod answers /healthz (and the gateway routes traffic)
            # the moment uvicorn is up, instead of after a token fetch, a TLS handshake and a
            # dozen information_schema round trips. The remix already created every table from
            # schema.sql, so this only catches an existing bucket up on new columns.
            threading.Thread(target=_safe(on_start), name="startup", daemon=True).start()
        mcp_app = build_mcp(boards).streamable_http_app(
            streamable_http_path="/mcp", stateless_http=True, json_response=True,
            # Every call carries identity only the gateway can mint, so host checks add nothing.
            transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))
        gate["mcp"] = mcp_app
        async with mcp_app.router.lifespan_context(mcp_app):
            yield

    app = FastAPI(title="Lanes", lifespan=lifespan)

    def base(request: Request) -> str:
        h = request.headers
        return f"{h.get('x-forwarded-proto', 'https')}://{h.get('x-forwarded-host', h.get('host'))}"

    @app.get("/healthz", include_in_schema=False)
    def healthz():
        return {"ok": True}

    @app.post("/", include_in_schema=False)
    def self_check():
        """`dodil ignite invoke` hits POST /: open the bucket with the app's OWN credential and
        report a row count per table (healthz deliberately makes no database round trip)."""
        check = getattr(boards.repo, "self_check", None)
        if check is None:
            return {"ok": True, "store": "memory"}
        try:
            return {"ok": True, "tables": check()}
        except Exception as e:
            return JSONResponse({"ok": False, "error": type(e).__name__}, status_code=500)

    @app.get(METADATA, include_in_schema=False)
    @app.get(METADATA + "/mcp", include_in_schema=False)
    def metadata(request: Request):
        return {"resource": base(request) + "/mcp", "authorization_servers": [f"{APPID}/{POOL}"],
                "bearer_methods_supported": ["header"], "resource_name": "boards"}

    class McpDoor:
        """No person → 401 with the metadata link, which is what starts Claude's sign-in.
        (A class, so Starlette hands it the raw ASGI call instead of a Request.)"""

        async def __call__(self, scope, receive, send):
            headers = {k.decode(): v.decode() for k, v in scope.get("headers", [])}
            if person_from(headers) is None:
                body = json.dumps({"detail": "sign in first"}).encode()
                link = f'Bearer resource_metadata="{base(Request(scope))}{METADATA}/mcp"'
                await send({"type": "http.response.start", "status": 401, "headers": [
                    (b"content-type", b"application/json"), (b"www-authenticate", link.encode())]})
                await send({"type": "http.response.body", "body": body})
                return
            await gate["mcp"]({**scope, "path": "/mcp"}, receive, send)

    mcp_door = McpDoor()

    for path in ("/mcp", "/mcp/"):
        app.router.routes.append(Route(path, endpoint=mcp_door, methods=["GET", "POST", "DELETE"]))
    app.include_router(build_router(boards))
    return app


def _production() -> FastAPI:
    if os.environ.get("BOARDS_STORE") == "memory":  # local demo, no Dodil account needed
        return create_app(Boards(MemoryBoards(), None))
    from boards.infrastructure.dodil import connect
    from boards.infrastructure.migrations import migrate
    from boards.infrastructure.repository import SqlBoards

    repo = SqlBoards()

    def on_start():
        repo.warm(1)                      # token + first connection, ahead of the first visitor
        with connect() as conn:
            migrate(conn)
        repo.warm(3)

    return create_app(Boards(repo, ModelsAssistant()), on_start)


app = _production()