From 7c2c5d95e373795e88454837a4bd260ebd0a84a2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 17 Sep 2026 09:23:03 +0000 Subject: [PATCH] fix(web): hand the new heartbeat token over in sessionStorage, not the URL --- web/app/(app)/monitors/[id]/page.tsx | 25 +++++++++++++------------ web/app/(app)/monitors/new/page.tsx | 11 ++++++++--- web/e2e/heartbeat.spec.ts | 2 +- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/web/app/(app)/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx index e0a0bf1..f40b5ee 100644 --- a/web/app/(app)/monitors/[id]/page.tsx +++ b/web/app/(app)/monitors/[id]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import { api, Incident, Rollup } from "@/lib/api"; import { Button, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui"; @@ -322,7 +322,6 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) { export default function MonitorDetailPage() { const params = useParams(); const router = useRouter(); - const searchParams = useSearchParams(); const queryClient = useQueryClient(); const monitorId = params.id as string; const [confirmDelete, setConfirmDelete] = useState(false); @@ -330,19 +329,21 @@ export default function MonitorDetailPage() { const [range, setRange] = useState(RANGES[0]); const toast = useToast(); - /* The token is only ever handed back once, on the create response, riding - in the query string for this one navigation. Keep it in state and strip - the query immediately so a reload, a bookmark or browser history never - holds it. */ + /* The token is only ever handed back once, on the create response. The + create page parks it in sessionStorage for this one navigation, because a + query string would reach proxy access logs. Read it once and remove it + straight away so a reload or another tab never shows it again. */ const [heartbeatToken, setHeartbeatToken] = useState(null); useEffect(() => { - const t = searchParams.get("token"); - if (t) { - setHeartbeatToken(t); - router.replace(`/monitors/${monitorId}`); + const key = `vantage:hbtoken:${monitorId}`; + try { + const t = sessionStorage.getItem(key); + sessionStorage.removeItem(key); + if (t) setHeartbeatToken(t); + } catch { + // Storage blocked: nothing was handed over. } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [monitorId]); const { data: monitor, isLoading } = useQuery({ queryKey: ["monitors", monitorId], diff --git a/web/app/(app)/monitors/new/page.tsx b/web/app/(app)/monitors/new/page.tsx index 8a5c9cb..4883930 100644 --- a/web/app/(app)/monitors/new/page.tsx +++ b/web/app/(app)/monitors/new/page.tsx @@ -24,11 +24,16 @@ export default function NewMonitorPage() { // survive the route change, which is the one thing an inline // banner on the form cannot do. The error stays on the form. toast.success(`Created ${m.name}. First check runs within its interval.`); + // The token is shown once. Hand it over in memory, never in the + // URL: the RSC fetch for a query string lands in proxy access logs. if (m.heartbeat_token) { - router.push(`/monitors/${m.monitor_id}?token=${encodeURIComponent(m.heartbeat_token)}`); - } else { - router.push(`/monitors/${m.monitor_id}`); + try { + sessionStorage.setItem(`vantage:hbtoken:${m.monitor_id}`, m.heartbeat_token); + } catch { + // Storage blocked: the user can rotate to get a token. + } } + router.push(`/monitors/${m.monitor_id}`); }, }); diff --git a/web/e2e/heartbeat.spec.ts b/web/e2e/heartbeat.spec.ts index 5c88975..40e0d71 100644 --- a/web/e2e/heartbeat.spec.ts +++ b/web/e2e/heartbeat.spec.ts @@ -36,7 +36,7 @@ test("heartbeat monitor: URL ping, /fail, header ping", async ({ page, request } const incidents = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}/incidents`)).json(); expect(incidents.length).toBeGreaterThan(0); - await new Promise((r) => setTimeout(r, 1100)); + await new Promise((r) => setTimeout(r, 1100)); // same kind (ping) as the first request; limit is 1/s per token and kind const headerPing = await request.post(`${BASE_URL}/public/hb`, { headers: { "X-Vantage-Token": monitor.heartbeat_token } }); expect(headerPing.status()).toBe(200); got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();