From 7661ea0f2e7310c1cf8569f689e27a64c4df2604 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Sat, 19 Sep 2026 00:00:02 +0000 Subject: [PATCH] Open the physics page and the sim config save to anyone /sim/physics bounced a visitor to the login card while every read behind it (config, ground truth, solver stats, nodes) was already public; only the PUT was admin-only. The console has no identity provider yet and the fleet it reconfigures is synthetic, so the save is open now and the page goes with it: isPublicRoute admits the whole /sim subtree like the other open routes, the route and both navs mount on AuthProvider's one syntheticFleet answer (/me for a session, /api/health for a visitor), and the Physics Layer entry is advertised beside Simulation wherever there is a fleet. Co-Authored-By: Claude Fable 5.1 --- ONBOARDING.md | 11 +-- backend/routes/test.py | 6 +- backend/tests/test_sim_ingest.py | 16 +++++ dashboard/src/App.tsx | 14 ++-- dashboard/src/components/Sidebar.tsx | 11 ++- dashboard/src/context/AuthContext.tsx | 19 ++++-- dashboard/src/test/physicsRoute.test.tsx | 76 ++++++++++++--------- dashboard/src/test/publicRoutes.test.ts | 27 +++++--- dashboard/src/test/signedOutChrome.test.tsx | 10 ++- dashboard/src/utils/physics.ts | 8 --- dashboard/src/utils/publicRoutes.ts | 34 ++++----- 11 files changed, 140 insertions(+), 92 deletions(-) delete mode 100644 dashboard/src/utils/physics.ts diff --git a/ONBOARDING.md b/ONBOARDING.md index 253576ed3..5cedebfb7 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -310,10 +310,13 @@ branch, open a PR, get it green, then merge. - **The map opens on localhost too.** There is one console per environment — `app`, `staging-app`, `test-app` — and the surface is chosen by path, not by hostname: `/map` is the real network, `/sim` the simulated fleet, and - `/sim/physics` the page that tunes it. `/sim` exists everywhere but is only - worth opening where the server sets `SYNTHETIC_FLEET_ENABLED` (test, - staging, the laptop — not production), which is what `/api/health` and - `/api/auth/me` both report so the nav can say so. `map`, `testmap`, + `/sim/physics` the page that tunes it. Both are open without signing in, + and so is the save behind the physics page — there is no identity provider + to sign an operator in with yet, and the fleet is synthetic. `/sim` exists + everywhere but is only worth opening where the server sets + `SYNTHETIC_FLEET_ENABLED` (test, staging, the laptop — not production), + which is what `/api/health` and `/api/auth/me` both report so the nav and + the physics route can follow it. `map`, `testmap`, `test-testmap`, `staging-map` and the other retired names are Cloudflare redirects into those consoles. `/map` still takes its feed from the hostname: `app` and `test-app` are real-only, and a local hostname retains diff --git a/backend/routes/test.py b/backend/routes/test.py index 25f690793..1fc66139a 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -440,9 +440,13 @@ async def get_simulation_config(): @router.put("/api/simulation/config") -async def put_simulation_config(body: dict = Body(...), _admin=Depends(require_admin)): +async def put_simulation_config(body: dict = Body(...)): """Update simulation physics fractions. + Open to anyone, like the GET beside it and the page that calls both: the + simulator is a public demo, the console has no identity provider yet, and + the fleet it reconfigures is synthetic. Nothing here touches a real node. + Accepted keys: frac_anomalous, frac_drone, frac_dark (0.0–1.0 each). Sum of the three must not exceed 1.0 — the remainder is commercial aircraft. frac_adsb_outage (0.0–1.0) is deliberately OUTSIDE that sum: it is the diff --git a/backend/tests/test_sim_ingest.py b/backend/tests/test_sim_ingest.py index 457cca9d7..3ac0fd40e 100644 --- a/backend/tests/test_sim_ingest.py +++ b/backend/tests/test_sim_ingest.py @@ -216,6 +216,22 @@ def test_live_knobs_default_on_with_dark_share(self, client): assert cfg["live_adsb_enabled"] is True assert cfg["frac_live_dark"] == 0.15 + def test_config_put_needs_no_session(self, client, monkeypatch): + # The suite reaches admin routes through the anonymous-admin bypass, + # which would mask an auth gate on this one. Off, an unsigned PUT is + # still accepted here while the admin PUT beside it goes on refusing. + import core.users as users + + monkeypatch.setattr(users, "AUTH_BYPASS", False) + try: + r = client.put("/api/simulation/config", json={"frac_live_dark": 0.2}) + assert r.status_code == 200 + assert r.json()["config"]["frac_live_dark"] == 0.2 + assert client.put("/api/test/known-hold", json={}).status_code == 401 + finally: + monkeypatch.undo() + client.put("/api/simulation/config", json={"frac_live_dark": 0.15}) + def test_live_knobs_accepted_and_echoed(self, client): r = client.put("/api/simulation/config", json={"frac_live_dark": 0.6, "live_adsb_enabled": False}) assert r.status_code == 200 diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index f10bf6067..d2aaa2088 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -9,7 +9,6 @@ import MapFrontDoor from "./components/MapFrontDoor"; import PhysicsRedirect from "./components/PhysicsRedirect"; import { resolveSurface, warnIfModeIgnored } from "./utils/surface"; import { useAuth } from "./context/AuthContext"; -import { showsPhysics } from "./utils/physics"; // User pages — lazy-loaded so each chunk is only downloaded when first visited const OverviewPage = lazy(() => import("./pages/user/OverviewPage")); @@ -62,7 +61,7 @@ const { isAdmin: isAdminSite, modeParamIgnored } = resolveSurface( warnIfModeIgnored(modeParamIgnored); export default function App() { - const { user } = useAuth(); + const { syntheticFleet } = useAuth(); return ( } /> @@ -107,14 +106,15 @@ export default function App() { fleet by name, so one console serves both and neither depends on what the browser's address bar says. */} } /> - {/* Gated as /physics was: the page draws the fleet's - solver internals and saves its configuration, so it - is a signed-in page on a server that has a fleet. The - old address below forwards whether or not this route + {/* Open like /sim, session or not: the page draws the + fleet's solver internals and saves its configuration, + and both ends of that are public on a server that + has a fleet. Absent where there is none. The old + address below forwards whether or not this route exists, so a stale link lands on the new one and then renders nothing where there is no fleet — exactly what /physics did on such a deployment already. */} - {showsPhysics(user) && ( + {syntheticFleet && ( } /> )} } /> diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 0c5aa0371..e00c7063f 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -2,7 +2,6 @@ import { NavLink } from "react-router-dom"; import { towerFinderUrl } from "../utils/siblings"; import { useAuth } from "../context/AuthContext"; import { advertisedPublicRoutes } from "../utils/publicRoutes"; -import { showsPhysics } from "../utils/physics"; import { externalLinkIcon } from "./RetnodeLink"; type NavItem = { @@ -22,7 +21,7 @@ type NavSection = { title: string; items: NavItem[] }; // Built per render rather than at module scope. The links below read // window.location, and an import-time read that threw would take down every // module that transitively imports this one, not just the sidebar. -const userNav = (physics: boolean): NavSection[] => [ +const userNav = (syntheticFleet: boolean): NavSection[] => [ { title: "Dashboard", items: [ @@ -37,7 +36,7 @@ const userNav = (physics: boolean): NavSection[] => [ // because the second is a setting of the first. Both exist only where // the server runs a fleet; on a fleetless deployment /sim would be an // empty map and the physics form would have nothing to configure. - ...(physics + ...(syntheticFleet ? [ { to: "/sim", label: "Simulation", icon: "target", end: true }, { to: "/sim/physics", label: "Physics Layer", icon: "layers" }, @@ -80,11 +79,11 @@ const userNav = (physics: boolean): NavSection[] => [ const publicNav = (syntheticFleet: boolean): NavSection[] => [ { title: "Explore", - items: advertisedPublicRoutes(syntheticFleet).map(({ path, label, icon, exact }) => ({ + items: advertisedPublicRoutes(syntheticFleet).map(({ path, label, icon, end }) => ({ to: path, label, icon, - end: exact, + end, })), }, ]; @@ -296,7 +295,7 @@ export default function Sidebar({ isAdmin, collapsed, onToggle }) { const nav = isAdmin ? adminNav : user - ? userNav(showsPhysics(user)) + ? userNav(Boolean(syntheticFleet)) : publicNav(Boolean(syntheticFleet)); return ( diff --git a/dashboard/src/context/AuthContext.tsx b/dashboard/src/context/AuthContext.tsx index 6cdc9f558..04445140e 100644 --- a/dashboard/src/context/AuthContext.tsx +++ b/dashboard/src/context/AuthContext.tsx @@ -7,12 +7,13 @@ const AuthContext = createContext(null); /** Whether this server runs a synthetic fleet, for a caller who has no session * to read it off. * - * A signed-in user gets the same fact as `synthetic_fleet` on /api/auth/me, - * and the console's own gates (utils/physics.ts) go on using that. A visitor - * does not, and the signed-out nav has to decide whether to point at /sim: - * test and staging run a fleet, production does not, and one bundle serves - * all three. /api/health is the only thing the server tells everyone, so the - * flag is answered there. + * A signed-in user gets the same fact as `synthetic_fleet` on /api/auth/me. + * A visitor does not, and the console has to decide whether to point at + * /sim and mount the physics page under it: test and staging run a fleet, + * production does not, and one bundle serves all three. /api/health is the + * only thing the server tells everyone, so the flag is answered there, and + * AuthProvider folds the two sources into the one `syntheticFleet` it hands + * out. * * Asked once at boot, beside the /me fetch, and never again — it is a * property of the deployment, which does not change under a running tab. A @@ -41,7 +42,11 @@ function useSyntheticFleet(): boolean { export function AuthProvider({ children }) { const { user, loading, setUser } = useCurrentUser(); - const syntheticFleet = useSyntheticFleet(); + const healthFleet = useSyntheticFleet(); + // /me's word first: it arrives under the loading gate, so a signed-in + // caller's pages mount on the first settled render rather than a health + // round trip later. + const syntheticFleet = Boolean(user?.synthetic_fleet ?? healthFleet); // Resolves { redirected } so a caller knows not to route over a navigation // that is still in flight. diff --git a/dashboard/src/test/physicsRoute.test.tsx b/dashboard/src/test/physicsRoute.test.tsx index 4f8de95a4..d67018e6e 100644 --- a/dashboard/src/test/physicsRoute.test.tsx +++ b/dashboard/src/test/physicsRoute.test.tsx @@ -4,15 +4,16 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import App from "../App"; import Sidebar from "../components/Sidebar"; import { ThemeProvider } from "../context/ThemeContext"; -import { showsPhysics } from "../utils/physics"; type User = { name: string; email: string; synthetic_fleet?: boolean }; -// The Physics item is in the signed-in nav, so the mocked caller has a session. +// AuthProvider answers `syntheticFleet` from /api/auth/me for a session and +// from /api/health for a visitor; the mock hands the cases the folded answer. const state = vi.hoisted(() => ({ auth: { user: { name: "Ada", email: "ada@example.com" } as User | null, loading: false, + syntheticFleet: false, logout: async () => ({ redirected: false }), }, })); @@ -42,8 +43,17 @@ function Where() { return {`${pathname}${search}${hash}`}; } -function renderSidebar({ fleet, realOnly }: { fleet: boolean; realOnly: boolean }) { - state.auth.user = { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet }; +function renderSidebar({ + fleet, + realOnly, + signedIn = true, +}: { + fleet: boolean; + realOnly: boolean; + signedIn?: boolean; +}) { + state.auth.user = signedIn ? { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet } : null; + state.auth.syntheticFleet = fleet; flags.realOnly = realOnly; return render( @@ -68,6 +78,12 @@ describe("the Physics Layer route", () => { expect(container.querySelector('a[href="/sim/physics"]')).toBeNull(); }); + // The save behind it is open, so there is no session to wait for. + it("is offered to a visitor with no session", () => { + const { container } = renderSidebar({ fleet: true, realOnly: false, signedIn: false }); + expect(container.querySelector('a[href="/sim/physics"]')).toHaveTextContent("Physics Layer"); + }); + // The old address is gone from the nav entirely: it survives only as a // forward for links already in circulation, and an entry pointing at a // redirect is one hop the caller need never take. @@ -91,21 +107,26 @@ describe("the Simulation route", () => { // Otherwise NavLink lights both entries up at once on /sim/physics, since a // link without `end` is current for its whole subtree. it("is not current while the physics page under it is", () => { - state.auth.user = { name: "Ada", email: "ada@example.com", synthetic_fleet: true }; - flags.realOnly = false; - const { container } = render( - - {}} /> - , - ); - expect(container.querySelector('a[href="/sim"]')).not.toHaveClass("active"); - expect(container.querySelector('a[href="/sim/physics"]')).toHaveClass("active"); + for (const signedIn of [true, false]) { + state.auth.user = signedIn ? { name: "Ada", email: "ada@example.com", synthetic_fleet: true } : null; + state.auth.syntheticFleet = true; + flags.realOnly = false; + const { container, unmount } = render( + + {}} /> + , + ); + expect(container.querySelector('a[href="/sim"]')).not.toHaveClass("active"); + expect(container.querySelector('a[href="/sim/physics"]')).toHaveClass("active"); + unmount(); + } }); }); describe("the /sim/physics address", () => { - function visit(fleet: boolean, at = "/sim/physics") { - state.auth.user = { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet }; + function visit(fleet: boolean, at = "/sim/physics", { signedIn = true } = {}) { + state.auth.user = signedIn ? { name: "Ada", email: "ada@example.com", synthetic_fleet: fleet } : null; + state.auth.syntheticFleet = fleet; window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: () => {}, @@ -126,6 +147,13 @@ describe("the /sim/physics address", () => { expect(await screen.findByText("physics page")).toBeInTheDocument(); }); + // No login card on the way: the route is open like /sim above it. + it("opens the page to a visitor with no session", async () => { + visit(true, "/sim/physics", { signedIn: false }); + expect(await screen.findByText("physics page")).toBeInTheDocument(); + expect(screen.getByLabelText("location")).toHaveTextContent("/sim/physics"); + }); + it("is no page at all where it runs none", async () => { visit(false); // Give the lazy route a chance to resolve before asserting it never did. @@ -149,21 +177,3 @@ describe("the /sim/physics address", () => { ); }); }); - -describe("showsPhysics", () => { - it("shows it to a signed-in user on a server with a fleet", () => { - expect(showsPhysics({ synthetic_fleet: true })).toBe(true); - }); - - it("hides it where the server runs no fleet", () => { - expect(showsPhysics({ synthetic_fleet: false })).toBe(false); - }); - - it("hides it from a server that does not say", () => { - expect(showsPhysics({})).toBe(false); - }); - - it("hides it when signed out", () => { - expect(showsPhysics(null)).toBe(false); - }); -}); diff --git a/dashboard/src/test/publicRoutes.test.ts b/dashboard/src/test/publicRoutes.test.ts index e58473655..77ce63ba7 100644 --- a/dashboard/src/test/publicRoutes.test.ts +++ b/dashboard/src/test/publicRoutes.test.ts @@ -51,16 +51,15 @@ describe("the simulation surface", () => { expect(isPublicRoute("/sim", false)).toBe(true); }); - // The page that writes the fleet's configuration. The PUT behind it is - // admin-only server-side, so this is defence in depth rather than the only - // lock — but a console that renders the form to a visitor and then refuses - // every save is worse than one that never offers it. - it("refuses the physics page nested under it", () => { - expect(isPublicRoute("/sim/physics", false)).toBe(false); + // The page that writes the fleet's configuration. It used to be refused + // here because the PUT behind it was admin-only; the save is open now, so + // the page travels with its parent like every other nested route. + it("admits the physics page nested under it", () => { + expect(isPublicRoute("/sim/physics", false)).toBe(true); }); - it("refuses anything else nested under it", () => { - expect(isPublicRoute("/sim/anything", false)).toBe(false); + it("admits the rest of the subtree, as the other open routes do", () => { + expect(isPublicRoute("/sim/anything", false)).toBe(true); }); it("still admits it spelled with a trailing slash", () => { @@ -83,6 +82,18 @@ describe("what a visitor is pointed at", () => { expect(labels(true)).toContain("Simulation"); }); + // Right after the simulator, as its setting. + it("offers the physics page beside it, and only there", () => { + expect(labels(true).indexOf("Physics Layer")).toBe(labels(true).indexOf("Simulation") + 1); + expect(labels(false)).not.toContain("Physics Layer"); + }); + + // NavLink would otherwise mark /sim current on /sim/physics as well. + it("ends the simulation entry at its own path", () => { + const sim = advertisedPublicRoutes(true).find((r) => r.path === "/sim"); + expect(sim?.end).toBe(true); + }); + // Advertisement is the only thing the flag moves; the route stays reachable // by link everywhere, because one bundle serves every environment. it("does not close the route it declines to advertise", () => { diff --git a/dashboard/src/test/signedOutChrome.test.tsx b/dashboard/src/test/signedOutChrome.test.tsx index 58a3a95bd..9039bdad2 100644 --- a/dashboard/src/test/signedOutChrome.test.tsx +++ b/dashboard/src/test/signedOutChrome.test.tsx @@ -103,9 +103,15 @@ describe("the sidebar shown to a caller with no session", () => { expect(shownLabels()).toEqual(advertisedPublicRoutes(true).map((r) => r.label)); }); - // The page that configures the fleet, whose save is admin-only server-side. - it("never offers the physics page, fleet or no fleet", () => { + // The page that configures the fleet. Its save is open like the rest of + // the simulator, so a visitor is pointed at it wherever there is a fleet. + it("offers the physics page beside the simulation where one is running", () => { state.auth = { ...state.auth, syntheticFleet: true }; + renderSidebar(); + expect(screen.getByRole("link", { name: "Physics Layer" })).toHaveAttribute("href", "/sim/physics"); + }); + + it("withholds the physics page with the simulation where there is no fleet", () => { renderSidebar(); expect(screen.queryByText("Physics Layer")).not.toBeInTheDocument(); }); diff --git a/dashboard/src/utils/physics.ts b/dashboard/src/utils/physics.ts deleted file mode 100644 index 89883074d..000000000 --- a/dashboard/src/utils/physics.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The physics layer draws the synthetic fleet's solver internals, so it exists - * only where the server runs a fleet, and only for a signed-in user. The server - * says which on /api/auth/me. - */ -export function showsPhysics(user: { synthetic_fleet?: boolean } | null): boolean { - return Boolean(user?.synthetic_fleet); -} diff --git a/dashboard/src/utils/publicRoutes.ts b/dashboard/src/utils/publicRoutes.ts index 77b6d764f..3013fddc3 100644 --- a/dashboard/src/utils/publicRoutes.ts +++ b/dashboard/src/utils/publicRoutes.ts @@ -16,16 +16,14 @@ export type PublicRoute = { /** A key into the sidebar's icon set. */ icon: string; /** - * Open at this path alone, rather than at everything nested under it. + * Current in the nav at this path alone, not for everything under it. * - * The default is the other way round, because most of these pages own a - * subtree that is the same publication: /data/2026/09/17 is the archive - * listing's own deep link. /sim is the exception — it has a child, /sim/ - * physics, that writes the fleet's configuration through an admin-only PUT. - * Opening the parent must not open the child, and the honest way to say so - * is on the parent rather than as a list of exceptions somewhere else. + * Openness is by first segment regardless (see `isPublicRoute`); this only + * stops a parent's entry lighting up beside its child's. /sim wants it + * because /sim/physics is its own entry, and without `end` NavLink would + * mark both current at once. */ - exact?: boolean; + end?: boolean; /** * Advertised only where the server runs a synthetic fleet. * @@ -44,7 +42,12 @@ export type PublicRoute = { */ export const PUBLIC_ROUTES: readonly PublicRoute[] = [ { path: "/map", label: "Map", icon: "map" }, - { path: "/sim", label: "Simulation", icon: "target", exact: true, needsFleet: true }, + { path: "/sim", label: "Simulation", icon: "target", end: true, needsFleet: true }, + // The page that tunes the fleet. Its save used to be admin-only, which is + // why /sim once opened without it; the console has no identity provider to + // sign an operator in with, so the PUT is open now and the page goes with + // it. Listed for the nav: openness it already has, as a child of /sim. + { path: "/sim/physics", label: "Physics Layer", icon: "layers", needsFleet: true }, { path: "/data", label: "Data Explorer", icon: "database" }, { path: "/leaderboard", label: "Leaderboard", icon: "trophy" }, { path: "/knowledge", label: "Knowledge Base", icon: "book" }, @@ -75,11 +78,10 @@ export function isPublicRoute(pathname: string, isAdmin: boolean): boolean { if (isAdmin) return false; // The index renders nothing of its own: it forwards to the map. if (pathname === "/") return true; - // First segment only, so a nested route travels with its parent unless the - // entry says otherwise. Split rather than a prefix test: "/datasets" starts - // with "/data" and is a different page. - const segments = pathname.split("/").filter(Boolean); - const entry = PUBLIC_ROUTES.find((r) => r.path === `/${segments[0] ?? ""}`); - if (!entry) return false; - return !entry.exact || segments.length === 1; + // First segment only, so a nested route travels with its parent: /data/ + // 2026/09/17 is the archive listing's own deep link, and /sim/physics is a + // setting of the simulator. Split rather than a prefix test: "/datasets" + // starts with "/data" and is a different page. + const first = `/${pathname.split("/").filter(Boolean)[0] ?? ""}`; + return PUBLIC_ROUTES.some((r) => r.path === first); }