diff --git a/cueweb/.env.example b/cueweb/.env.example index b4ff813e0..3ed8512f1 100644 --- a/cueweb/.env.example +++ b/cueweb/.env.example @@ -1,5 +1,20 @@ NEXT_PUBLIC_OPENCUE_ENDPOINT=http://your-rest-gateway-url.com +# Cuebot Facility switching (CueGUI "Cuebot Facility" menu parity). +# NEXT_PUBLIC_CUEBOT_FACILITIES lists the facilities shown in the header menu +# (comma-separated). Defaults to "local,dev,cloud,external" when unset. +# NEXT_PUBLIC_CUEBOT_FACILITIES=local,dev,cloud,external +# +# Each facility may point at its own REST gateway + JWT secret via paired, +# server-only vars named CUEBOT__REST_GATEWAY_URL and +# CUEBOT__JWT_SECRET (NAME uppercased). A facility with no override falls +# back to NEXT_PUBLIC_OPENCUE_ENDPOINT / NEXT_JWT_SECRET, so the default +# deployment works with only the "local" facility wired up. +# CUEBOT_DEV_REST_GATEWAY_URL=http://dev-rest-gateway:8448 +# CUEBOT_DEV_JWT_SECRET=dev-gateway-jwt-secret +# CUEBOT_CLOUD_REST_GATEWAY_URL=https://cloud-rest-gateway.example.com +# CUEBOT_CLOUD_JWT_SECRET=cloud-gateway-jwt-secret + # Sentry values SENTRY_ENVIRONMENT='development' SENTRY_DSN = sentrydsn diff --git a/cueweb/app/api/health/route.ts b/cueweb/app/api/health/route.ts index d2aec1867..1421fe339 100644 --- a/cueweb/app/api/health/route.ts +++ b/cueweb/app/api/health/route.ts @@ -17,6 +17,7 @@ import { NextResponse } from "next/server"; import { createJwtToken } from "@/app/utils/api_utils"; +import { getRequestFacilityTarget } from "@/lib/facility"; interface JwtParams { sub: string; @@ -49,7 +50,9 @@ interface HealthBody { } export async function GET(): Promise> { - const gateway = process.env.NEXT_PUBLIC_OPENCUE_ENDPOINT; + // Probe the gateway for the facility selected in the request cookie, so the + // status bar reflects the facility the rest of the app is talking to. + const { gatewayUrl: gateway, jwtSecret } = await getRequestFacilityTarget(); const checkedAt = new Date().toISOString(); if (!gateway) { @@ -59,7 +62,7 @@ export async function GET(): Promise> { status: 0, latencyMs: 0, checkedAt, - error: "NEXT_PUBLIC_OPENCUE_ENDPOINT is not configured", + error: "No REST gateway configured for the selected facility", }, { status: 200 }, ); @@ -74,7 +77,7 @@ export async function GET(): Promise> { let token: string; try { - token = createJwtToken(jwtParams); + token = createJwtToken(jwtParams, jwtSecret); } catch (err) { console.error("Health JWT signing failed", err); return NextResponse.json( diff --git a/cueweb/app/utils/api_utils.ts b/cueweb/app/utils/api_utils.ts index a35c4bf94..d28559fd9 100644 --- a/cueweb/app/utils/api_utils.ts +++ b/cueweb/app/utils/api_utils.ts @@ -17,6 +17,7 @@ import jwt from "jsonwebtoken"; import { NextResponse } from "next/server"; import { handleError } from "./notify_utils"; +import { getRequestFacilityTarget } from "@/lib/facility"; /************************************************************/ // Utility functions for accessing the Api including: @@ -39,16 +40,27 @@ export async function fetchObjectFromRestGateway( method: string, body: string ): Promise { - const NEXT_PUBLIC_OPENCUE_ENDPOINT = process.env.NEXT_PUBLIC_OPENCUE_ENDPOINT; - const url = `${NEXT_PUBLIC_OPENCUE_ENDPOINT}${endpoint}`; - + // Route to the gateway for the facility selected in the request cookie + // (Cuebot Facility menu). Falls back to the default/legacy gateway when + // no per-facility config is present. + const { gatewayUrl, jwtSecret } = await getRequestFacilityTarget(); + if (!gatewayUrl) { + // Misconfigured facility (no gateway URL and no default): fail with a + // clear, diagnosable error instead of a generic fetch failure. + return NextResponse.json( + { error: "No REST gateway configured for the selected facility" }, + { status: 503 }, + ); + } + const url = `${gatewayUrl}${endpoint}`; + const jwtParams: JwtParams = { sub: "user-id", // Replace with a user id role: "user-role", // Replace with the user's role iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour }; - const jwtToken = createJwtToken(jwtParams); + const jwtToken = createJwtToken(jwtParams, jwtSecret); try { const response = await fetch(url, { @@ -73,11 +85,19 @@ export async function fetchObjectFromRestGateway( } } -// Create the JWT token given the payload parameters -export function createJwtToken({ sub, role, iat, exp }: JwtParams): string { - const NEXT_JWT_SECRET = process.env.NEXT_JWT_SECRET; +// Create the JWT token given the payload parameters. The signing secret +// defaults to NEXT_JWT_SECRET but can be overridden per facility (the target +// gateway trusts its own secret). +export function createJwtToken({ sub, role, iat, exp }: JwtParams, secret?: string): string { + const signingSecret = secret ?? process.env.NEXT_JWT_SECRET; + // Fail fast on a missing/blank secret rather than signing with an empty key. + // Validate via trim() but sign with the original value (a gateway reading the + // same env verbatim would not trim it). + if (!signingSecret || signingSecret.trim() === "") { + throw new Error("Missing JWT signing secret"); + } const payload = { sub, role, iat, exp }; - return jwt.sign(payload, NEXT_JWT_SECRET as string); + return jwt.sign(payload, signingSecret); } diff --git a/cueweb/app/utils/use_cuebot_facility.ts b/cueweb/app/utils/use_cuebot_facility.ts index 27fc90d5e..fa23f2818 100644 --- a/cueweb/app/utils/use_cuebot_facility.ts +++ b/cueweb/app/utils/use_cuebot_facility.ts @@ -29,15 +29,46 @@ import * as React from "react"; * across components via a CustomEvent (same tab) + the browser `storage` * event (cross-tab). * - * NOTE: this hook persists and broadcasts the selection. Actually routing - * REST-gateway calls per-facility is a separate task. Until that lands, the value is informational. + * The selection is ALSO written to a cookie (`cueweb.facility`) so server-side + * API routes can resolve the per-facility REST gateway for each request (see + * `lib/facility.ts`). Selecting a facility reloads the page so every view + * re-fetches against the newly selected gateway — mirroring CueGUI, which + * clears and re-fetches all data on a facility switch. */ export const STORAGE_KEY = "cueweb.facility.selected"; const CHANGE_EVENT = "cueweb:facility-changed"; +// Cookie read server-side by lib/facility.ts (FACILITY_COOKIE). Keep in sync. +const COOKIE_KEY = "cueweb.facility"; const DEFAULT_FACILITIES = ["local", "dev", "cloud", "external"] as const; +/** Mirror the selection into a cookie the server reads on every request. */ +function writeCookie(value: string): void { + if (typeof document === "undefined") return; + // Not HttpOnly: the client sets it for instant routing, and the server + // re-validates the value against the configured facility list, so a tampered + // cookie can only ever select another already-configured facility. + const oneYear = 60 * 60 * 24 * 365; + document.cookie = `${COOKIE_KEY}=${encodeURIComponent(value)}; path=/; max-age=${oneYear}; samesite=lax`; +} + +/** Read the facility cookie (returns null when absent). */ +function readCookie(): string | null { + if (typeof document === "undefined") return null; + const row = document.cookie + .split("; ") + .find((r) => r.startsWith(`${COOKIE_KEY}=`)); + if (!row) return null; + try { + // A malformed value (bad %-escape) would otherwise throw in the mount + // effect and stop the hook from syncing/recovering. + return decodeURIComponent(row.slice(COOKIE_KEY.length + 1)); + } catch { + return null; + } +} + /** Parse the build-time env var; falls back to the CueGUI defaults. */ function readFacilitiesFromEnv(): string[] { const raw = process.env.NEXT_PUBLIC_CUEBOT_FACILITIES ?? ""; @@ -84,7 +115,11 @@ export function useCuebotFacility(): { ); React.useEffect(() => { - setFacilityState(readSelected(facilities)); + const current = readSelected(facilities); + setFacilityState(current); + // Propagate a pre-existing localStorage selection (set before the cookie + // existed) to the cookie so server routes pick it up without a reselect. + if (readCookie() !== current) writeCookie(current); const customHandler = () => setFacilityState(readSelected(facilities)); const storageHandler = (e: StorageEvent) => { @@ -102,10 +137,16 @@ export function useCuebotFacility(): { const setFacility = React.useCallback( (next: string) => { if (!facilities.includes(next)) return; + const previous = readSelected(facilities); writeSelected(next); + writeCookie(next); setFacilityState(next); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); + // Re-fetch everything against the newly selected gateway. CueGUI clears + // and reloads all data on a facility switch; a full reload is the + // simplest equivalent and guarantees no stale cross-facility data. + if (next !== previous) window.location.reload(); } }, [facilities], diff --git a/cueweb/components/ui/status-bar.tsx b/cueweb/components/ui/status-bar.tsx index 793c50c42..52caa0df1 100644 --- a/cueweb/components/ui/status-bar.tsx +++ b/cueweb/components/ui/status-bar.tsx @@ -18,9 +18,10 @@ import { usePathname } from "next/navigation"; import * as React from "react"; -import { Activity, Clock, Tag } from "lucide-react"; +import { Activity, Clock, Server, Tag } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useCuebotFacility } from "@/app/utils/use_cuebot_facility"; /** * IDE-style fixed status bar mounted at the bottom of every authenticated @@ -92,6 +93,7 @@ function StatusItem({ export function StatusBar() { const pathname = usePathname(); + const { facility } = useCuebotFacility(); const [health, setHealth] = React.useState(null); const [lastRefresh, setLastRefresh] = React.useState(null); // Tick once per second so relative timestamps stay fresh without waiting @@ -221,6 +223,18 @@ export function StatusBar() {