Files
roboco/panel/src/proxy.ts
T
c40a7a39c3 feat: Telegram V3 — Mini App cockpit (initData auth + /tg surface) (#554)
* feat(telegram): Mini App auth — initData validation mints the cloud-auth session cookie

* feat(panel): /tg Mini App cockpit — approvals, inbox, read-only board, A2A chat

* fix(telegram,panel): unconditional webapp-auth rate limit, future-dated initData rejection, anchored /tg matcher

* docs(map,rag): Telegram Mini App auth route, initData validator, (tg) surface

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-18 02:47:59 +02:00

72 lines
3.0 KiB
TypeScript

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Server-side only (no NEXT_PUBLIC_ prefix — never reaches the browser
// bundle). Runs inside the panel container and reaches the orchestrator over
// the docker-internal network (roboco_default), not through nginx — a request
// back out through the panel's own public origin would be a container calling
// itself over the internet. Defaults to the compose service name.
const INTERNAL_API_URL =
process.env.INTERNAL_API_URL || "http://roboco-orchestrator:8000/api";
// Must match roboco.api.auth.backend.SESSION_COOKIE_NAME.
const SESSION_COOKIE_NAME = "roboco_session";
// The probe must never block navigation: a slow/unreachable backend fails
// open to "cloud auth off" (the safe default — off is what every deploy
// starts on), not a stuck redirect.
const STATUS_PROBE_TIMEOUT_MS = 1500;
const PROBE_TTL_MS = 30_000;
let lastKnown: { value: boolean; at: number } | null = null;
export async function isCloudAuthEnabled(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS);
const res = await fetch(`${INTERNAL_API_URL}/auth/status`, {
signal: controller.signal,
cache: "no-store",
});
clearTimeout(timer);
if (res.ok) {
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
const value = data.cloud_auth_enabled === true;
lastKnown = { value, at: Date.now() };
return value;
}
// Non-ok response: fall back to a fresh cache rather than fail open.
} catch {
// Network/timeout: fall back to a fresh cache rather than fail open.
}
if (lastKnown && Date.now() - lastKnown.at < PROBE_TTL_MS) {
return lastKnown.value;
}
// No fresh cache: the safe default is "off" (what every deploy starts on).
return false;
}
export async function proxy(request: NextRequest) {
if (!(await isCloudAuthEnabled())) {
return NextResponse.next();
}
// UX redirect only — shields dashboard chrome from flashing before login. The API (/api/*) enforces auth independently; a stale cookie shows chrome then 401s on the first API call.
if (!request.cookies.has(SESSION_COOKIE_NAME)) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
// Everything except the login page itself (avoids a redirect loop), API
// routes (nginx routes /api/* straight to the orchestrator in prod — this
// never sees them there; excluded defensively for a bare `next start`),
// the Telegram Mini App surface (/tg authenticates via Telegram initData,
// not the password-login cookie — redirecting it to /login would strand a
// phone session that can never reach that page), Next's internal asset
// paths, and the static icon files at the app root.
matcher: [
"/((?!login|api|tg(?:/|$)|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
],
};