feat: base-path support (ROOT_PATH) for serving under /portal
build-image / test (push) Successful in 33s
build-image / build (push) Successful in 29s

Links, form actions, and redirects are prefixed with the gateway path prefix so
the portal can be served at intervalsicu.farhoodlabs.com/portal (gateway strips
/portal; FastAPI root_path generates correct URLs). Connector URL is configurable.
This commit is contained in:
2026-07-04 21:25:12 -04:00
parent 2e09009e11
commit 3908251f6e
8 changed files with 33 additions and 20 deletions
BIN
View File
Binary file not shown.
+18 -11
View File
@@ -43,18 +43,25 @@ def create_app(config: Config | None = None) -> FastAPI:
client_kwargs={"scope": cfg.oidc_scopes},
)
app = FastAPI(title="Intervals.icu MCP portal")
app = FastAPI(title="Intervals.icu MCP portal", root_path=cfg.root_path)
app.add_middleware(SessionMiddleware, secret_key=cfg.session_secret, https_only=True, same_site="lax")
app.state.cfg = cfg
app.state.oauth = oauth
def render(request, name, **ctx):
return _TEMPLATES.TemplateResponse(request, name, {"user": current_user(request), **ctx})
return _TEMPLATES.TemplateResponse(
request,
name,
{"user": current_user(request), "base": cfg.root_path, "mcp_url": cfg.mcp_url, **ctx},
)
def redirect(path: str, status_code: int = 307):
return RedirectResponse(f"{cfg.root_path}{path}", status_code=status_code)
# ----- auth ----------------------------------------------------------- #
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, user: dict | None = Depends(current_user)):
return RedirectResponse("/account" if user else "/login")
return redirect("/account" if user else "/login")
@app.get("/login", response_class=HTMLResponse)
async def login(request: Request):
@@ -70,7 +77,7 @@ def create_app(config: Config | None = None) -> FastAPI:
claims = dict(token.get("userinfo") or {})
sub = claims.get("sub")
if not sub:
return RedirectResponse("/login")
return redirect("/login")
email = claims.get("email", "")
name = claims.get("name")
groups = claims.get("groups") or []
@@ -82,18 +89,18 @@ def create_app(config: Config | None = None) -> FastAPI:
"name": name,
"is_admin": cfg.admin_group in groups,
}
return RedirectResponse("/account")
return redirect("/account")
@app.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login")
return redirect("/login")
# ----- account -------------------------------------------------------- #
@app.get("/account", response_class=HTMLResponse)
async def account(request: Request, user: dict | None = Depends(current_user)):
if not user:
return RedirectResponse("/login")
return redirect("/login")
async with db.sessionmaker()() as session:
record = await db.get_user(session, user["sub"])
return render(request, "account.html", record=record)
@@ -106,7 +113,7 @@ def create_app(config: Config | None = None) -> FastAPI:
user: dict | None = Depends(current_user),
):
if not user:
return RedirectResponse("/login")
return redirect("/login")
ok, message = await validate_credentials(cfg.intervals_api_base, athlete_id, api_key)
async with db.sessionmaker()() as session:
if ok:
@@ -124,7 +131,7 @@ def create_app(config: Config | None = None) -> FastAPI:
@app.get("/admin", response_class=HTMLResponse)
async def admin(request: Request, admin_user: dict | None = Depends(require_admin)):
if not admin_user:
return RedirectResponse("/account")
return redirect("/account")
async with db.sessionmaker()() as session:
users = await db.list_users(session)
return render(request, "admin.html", users=users)
@@ -135,7 +142,7 @@ def create_app(config: Config | None = None) -> FastAPI:
admin_user: dict | None = Depends(require_admin),
):
if not admin_user:
return RedirectResponse("/account")
return redirect("/account")
async with db.sessionmaker()() as session:
if action == "enable":
await db.set_enabled(session, sub, True)
@@ -143,7 +150,7 @@ def create_app(config: Config | None = None) -> FastAPI:
await db.set_enabled(session, sub, False)
elif action == "delete":
await db.delete_user(session, sub)
return RedirectResponse("/admin", status_code=303)
return redirect("/admin", status_code=303)
@app.get("/healthz")
async def healthz():
+4
View File
@@ -17,6 +17,8 @@ class Config:
admin_group: str
intervals_api_base: str
oidc_scopes: str
root_path: str # public path prefix when served behind a gateway (e.g. "/portal")
mcp_url: str # the MCP connector URL to show users
def load_config() -> Config:
@@ -30,4 +32,6 @@ def load_config() -> Config:
admin_group=os.environ.get("ADMIN_GROUP", "intervalsicu-mcp-admins"),
intervals_api_base=os.environ.get("INTERVALS_API_BASE_URL", "https://intervals.icu/api/v1"),
oidc_scopes=os.environ.get("OIDC_SCOPES", "openid email profile groups"),
root_path=os.environ.get("ROOT_PATH", "").rstrip("/"),
mcp_url=os.environ.get("MCP_URL", "https://intervalsicu.farhoodlabs.com/mcp"),
)
@@ -20,7 +20,7 @@
</p>
<p class="muted">Find these in Intervals.icu → Settings → Developer. Your athlete ID looks like <code>i123456</code>.</p>
<form method="post" action="/account">
<form method="post" action="{{ base }}/account">
<label for="athlete_id">Athlete ID</label>
<input type="text" id="athlete_id" name="athlete_id" value="{{ record.athlete_id or '' if record else '' }}" placeholder="i123456" required>
<label for="api_key">API key</label>
@@ -30,5 +30,5 @@
<h2>Connect Claude</h2>
<p>Add a custom connector in Claude pointing at:</p>
<p><code>https://intervalsicu-mcp.farhoodlabs.com/mcp</code></p>
<p><code>{{ mcp_url }}</code></p>
{% endblock %}
+3 -3
View File
@@ -15,11 +15,11 @@
<td class="muted">{{ u.last_login_at.strftime('%Y-%m-%d') if u.last_login_at else '—' }}</td>
<td>
{% if u.enabled %}
<form class="inline" method="post" action="/admin/{{ u.sub }}/disable"><button class="secondary" type="submit">Disable</button></form>
<form class="inline" method="post" action="{{ base }}/admin/{{ u.sub }}/disable"><button class="secondary" type="submit">Disable</button></form>
{% else %}
<form class="inline" method="post" action="/admin/{{ u.sub }}/enable"><button type="submit">Approve</button></form>
<form class="inline" method="post" action="{{ base }}/admin/{{ u.sub }}/enable"><button type="submit">Approve</button></form>
{% endif %}
<form class="inline" method="post" action="/admin/{{ u.sub }}/delete" onsubmit="return confirm('Delete {{ u.email }} and their stored credentials?');"><button class="secondary" type="submit">Delete</button></form>
<form class="inline" method="post" action="{{ base }}/admin/{{ u.sub }}/delete" onsubmit="return confirm('Delete {{ u.email }} and their stored credentials?');"><button class="secondary" type="submit">Delete</button></form>
</td>
</tr>
{% endfor %}
+3 -3
View File
@@ -34,9 +34,9 @@
<strong>Intervals.icu&nbsp;MCP</strong>
{% if user %}
<nav>
<a href="/account">My account</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<a href="/logout">Sign out</a>
<a href="{{ base }}/account">My account</a>
{% if user.is_admin %}<a href="{{ base }}/admin">Admin</a>{% endif %}
<a href="{{ base }}/logout">Sign out</a>
</nav>
{% endif %}
</header>
+1 -1
View File
@@ -2,5 +2,5 @@
{% block content %}
<h1>Intervals.icu MCP portal</h1>
<p>Sign in to connect your Intervals.icu account to the MCP server and manage your API credentials.</p>
<p><a href="/auth/login"><button>Sign in</button></a></p>
<p><a href="{{ base }}/auth/login"><button>Sign in</button></a></p>
{% endblock %}
+2
View File
@@ -41,6 +41,8 @@ def app_url(tmp_path, monkeypatch):
admin_group="intervalsicu-mcp-admins",
intervals_api_base="https://intervals.icu/api/v1",
oidc_scopes="openid email profile groups",
root_path="",
mcp_url="https://intervalsicu.farhoodlabs.com/mcp",
)
return appmod.create_app(cfg), url