From 238a0da0a01da4ccd270da5204105a277d58af99 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:04:05 -0700 Subject: [PATCH 1/3] feat(web): owner analytics dashboard at /u//dashboard A dashboard link carries a 15-minute, single-use grant. next.config rewrites the grant-bearing request (beforeFiles, keyed on the grant query) to a route handler that exchanges it server-to-server with the relay-agent registry, sets an HttpOnly/Secure/SameSite=Lax session cookie scoped to the dashboard path with the session's absolute expiry, and 303s to the bare URL. No page, layout, or PostHog code runs for the exchange, and the grant is never echoed. The force-dynamic page reads the cookie server-side, fetches 7- and 30-day stats with the Bearer session, and renders daily conversations, messages, replies, latency, visitor-days, clients, countries, and outcomes, with expired, unavailable, empty, and estimated states. Exchange, redirect, and page responses are private/no-store, no-referrer, and CSP-locked; the page is noindex. PostHog page views and all captured events are dropped on dashboard paths. A Node-runtime proxy.ts was not used: OpenNext Cloudflare 1.20 refuses Node middleware at build time. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 8fa30995-3394-4357-aadf-23aee1464578 --- web/app/WebsitePostHogProvider.tsx | 3 +- .../u/[handle]/dashboard/dashboard.module.css | 220 ++++++++++++ .../u/[handle]/dashboard/exchange/route.ts | 15 + web/app/u/[handle]/dashboard/page.tsx | 313 ++++++++++++++++++ web/lib/agent-dashboard.ts | 227 +++++++++++++ web/lib/agent-registry.ts | 4 +- web/lib/site-analytics.ts | 30 ++ web/lib/test/agent-dashboard-page.test.tsx | 126 +++++++ web/lib/test/agent-dashboard.test.ts | 224 +++++++++++++ web/next.config.mjs | 32 +- 10 files changed, 1190 insertions(+), 4 deletions(-) create mode 100644 web/app/u/[handle]/dashboard/dashboard.module.css create mode 100644 web/app/u/[handle]/dashboard/exchange/route.ts create mode 100644 web/app/u/[handle]/dashboard/page.tsx create mode 100644 web/lib/agent-dashboard.ts create mode 100644 web/lib/test/agent-dashboard-page.test.tsx create mode 100644 web/lib/test/agent-dashboard.test.ts diff --git a/web/app/WebsitePostHogProvider.tsx b/web/app/WebsitePostHogProvider.tsx index 91f3ba50..82f32c5d 100644 --- a/web/app/WebsitePostHogProvider.tsx +++ b/web/app/WebsitePostHogProvider.tsx @@ -4,6 +4,7 @@ // through the server-component boundary, and before the first SDK capture. import { PostHogProvider } from '@posthog/next/pages'; import type { ReactNode } from 'react'; +import { dropAgentDashboardAnalytics } from '../lib/site-analytics'; import { maskSignupNetworkRequest, sanitizeSignupAnalytics } from '../lib/signup-analytics-privacy'; export function WebsitePostHogProvider({ apiKey, host, children }: { apiKey: string; host: string; children: ReactNode }) { @@ -11,6 +12,6 @@ export function WebsitePostHogProvider({ apiKey, host, children }: { apiKey: str api_host: host, autocapture: true, capture_exceptions: true, capture_heatmaps: true, capture_pageview: false, capture_pageleave: true, session_recording: { maskAllInputs: true, blockSelector: '.ph-sensitive', maskCapturedNetworkRequestFn: maskSignupNetworkRequest }, - before_send: sanitizeSignupAnalytics, + before_send: [dropAgentDashboardAnalytics, sanitizeSignupAnalytics], }}>{children}; } diff --git a/web/app/u/[handle]/dashboard/dashboard.module.css b/web/app/u/[handle]/dashboard/dashboard.module.css new file mode 100644 index 00000000..93555ded --- /dev/null +++ b/web/app/u/[handle]/dashboard/dashboard.module.css @@ -0,0 +1,220 @@ +.page { + background: #0d2638; +} + +.page :is(a, button):focus-visible { + outline: 2px solid var(--flows-accent-strong); + outline-offset: 4px; +} + +.main { + max-width: 960px; + margin: 0 auto; + padding: clamp(96px, 12vw, 136px) 16px 96px; +} + +.header { + margin-bottom: 32px; +} + +.headline { + margin: 0; + font-family: var(--font-heading), sans-serif; + color: var(--flows-text); + font-size: clamp(2rem, 4.5vw, 3rem); + font-weight: 500; +} + +.handle { + display: inline-block; + margin: 12px 0 0; + border: 1px solid #47708d; + border-radius: 999px; + padding: 4px 12px; + color: #b9ddf5; + font-family: var(--font-mono, ui-monospace), monospace; + font-size: 13px; +} + +.note, +.explain, +.generated, +.muted { + color: var(--flows-muted); + line-height: 1.6; + font-size: 14px; +} + +.note { + margin: 16px 0 0; +} + +.notice { + border: 1px solid #8f655d; + border-radius: 12px; + padding: 20px; + background: #291d1d; + color: #ffd1c8; + line-height: 1.6; +} + +.notice p { + margin: 0; +} + +.notice p + p { + margin-top: 8px; +} + +.notice code { + font-family: var(--font-mono, ui-monospace), monospace; + font-size: 13px; + overflow-wrap: anywhere; +} + +.toggle { + display: flex; + gap: 8px; + margin-bottom: 32px; +} + +.toggle a { + border: 1px solid #405c72; + border-radius: 999px; + padding: 6px 14px; + color: var(--flows-muted); + font-size: 14px; + text-decoration: none; +} + +.toggle .toggleActive { + border-color: var(--flows-accent); + color: var(--flows-text); + background: #16384f; +} + +.section { + margin-bottom: 48px; +} + +.sectionHeading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 12px; + margin: 0 0 8px; + font-family: var(--font-heading), sans-serif; + color: var(--flows-text); + font-size: clamp(1.4rem, 3vw, 1.8rem); + font-weight: 500; +} + +.estimated { + border: 1px solid #8f7a4d; + border-radius: 999px; + padding: 2px 10px; + color: #f3dca6; + font-family: var(--font-mono, ui-monospace), monospace; + font-size: 12px; + font-weight: 400; +} + +.generated { + margin: 0 0 20px; +} + +.tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + margin: 0; +} + +.tile { + border: 1px solid #2c4a60; + border-radius: 12px; + padding: 14px 16px; + background: #102e43; +} + +.tile dt { + color: var(--flows-muted); + font-size: 13px; +} + +.tile dd { + margin: 6px 0 0; + color: var(--flows-text); + font-size: 1.5rem; + font-variant-numeric: tabular-nums; +} + +.explain { + margin: 12px 0 32px; +} + +.block { + margin-bottom: 32px; +} + +.blockHeading { + margin: 0 0 12px; + color: var(--flows-text); + font-size: 1rem; + font-weight: 600; +} + +.breakdowns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 0 24px; +} + +.tableWrap { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + color: var(--flows-text); + font-size: 14px; +} + +.table th, +.table td { + border-bottom: 1px solid #23415a; + padding: 8px 10px; + text-align: left; + font-weight: 400; + white-space: nowrap; +} + +.table thead th { + color: var(--flows-muted); + font-size: 13px; +} + +.table .num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.barCell { + display: flex; + align-items: center; + gap: 8px; + min-width: 140px; +} + +.bar { + display: block; + height: 10px; + min-width: 2px; + border-radius: 3px; + background: var(--flows-accent); +} + +.barValue { + font-variant-numeric: tabular-nums; +} diff --git a/web/app/u/[handle]/dashboard/exchange/route.ts b/web/app/u/[handle]/dashboard/exchange/route.ts new file mode 100644 index 00000000..c2c6d62b --- /dev/null +++ b/web/app/u/[handle]/dashboard/exchange/route.ts @@ -0,0 +1,15 @@ +import { handleDashboardExchange } from '../../../../../lib/agent-dashboard'; + +// `/u//dashboard?grant=...` is rewritten here by next.config.mjs +// (beforeFiles, keyed on the grant query), so the grant is exchanged before +// any page, layout, or analytics code runs. See lib/agent-dashboard.ts. +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ handle: string }>; +}; + +export async function GET(request: Request, { params }: RouteContext) { + const { handle } = await params; + return handleDashboardExchange(request, handle); +} diff --git a/web/app/u/[handle]/dashboard/page.tsx b/web/app/u/[handle]/dashboard/page.tsx new file mode 100644 index 00000000..0b1befc5 --- /dev/null +++ b/web/app/u/[handle]/dashboard/page.tsx @@ -0,0 +1,313 @@ +import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; +import { notFound } from 'next/navigation'; +import type { ReactNode } from 'react'; + +import { SiteFooter } from '../../../../components/SiteFooter'; +import { SiteNav } from '../../../../components/SiteNav'; +import { + DASHBOARD_SESSION_COOKIE, + dashboardPath, + type DashboardWindow, + fetchDashboardStats, + type HandleStats, + parseDashboardWindow, +} from '../../../../lib/agent-dashboard'; +import { validRegistryHandle } from '../../../../lib/agent-registry'; +import home from '../../../landing.module.css'; +import flows from '../../../flows/flows.module.css'; +import s from './dashboard.module.css'; + +// Reads a per-owner session cookie; never prerender or cache. +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + title: 'Agent dashboard', + robots: { index: false, follow: false }, + referrer: 'no-referrer', +}; + +type PageProps = { + params: Promise<{ handle: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +const COLLECTION_NOTE = + 'Collection is server-side and started when analytics shipped; earlier traffic is not included.'; + +const OUTCOME_LABELS: Record = { + ok: 'Replied', + timeout: 'Timed out', + 'rate-limited': 'Rate limited', + error: 'Error', + 'delivery-failed': 'Delivery failed', +}; + +const numberFormat = new Intl.NumberFormat('en-US'); + +export default async function AgentDashboardPage({ params, searchParams }: PageProps) { + const { handle } = await params; + if (!validRegistryHandle(handle)) notFound(); + const selected = parseDashboardWindow((await searchParams).window); + + // The session stays on the server: it is only ever sent to the registry as a + // Bearer token and never passed to a client component or rendered. + const session = (await cookies()).get(DASHBOARD_SESSION_COOKIE)?.value; + if (!session) return ; + + const [week, month] = await Promise.all([ + fetchDashboardStats(handle, session, 7), + fetchDashboardStats(handle, session, 30), + ]); + if (week.kind === 'unauthorized' || month.kind === 'unauthorized') { + return ; + } + if (week.kind !== 'ok' || month.kind !== 'ok') { + return ( + +

+ Analytics are temporarily unavailable. Your dashboard link is still valid for a few minutes; reload to try + again. +

+
+ ); + } + + const stats = selected === 30 ? month.stats : week.stats; + return ( + + + + + + ); +} + +function Shell({ handle, children }: { handle: string; children: ReactNode }) { + return ( +
+ Skip to content + +
+
+

Agent dashboard

+

{handle}

+

{COLLECTION_NOTE}

+
+ {children} +
+ +
+ ); +} + +function ExpiredState({ handle }: { handle: string }) { + return ( +
+

+ This dashboard link has expired or was already used. Ask your agent for a new one with{' '} + POST /api/v1/agents/{handle}/manage/dashboard-link. +

+
+ ); +} + +function WindowToggle({ handle, selected }: { handle: string; selected: DashboardWindow }) { + return ( + + ); +} + +function Comparison({ week, month }: { week: HandleStats; month: HandleStats }) { + const rows: Array<[string, (stats: HandleStats) => string]> = [ + ['Conversations', (x) => fmt(x.totals.conversations)], + ['Visitor messages accepted', (x) => fmt(x.totals.visitorMessages)], + ['Replies delivered', (x) => fmt(x.totals.repliesDelivered)], + ['Visitor-days', (x) => fmt(x.totals.visitorDays)], + ['Median reply latency', (x) => (x.latencyMs ? latency(x.latencyMs.p50) : '—')], + ['p95 reply latency', (x) => (x.latencyMs ? latency(x.latencyMs.p95) : '—')], + ]; + return ( +
+

7 days and 30 days

+
+ + + + + + + + + + {rows.map(([label, value]) => ( + + + + + + ))} + +
MetricLast 7 days{week.estimated ? ' (estimated)' : ''}Last 30 days{month.estimated ? ' (estimated)' : ''}
{label}{value(week)}{value(month)}
+
+
+ ); +} + +function WindowView({ stats }: { stats: HandleStats }) { + const { totals } = stats; + const empty = totals.conversations === 0 && totals.visitorMessages === 0 && totals.guideFetches === 0 + && stats.daily.every((day) => day.conversations === 0 && day.dailyUniqueVisitors === 0); + return ( +
+

+ Last {stats.windowDays} days + {stats.estimated && estimated (sampled)} +

+

+ Updated {formatTimestamp(stats.generatedAt)}. Days are UTC. +

+ + {empty ? ( +
+

No traffic recorded in this window yet.

+

{COLLECTION_NOTE}

+
+ ) : ( + <> +
+ + + + + + + + +
+

+ Visitors are counted once per day; the period total is the sum of daily visitors (visitor-days), not unique + people. +

+ + + +
+ + + +
+ + )} +
+ ); +} + +function Tile({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function DailyTable({ stats }: { stats: HandleStats }) { + const max = Math.max(1, ...stats.daily.map((day) => day.conversations)); + return ( +
+

Per UTC day

+
+ + + + + + + + + + + + {stats.daily.map((day) => ( + + + + + + + + ))} + +
DayConversationsVisitor messagesReplies deliveredDaily unique visitors
+ + + {fmt(day.visitorMessages)}{fmt(day.repliesDelivered)}{fmt(day.dailyUniqueVisitors)}
+
+
+ ); +} + +function Breakdown({ title, rows, labels }: { + title: string; + rows: Array<{ name: string; count: number }>; + labels?: Record; +}) { + const total = rows.reduce((sum, row) => sum + row.count, 0); + return ( +
+

{title}

+ {rows.length === 0 ? ( +

None recorded.

+ ) : ( + + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
NameCountShare
{labels?.[row.name] ?? row.name}{fmt(row.count)}{total ? `${Math.round((row.count / total) * 100)}%` : '—'}
+ )} +
+ ); +} + +function fmt(value: number): string { + return numberFormat.format(value); +} + +function latency(ms: number): string { + return ms >= 1_000 ? `${(ms / 1_000).toFixed(1)} s` : `${Math.round(ms)} ms`; +} + +function formatDay(day: string): string { + return new Date(`${day}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); +} + +function formatTimestamp(iso: string): string { + return `${new Date(iso).toISOString().slice(0, 16).replace('T', ' ')} UTC`; +} diff --git a/web/lib/agent-dashboard.ts b/web/lib/agent-dashboard.ts new file mode 100644 index 00000000..fd26cf44 --- /dev/null +++ b/web/lib/agent-dashboard.ts @@ -0,0 +1,227 @@ +// Server-only helpers for the owner analytics dashboard at /u//dashboard. +// +// The owner arrives with a short-lived, single-use grant in the query string. +// That first request never renders a page: next.config.mjs rewrites it (before +// the filesystem routes) to the exchange route handler, which trades the grant +// for a session server-to-server, stores the session in an HttpOnly cookie +// scoped to the dashboard path, and 303s to the bare URL. The page then reads +// the cookie server-side and fetches stats with it. Neither the grant nor the +// session is ever handed to a client component, logged, or echoed back. + +import { readBoundedBody, REGISTRY_ORIGIN, validRegistryHandle } from './agent-registry'; + +export const DASHBOARD_SESSION_COOKIE = '__Secure-relay_dashboard'; +export const DASHBOARD_WINDOWS = [7, 30] as const; +export type DashboardWindow = (typeof DASHBOARD_WINDOWS)[number]; + +const REQUEST_TIMEOUT_MS = 5_000; +const MAX_EXCHANGE_BYTES = 8_000; +const MAX_STATS_BYTES = 64_000; +const MAX_GRANT_LENGTH = 4_096; +// RFC 6265 cookie-octet, so the session can be stored without encoding. +const SESSION_PATTERN = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]{16,2048}$/; +const GRANT_PATTERN = /^[\x21-\x7E]+$/; + +/** Headers for the exchange and its redirect: nothing on them may load or be framed. */ +export const DASHBOARD_EXCHANGE_HEADERS: Readonly> = { + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + 'X-Content-Type-Options': 'nosniff', +}; + +/** Headers for the rendered dashboard page (applied in next.config.mjs). */ +export const DASHBOARD_PAGE_HEADERS: Readonly> = { + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + 'Content-Security-Policy': "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'", +}; + +export function dashboardPath(handle: string): string { + return `/u/${handle}/dashboard`; +} + +export function parseDashboardWindow(value: string | string[] | undefined): DashboardWindow { + return value === '30' ? 30 : 7; +} + +type DashboardSession = { session: string; expiresAt: Date }; + +/** Trade a grant for a session. Any failure (expired, used, flag off, outage) is null. */ +export async function exchangeDashboardGrant( + handle: string, + grant: string, + fetcher: typeof globalThis.fetch = globalThis.fetch, + now: () => number = Date.now, +): Promise { + if (!validRegistryHandle(handle)) return null; + if (grant.length > MAX_GRANT_LENGTH || !GRANT_PATTERN.test(grant)) return null; + try { + const response = await fetcher( + `${REGISTRY_ORIGIN}/api/v1/agents/${encodeURIComponent(handle)}/dashboard/exchange`, + { + method: 'POST', + cache: 'no-store', + redirect: 'manual', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ grant }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + if (response.status !== 200) { + await response.body?.cancel(); + return null; + } + const value: unknown = JSON.parse(await readBoundedBody(response, MAX_EXCHANGE_BYTES)); + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const { session, expiresAt } = value as Record; + if (typeof session !== 'string' || !SESSION_PATTERN.test(session)) return null; + if (typeof expiresAt !== 'string') return null; + const expiry = new Date(expiresAt); + if (!Number.isFinite(expiry.getTime()) || expiry.getTime() <= now()) return null; + return { session, expiresAt: expiry }; + } catch { + return null; + } +} + +/** Set-Cookie value: HttpOnly, Secure, Lax, dashboard-path scoped, same absolute expiry as the session. */ +export function dashboardSessionCookie(handle: string, { session, expiresAt }: DashboardSession): string { + return [ + `${DASHBOARD_SESSION_COOKIE}=${session}`, + `Path=${dashboardPath(handle)}`, + `Expires=${expiresAt.toUTCString()}`, + 'HttpOnly', + 'Secure', + 'SameSite=Lax', + ].join('; '); +} + +/** + * Handle `/u//dashboard?grant=...`: exchange, then 303 to the bare URL. + * Success sets the session cookie; failure sets nothing, so the page shows the + * expired-link state. The grant is never reflected in the response. + */ +export async function handleDashboardExchange( + request: Request, + handle: string, + fetcher: typeof globalThis.fetch = globalThis.fetch, +): Promise { + if (!validRegistryHandle(handle)) { + return new Response('Not found\n', { + status: 404, + headers: { ...DASHBOARD_EXCHANGE_HEADERS, 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } + const headers = new Headers(DASHBOARD_EXCHANGE_HEADERS); + headers.set('Location', dashboardPath(handle)); + const grant = new URL(request.url).searchParams.get('grant'); + if (grant) { + const session = await exchangeDashboardGrant(handle, grant, fetcher); + if (session) headers.append('Set-Cookie', dashboardSessionCookie(handle, session)); + } + return new Response(null, { status: 303, headers }); +} + +type CountRow = { name: string; count: number }; + +export type HandleStats = { + handle: string; + windowDays: DashboardWindow; + generatedAt: string; + estimated: boolean; + totals: { + guideFetches: number; + conversations: number; + visitorMessages: number; + repliesDelivered: number; + waitPolls: number; + visitorDays: number; + }; + latencyMs: { p50: number; p95: number } | null; + daily: Array<{ + day: string; + conversations: number; + visitorMessages: number; + repliesDelivered: number; + dailyUniqueVisitors: number; + }>; + clients: CountRow[]; + countries: CountRow[]; + outcomes: CountRow[]; +}; + +export type DashboardStatsResult = + | { kind: 'ok'; stats: HandleStats } + | { kind: 'unauthorized' } + | { kind: 'unavailable' }; + +export async function fetchDashboardStats( + handle: string, + session: string, + windowDays: DashboardWindow, + fetcher: typeof globalThis.fetch = globalThis.fetch, +): Promise { + if (!validRegistryHandle(handle) || !SESSION_PATTERN.test(session)) return { kind: 'unauthorized' }; + try { + const response = await fetcher( + `${REGISTRY_ORIGIN}/api/v1/agents/${encodeURIComponent(handle)}/dashboard/stats?window=${windowDays}`, + { + cache: 'no-store', + redirect: 'manual', + headers: { accept: 'application/json', authorization: `Bearer ${session}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + if (response.status === 401 || response.status === 403) { + await response.body?.cancel(); + return { kind: 'unauthorized' }; + } + if (response.status !== 200) { + await response.body?.cancel(); + return { kind: 'unavailable' }; + } + const value: unknown = JSON.parse(await readBoundedBody(response, MAX_STATS_BYTES)); + if (!isHandleStats(value, handle, windowDays)) return { kind: 'unavailable' }; + return { kind: 'ok', stats: value }; + } catch { + return { kind: 'unavailable' }; + } +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isCount(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function hasCounts(value: unknown, keys: readonly string[]): boolean { + return isRecord(value) && keys.every((key) => isCount(value[key])); +} + +function isCountRows(value: unknown): value is CountRow[] { + return Array.isArray(value) && value.length <= 50 && value.every((row) => + isRecord(row) && typeof row.name === 'string' && row.name.length >= 1 && row.name.length <= 100 && isCount(row.count)); +} + +export function isHandleStats(value: unknown, handle: string, windowDays: DashboardWindow): value is HandleStats { + if (!isRecord(value)) return false; + return value.handle === handle + && value.windowDays === windowDays + && typeof value.generatedAt === 'string' + && Number.isFinite(Date.parse(value.generatedAt)) + && typeof value.estimated === 'boolean' + && hasCounts(value.totals, ['guideFetches', 'conversations', 'visitorMessages', 'repliesDelivered', 'waitPolls', 'visitorDays']) + && (value.latencyMs === null || hasCounts(value.latencyMs, ['p50', 'p95'])) + && Array.isArray(value.daily) + && value.daily.length <= 31 + && value.daily.every((day) => isRecord(day) + && typeof day.day === 'string' + && /^\d{4}-\d{2}-\d{2}$/.test(day.day) + && hasCounts(day, ['conversations', 'visitorMessages', 'repliesDelivered', 'dailyUniqueVisitors'])) + && isCountRows(value.clients) + && isCountRows(value.countries) + && isCountRows(value.outcomes); +} diff --git a/web/lib/agent-registry.ts b/web/lib/agent-registry.ts index 8a400c7c..8c80b53c 100644 --- a/web/lib/agent-registry.ts +++ b/web/lib/agent-registry.ts @@ -3,7 +3,7 @@ import { cache } from 'react'; const HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])$/; const MAX_PROFILE_BYTES = 16_000; const PROFILE_TIMEOUT_MS = 5_000; -const REGISTRY_ORIGIN = 'https://arelay.to'; +export const REGISTRY_ORIGIN = 'https://arelay.to'; export type PublicAgentProfile = { handle: string; @@ -48,7 +48,7 @@ export async function fetchAgentProfile( return value; } -async function readBoundedBody(response: Response, limit: number): Promise { +export async function readBoundedBody(response: Response, limit: number): Promise { if (!response.body) return ''; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; diff --git a/web/lib/site-analytics.ts b/web/lib/site-analytics.ts index df6b0e5e..2fd9977c 100644 --- a/web/lib/site-analytics.ts +++ b/web/lib/site-analytics.ts @@ -10,6 +10,35 @@ const TRACKED_ROUTE_PREFIXES = [ const EXCLUDED_ROUTE_PREFIXES = ['/openclaw/skill/invite/'] as const; +// The owner analytics dashboard (and its grant exchange) is private: no page +// views, autocapture, replay, or page-leave events are ever sent from it. +const AGENT_DASHBOARD_PATH = /^\/u\/[^/]+\/dashboard(?:\/|$)/; + +export function isAgentDashboardPath(pathname: string | null | undefined): boolean { + return !!pathname && AGENT_DASHBOARD_PATH.test(pathname); +} + +function pathnameOf(url: unknown): string | null { + if (typeof url !== 'string') return null; + try { + return new URL(url, 'https://agentrelay.com').pathname; + } catch { + return null; + } +} + +/** PostHog before_send: drop every event raised on, or describing, a dashboard page. */ +export function dropAgentDashboardAnalytics(event: T | null): T | null { + if (!event) return event; + if (isAgentDashboardPath(globalThis.location?.pathname)) return null; + const properties = (event as { properties?: Record }).properties; + if (properties && ( + isAgentDashboardPath(pathnameOf(properties.$current_url)) + || isAgentDashboardPath(typeof properties.$pathname === 'string' ? properties.$pathname : null) + )) return null; + return event; +} + export type WebsiteAnalyticsPage = { pageGroup: (typeof TRACKED_ROUTE_PREFIXES)[number]['pageGroup']; pathname: string; @@ -21,6 +50,7 @@ function matchesPrefix(pathname: string, prefix: string): boolean { export function getWebsiteAnalyticsPage(pathname: string | null | undefined): WebsiteAnalyticsPage | null { if (!pathname) return null; + if (isAgentDashboardPath(pathname)) return null; if (EXCLUDED_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null; const match = TRACKED_ROUTE_PREFIXES.find(({ prefix }) => matchesPrefix(pathname, prefix)); diff --git a/web/lib/test/agent-dashboard-page.test.tsx b/web/lib/test/agent-dashboard-page.test.tsx new file mode 100644 index 00000000..14c0bbd8 --- /dev/null +++ b/web/lib/test/agent-dashboard-page.test.tsx @@ -0,0 +1,126 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const cookieJar = vi.hoisted(() => ({ value: undefined as string | undefined })); + +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (name === '__Secure-relay_dashboard' && cookieJar.value + ? { name, value: cookieJar.value } + : undefined), + }), +})); +vi.mock('../../components/SiteNav', () => ({ SiteNav: () => null })); +vi.mock('../../components/SiteFooter', () => ({ SiteFooter: () => null })); + +import AgentDashboardPage, { metadata } from '../../app/u/[handle]/dashboard/page'; + +const SESSION = 'sess_0123456789abcdefghijklmnop'; + +function stats(windowDays: 7 | 30, overrides: Record = {}) { + return { + handle: 'acme-support', + windowDays, + generatedAt: '2026-10-07T12:00:00.000Z', + estimated: false, + totals: { guideFetches: 12, conversations: 5, visitorMessages: 9, repliesDelivered: 8, waitPolls: 30, visitorDays: 6 }, + latencyMs: { p50: 850, p95: 2400 }, + daily: [{ day: '2026-10-07', conversations: 5, visitorMessages: 9, repliesDelivered: 8, dailyUniqueVisitors: 6 }], + clients: [{ name: 'claude-code', count: 5 }], + countries: [{ name: 'US', count: 4 }, { name: 'Other', count: 1 }], + outcomes: [{ name: 'ok', count: 8 }, { name: 'delivery-failed', count: 1 }], + ...overrides, + }; +} + +function statsFetch(handler: (windowDays: 7 | 30) => Response) { + return vi.fn(async (url: string) => handler(new URL(url).searchParams.get('window') === '30' ? 30 : 7)); +} + +async function render(search: Record = {}, handle = 'acme-support') { + const element = await AgentDashboardPage({ + params: Promise.resolve({ handle }), + searchParams: Promise.resolve(search), + }); + return renderToStaticMarkup(element); +} + +beforeEach(() => { + cookieJar.value = SESSION; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('agent dashboard page', () => { + it('is never indexed', () => { + expect(metadata.robots).toMatchObject({ index: false }); + }); + + it('fetches both windows server-side with the Bearer session and never renders the session', async () => { + const fetcher = statsFetch((w) => Response.json(stats(w, w === 30 ? { estimated: true } : {}))); + vi.stubGlobal('fetch', fetcher); + const html = await render({ window: '30' }); + + expect(fetcher).toHaveBeenCalledTimes(2); + const urls = fetcher.mock.calls.map(([url]) => url).sort(); + expect(urls).toEqual([ + 'https://arelay.to/api/v1/agents/acme-support/dashboard/stats?window=30', + 'https://arelay.to/api/v1/agents/acme-support/dashboard/stats?window=7', + ]); + for (const call of fetcher.mock.calls as unknown as Array<[string, RequestInit]>) { + expect((call[1].headers as Record).authorization).toBe(`Bearer ${SESSION}`); + } + expect(html).not.toContain(SESSION); + expect(html).toContain('Last 30 days'); + expect(html).toContain('estimated (sampled)'); + expect(html).toContain('Visitor-days'); + expect(html).toContain('the sum of daily visitors (visitor-days), not unique people'); + expect(html).toContain('850 ms'); + expect(html).toContain('2.4 s'); + expect(html).toContain('Delivery failed'); + expect(html).toContain('Other'); + expect(html).not.toMatch(/posthog/i); + }); + + it('shows the expired state without a cookie and does not call the registry', async () => { + cookieJar.value = undefined; + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + const html = await render(); + expect(fetcher).not.toHaveBeenCalled(); + expect(html).toContain('This dashboard link has expired'); + expect(html).toContain('POST /api/v1/agents/acme-support/manage/dashboard-link'); + }); + + it('shows the expired state on a 401', async () => { + vi.stubGlobal('fetch', statsFetch(() => new Response('{}', { status: 401 }))); + const html = await render(); + expect(html).toContain('This dashboard link has expired'); + expect(html).not.toContain(SESSION); + }); + + it('shows the unavailable state on a 503', async () => { + vi.stubGlobal('fetch', statsFetch(() => Response.json({ code: 'analytics_unavailable' }, { status: 503 }))); + const html = await render(); + expect(html).toContain('Analytics are temporarily unavailable'); + }); + + it('explains the empty state without implying imported history', async () => { + const empty = { + totals: { guideFetches: 0, conversations: 0, visitorMessages: 0, repliesDelivered: 0, waitPolls: 0, visitorDays: 0 }, + latencyMs: null, + daily: [], + clients: [], + countries: [], + outcomes: [], + }; + vi.stubGlobal('fetch', statsFetch((w) => Response.json(stats(w, empty)))); + const html = await render(); + expect(html).toContain( + 'Collection is server-side and started when analytics shipped; earlier traffic is not included.', + ); + expect(html).not.toMatch(/import/i); + }); +}); diff --git a/web/lib/test/agent-dashboard.test.ts b/web/lib/test/agent-dashboard.test.ts new file mode 100644 index 00000000..62b146c5 --- /dev/null +++ b/web/lib/test/agent-dashboard.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from '../../app/u/[handle]/dashboard/exchange/route'; +import { + DASHBOARD_PAGE_HEADERS, + DASHBOARD_SESSION_COOKIE, + fetchDashboardStats, + handleDashboardExchange, + parseDashboardWindow, +} from '../agent-dashboard'; +import { dropAgentDashboardAnalytics, getWebsiteAnalyticsPage, isAgentDashboardPath } from '../site-analytics'; + +const GRANT = 'g1.eyJoYW5kbGUiOiJhY21lLXN1cHBvcnQifQ.c2lnbmF0dXJlLXNlY3JldA'; +const SESSION = 'sess_0123456789abcdefghijklmnop'; +const EXPIRES_AT = '2099-01-01T00:15:00.000Z'; + +const STATS = { + handle: 'acme-support', + windowDays: 7, + generatedAt: '2026-10-07T12:00:00.000Z', + estimated: false, + totals: { guideFetches: 12, conversations: 5, visitorMessages: 9, repliesDelivered: 8, waitPolls: 30, visitorDays: 6 }, + latencyMs: { p50: 850, p95: 2400 }, + daily: [ + { day: '2026-10-06', conversations: 2, visitorMessages: 4, repliesDelivered: 3, dailyUniqueVisitors: 2 }, + { day: '2026-10-07', conversations: 3, visitorMessages: 5, repliesDelivered: 5, dailyUniqueVisitors: 4 }, + ], + clients: [{ name: 'claude-code', count: 4 }, { name: 'codex', count: 1 }], + countries: [{ name: 'US', count: 3 }, { name: 'Other', count: 2 }], + outcomes: [{ name: 'ok', count: 8 }, { name: 'timeout', count: 1 }], +}; + +function exchangeRequest(handle = 'acme-support', grant = GRANT) { + return new Request(`https://agentrelay.com/u/${handle}/dashboard?grant=${encodeURIComponent(grant)}`); +} + +function allHeaderText(response: Response): string { + return [...response.headers.entries()].map(([k, v]) => `${k}: ${v}`).join('\n'); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('dashboard grant exchange', () => { + it('exchanges the grant server-to-server and sets a scoped, non-extended session cookie', async () => { + const fetcher = vi.fn(async () => Response.json({ session: SESSION, expiresAt: EXPIRES_AT })); + const response = await handleDashboardExchange(exchangeRequest(), 'acme-support', fetcher as typeof fetch); + + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://arelay.to/api/v1/agents/acme-support/dashboard/exchange'); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ grant: GRANT }); + + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe('/u/acme-support/dashboard'); + const cookie = response.headers.get('set-cookie') ?? ''; + const parts = cookie.split(/;\s*/); + expect(parts[0]).toBe(`${DASHBOARD_SESSION_COOKIE}=${SESSION}`); + expect(parts).toContain('HttpOnly'); + expect(parts).toContain('Secure'); + expect(parts).toContain('SameSite=Lax'); + expect(parts).toContain('Path=/u/acme-support/dashboard'); + expect(parts).toContain(`Expires=${new Date(EXPIRES_AT).toUTCString()}`); + expect(cookie).not.toMatch(/Max-Age/i); + expect(new Date(cookie.match(/Expires=([^;]+)/)![1]).getTime()).toBe(Date.parse(EXPIRES_AT)); + }); + + it('sets private, no-referrer, and locked-down CSP headers on the redirect', async () => { + const fetcher = vi.fn(async () => Response.json({ session: SESSION, expiresAt: EXPIRES_AT })); + const response = await handleDashboardExchange(exchangeRequest(), 'acme-support', fetcher as typeof fetch); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + const csp = response.headers.get('content-security-policy') ?? ''; + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + it('never echoes the grant in the response', async () => { + for (const fetcher of [ + vi.fn(async () => Response.json({ session: SESSION, expiresAt: EXPIRES_AT })), + vi.fn(async () => Response.json({ code: 'invalid_grant' }, { status: 401 })), + ]) { + const response = await handleDashboardExchange(exchangeRequest(), 'acme-support', fetcher as typeof fetch); + expect(response.headers.get('location')).not.toContain('grant'); + expect(allHeaderText(response)).not.toContain(GRANT); + expect(await response.text()).toBe(''); + } + }); + + it.each([ + ['an invalid grant', async () => Response.json({ code: 'invalid_grant' }, { status: 401 })], + ['the feature flag being off', async () => new Response('not found', { status: 404 })], + ['a registry outage', async () => { throw new Error('network down'); }], + ['an already-expired session', async () => Response.json({ session: SESSION, expiresAt: '2000-01-01T00:00:00Z' })], + ['a malformed session', async () => Response.json({ session: 'bad; Path=/', expiresAt: EXPIRES_AT })], + ])('redirects without a cookie on %s', async (_label, impl) => { + const fetcher = vi.fn(impl); + const response = await handleDashboardExchange(exchangeRequest(), 'acme-support', fetcher as typeof fetch); + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe('/u/acme-support/dashboard'); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('redirects without calling the registry when there is no grant', async () => { + const fetcher = vi.fn(); + const response = await handleDashboardExchange( + new Request('https://agentrelay.com/u/acme-support/dashboard'), 'acme-support', fetcher as typeof fetch, + ); + expect(response.status).toBe(303); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('404s an invalid handle without calling the registry', async () => { + const fetcher = vi.fn(); + for (const handle of ['Acme', '-acme', 'a', 'acme_support', '../x']) { + const response = await handleDashboardExchange(exchangeRequest('x'), handle, fetcher as typeof fetch); + expect(response.status).toBe(404); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(await response.text()).not.toContain(GRANT); + } + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('is wired as a GET route handler using the global fetch', async () => { + const fetcher = vi.fn(async () => Response.json({ session: SESSION, expiresAt: EXPIRES_AT })); + vi.stubGlobal('fetch', fetcher); + const response = await GET(exchangeRequest(), { params: Promise.resolve({ handle: 'acme-support' }) }); + expect(response.status).toBe(303); + expect(response.headers.get('set-cookie')).toContain(`${DASHBOARD_SESSION_COOKIE}=${SESSION}`); + const invalid = await GET(exchangeRequest(), { params: Promise.resolve({ handle: 'NOPE' }) }); + expect(invalid.status).toBe(404); + }); +}); + +// next.config.mjs is untyped JS; load it as a plain module. +async function loadNextConfig() { + const configPath: string = '../../next.config.mjs'; + return (await import(configPath)).default; +} + +describe('next.config dashboard routing', () => { + it('rewrites grant-bearing dashboard requests to the exchange before filesystem routes', async () => { + const rewrites = await (await loadNextConfig()).rewrites(); + expect(rewrites.beforeFiles).toContainEqual({ + source: '/u/:handle/dashboard', + has: [{ type: 'query', key: 'grant', value: '.+' }], + destination: '/u/:handle/dashboard/exchange', + }); + }); + + it('applies the page headers to the bare dashboard URL only', async () => { + const rules = await (await loadNextConfig()).headers(); + const rule = rules.find((r: { source: string }) => r.source === '/u/:handle/dashboard'); + expect(rule.missing).toEqual([{ type: 'query', key: 'grant', value: '.+' }]); + const headers = Object.fromEntries(rule.headers.map((h: { key: string; value: string }) => [h.key, h.value])); + for (const [key, value] of Object.entries(DASHBOARD_PAGE_HEADERS)) expect(headers[key]).toBe(value); + expect(headers['Content-Security-Policy']).toContain("frame-ancestors 'none'"); + }); +}); + +describe('dashboard stats', () => { + it('fetches stats with the Bearer session and validates the shape', async () => { + const fetcher = vi.fn(async () => Response.json(STATS)); + const result = await fetchDashboardStats('acme-support', SESSION, 7, fetcher as typeof fetch); + expect(result).toEqual({ kind: 'ok', stats: STATS }); + const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://arelay.to/api/v1/agents/acme-support/dashboard/stats?window=7'); + expect((init.headers as Record).authorization).toBe(`Bearer ${SESSION}`); + expect(init.cache).toBe('no-store'); + }); + + it('maps 401 to unauthorized and 503 or bad shapes to unavailable', async () => { + const unauthorized = vi.fn(async () => new Response('{}', { status: 401 })); + await expect(fetchDashboardStats('acme-support', SESSION, 30, unauthorized as typeof fetch)) + .resolves.toEqual({ kind: 'unauthorized' }); + const unavailable = vi.fn(async () => Response.json({ code: 'analytics_unavailable' }, { status: 503 })); + await expect(fetchDashboardStats('acme-support', SESSION, 30, unavailable as typeof fetch)) + .resolves.toEqual({ kind: 'unavailable' }); + const wrongWindow = vi.fn(async () => Response.json(STATS)); + await expect(fetchDashboardStats('acme-support', SESSION, 30, wrongWindow as typeof fetch)) + .resolves.toEqual({ kind: 'unavailable' }); + }); + + it('allowlists the window', () => { + expect(parseDashboardWindow('30')).toBe(30); + expect(parseDashboardWindow('7')).toBe(7); + expect(parseDashboardWindow('90')).toBe(7); + expect(parseDashboardWindow(['30', '7'])).toBe(7); + expect(parseDashboardWindow(undefined)).toBe(7); + }); +}); + +describe('dashboard analytics exclusion', () => { + it('matches dashboard paths and subpaths only', () => { + expect(isAgentDashboardPath('/u/acme-support/dashboard')).toBe(true); + expect(isAgentDashboardPath('/u/acme-support/dashboard/')).toBe(true); + expect(isAgentDashboardPath('/u/acme-support/dashboard/exchange')).toBe(true); + expect(isAgentDashboardPath('/u/acme-support')).toBe(false); + expect(isAgentDashboardPath('/u/acme-support/dashboards')).toBe(false); + expect(isAgentDashboardPath('/docs/u/x/dashboard')).toBe(false); + expect(isAgentDashboardPath(null)).toBe(false); + }); + + it('never tracks a page view on a dashboard path', () => { + expect(getWebsiteAnalyticsPage('/u/acme-support/dashboard')).toBeNull(); + expect(getWebsiteAnalyticsPage('/u/acme-support/dashboard/exchange')).toBeNull(); + }); + + it('drops PostHog events raised on or describing a dashboard page', () => { + const onDashboard = { event: '$autocapture', properties: { $current_url: 'https://agentrelay.com/u/acme-support/dashboard?window=30' } }; + expect(dropAgentDashboardAnalytics(onDashboard)).toBeNull(); + expect(dropAgentDashboardAnalytics({ event: '$pageleave', properties: { $pathname: '/u/a1/dashboard' } })).toBeNull(); + const elsewhere = { event: '$pageview', properties: { $current_url: 'https://agentrelay.com/docs' } }; + expect(dropAgentDashboardAnalytics(elsewhere)).toBe(elsewhere); + + vi.stubGlobal('location', { pathname: '/u/acme-support/dashboard' }); + expect(dropAgentDashboardAnalytics({ event: '$snapshot', properties: {} })).toBeNull(); + }); +}); diff --git a/web/next.config.mjs b/web/next.config.mjs index 3cef7584..b6f621cb 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -85,6 +85,24 @@ const nextConfig = { source: '/well-known/:path*', headers: [{ key: 'X-Robots-Tag', value: 'noindex' }], }, + { + // The owner analytics dashboard: private, never cached, never framed, + // never leaks its URL. The grant-bearing first request is rewritten to + // the exchange route below and sets its own stricter headers, so it is + // excluded here. Kept in sync with DASHBOARD_PAGE_HEADERS in + // lib/agent-dashboard.ts (asserted by lib/test/agent-dashboard.test.ts). + source: '/u/:handle/dashboard', + missing: [{ type: 'query', key: 'grant', value: '.+' }], + headers: [ + { key: 'Cache-Control', value: 'private, no-store' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'none'", + }, + { key: 'X-Robots-Tag', value: 'noindex, nofollow' }, + ], + }, ]; }, async rewrites() { @@ -100,7 +118,19 @@ const nextConfig = { cloudProxy.push({ source: '/cloud/:path*', destination: `${origin.origin}/cloud/:path*` }); } return { - beforeFiles: cloudProxy, + beforeFiles: [ + ...cloudProxy, + // A dashboard link carries a single-use grant. Exchange it in a route + // handler before any page, layout, or PostHog code can run; the handler + // sets the session cookie and 303s to the bare dashboard URL. `value` + // is explicit because OpenNext treats a valueless query `has` as + // always matching. + { + source: '/u/:handle/dashboard', + has: [{ type: 'query', key: 'grant', value: '.+' }], + destination: '/u/:handle/dashboard/exchange', + }, + ], afterFiles: [ // Conventional llms.txt path under /docs resolves to the root route. { source: '/docs/llms.txt', destination: '/llms.txt' }, From ced0e220f146d7dcea97fe9daae2b56225b40576 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:20:28 -0700 Subject: [PATCH 2/3] fix(web): confirm-before-redeem dashboard links; clear stale sessions - GET/HEAD on a grant link renders a static, script-free confirmation; only its same-origin POST redeems the single-use grant, so link scanners, unfurlers and prefetchers cannot burn it. - A failed exchange clears any older handle-scoped session cookie. - The empty state considers every displayed metric. - Dashboard content is blocked from session replay (ph-no-capture). - Zero-conversation days render a zero-width bar. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: 8fa30995-3394-4357-aadf-23aee1464578 --- .../u/[handle]/dashboard/dashboard.module.css | 2 +- .../u/[handle]/dashboard/exchange/route.ts | 13 ++- web/app/u/[handle]/dashboard/page.tsx | 10 +- web/lib/agent-dashboard.ts | 106 +++++++++++++++--- web/lib/test/agent-dashboard.test.ts | 76 ++++++++++--- 5 files changed, 173 insertions(+), 34 deletions(-) diff --git a/web/app/u/[handle]/dashboard/dashboard.module.css b/web/app/u/[handle]/dashboard/dashboard.module.css index 93555ded..d180e5d4 100644 --- a/web/app/u/[handle]/dashboard/dashboard.module.css +++ b/web/app/u/[handle]/dashboard/dashboard.module.css @@ -210,7 +210,7 @@ .bar { display: block; height: 10px; - min-width: 2px; + min-width: 0; border-radius: 3px; background: var(--flows-accent); } diff --git a/web/app/u/[handle]/dashboard/exchange/route.ts b/web/app/u/[handle]/dashboard/exchange/route.ts index c2c6d62b..e081a32c 100644 --- a/web/app/u/[handle]/dashboard/exchange/route.ts +++ b/web/app/u/[handle]/dashboard/exchange/route.ts @@ -1,8 +1,10 @@ -import { handleDashboardExchange } from '../../../../../lib/agent-dashboard'; +import { dashboardConfirmPage, handleDashboardExchange } from '../../../../../lib/agent-dashboard'; // `/u//dashboard?grant=...` is rewritten here by next.config.mjs -// (beforeFiles, keyed on the grant query), so the grant is exchanged before -// any page, layout, or analytics code runs. See lib/agent-dashboard.ts. +// (beforeFiles, keyed on the grant query), so no page, layout, or analytics +// code runs for a grant-bearing request. GET only shows a static confirmation +// page; the single-use grant is redeemed by its same-origin POST, so link +// scanners and prefetchers cannot burn it. See lib/agent-dashboard.ts. export const dynamic = 'force-dynamic'; type RouteContext = { @@ -10,6 +12,11 @@ type RouteContext = { }; export async function GET(request: Request, { params }: RouteContext) { + const { handle } = await params; + return dashboardConfirmPage(request, handle); +} + +export async function POST(request: Request, { params }: RouteContext) { const { handle } = await params; return handleDashboardExchange(request, handle); } diff --git a/web/app/u/[handle]/dashboard/page.tsx b/web/app/u/[handle]/dashboard/page.tsx index 0b1befc5..f0f7c74f 100644 --- a/web/app/u/[handle]/dashboard/page.tsx +++ b/web/app/u/[handle]/dashboard/page.tsx @@ -88,7 +88,8 @@ function Shell({ handle, children }: { handle: string; children: ReactNode }) {
Skip to content -
+ {/* ph-sensitive/ph-no-capture: session replay must never record dashboard data. */} +

Agent dashboard

{handle}

@@ -167,8 +168,11 @@ function Comparison({ week, month }: { week: HandleStats; month: HandleStats }) function WindowView({ stats }: { stats: HandleStats }) { const { totals } = stats; - const empty = totals.conversations === 0 && totals.visitorMessages === 0 && totals.guideFetches === 0 - && stats.daily.every((day) => day.conversations === 0 && day.dailyUniqueVisitors === 0); + const empty = Object.values(totals).every((value) => value === 0) + && stats.latencyMs === null + && stats.daily.every((day) => day.conversations === 0 && day.visitorMessages === 0 + && day.repliesDelivered === 0 && day.dailyUniqueVisitors === 0) + && [...stats.clients, ...stats.countries, ...stats.outcomes].every((row) => row.count === 0); return (

diff --git a/web/lib/agent-dashboard.ts b/web/lib/agent-dashboard.ts index fd26cf44..9ff3d04d 100644 --- a/web/lib/agent-dashboard.ts +++ b/web/lib/agent-dashboard.ts @@ -30,6 +30,18 @@ export const DASHBOARD_EXCHANGE_HEADERS: Readonly> = { 'X-Content-Type-Options': 'nosniff', }; +/** + * Headers for the confirmation interstitial: inline styles only, no scripts, + * and its one form may post only to this origin. + */ +export const DASHBOARD_CONFIRM_HEADERS: Readonly> = { + 'Cache-Control': 'private, no-store', + 'Referrer-Policy': 'no-referrer', + 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'", + 'X-Content-Type-Options': 'nosniff', + 'X-Robots-Tag': 'noindex, nofollow', +}; + /** Headers for the rendered dashboard page (applied in next.config.mjs). */ export const DASHBOARD_PAGE_HEADERS: Readonly> = { 'Cache-Control': 'private, no-store', @@ -97,29 +109,97 @@ export function dashboardSessionCookie(handle: string, { session, expiresAt }: D ].join('; '); } +/** Clears the handle-scoped session so a failed link never shows an older session's data. */ +export function clearDashboardSessionCookie(handle: string): string { + return [ + `${DASHBOARD_SESSION_COOKIE}=`, + `Path=${dashboardPath(handle)}`, + 'Expires=Thu, 01 Jan 1970 00:00:00 GMT', + 'HttpOnly', + 'Secure', + 'SameSite=Lax', + ].join('; '); +} + +function notFound(): Response { + return new Response('Not found\n', { + status: 404, + headers: { ...DASHBOARD_EXCHANGE_HEADERS, 'Content-Type': 'text/plain; charset=utf-8' }, + }); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); +} + +/** + * GET (and HEAD) `/u//dashboard?grant=...` never redeems the grant: link + * scanners, unfurlers and prefetchers would otherwise burn the single-use link. + * It returns a static, script-free page whose button POSTs the grant to the + * exchange. No React page, layout, or analytics code runs for it. + */ +export function dashboardConfirmPage(request: Request, handle: string): Response { + if (!validRegistryHandle(handle)) return notFound(); + const grant = new URL(request.url).searchParams.get('grant'); + if (!grant || grant.length > MAX_GRANT_LENGTH || !GRANT_PATTERN.test(grant)) { + const headers = new Headers(DASHBOARD_EXCHANGE_HEADERS); + headers.set('Location', dashboardPath(handle)); + return new Response(null, { status: 303, headers }); + } + const body = ` + +Open agent dashboard + +

Agent dashboard

Open the analytics dashboard for ${escapeHtml(handle)}. This link works once and expires 15 minutes after it was created.

+
+`; + return new Response(request.method === 'HEAD' ? null : body, { + status: 200, + headers: { ...DASHBOARD_CONFIRM_HEADERS, 'Content-Type': 'text/html; charset=utf-8' }, + }); +} + +function sameOrigin(request: Request): boolean { + const origin = request.headers.get('origin'); + if (!origin || origin === 'null') return false; + const requestUrl = new URL(request.url); + const forwardedHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host'); + try { + const parsed = new URL(origin); + return parsed.host === requestUrl.host || (!!forwardedHost && parsed.host === forwardedHost); + } catch { + return false; + } +} + /** - * Handle `/u//dashboard?grant=...`: exchange, then 303 to the bare URL. - * Success sets the session cookie; failure sets nothing, so the page shows the - * expired-link state. The grant is never reflected in the response. + * POST `/u//dashboard/exchange` from the confirmation page: redeem the + * grant server-to-server, then 303 to the bare URL. Success sets the session + * cookie; failure clears any older session so the page shows the expired-link + * state. The grant is never reflected in the response. */ export async function handleDashboardExchange( request: Request, handle: string, fetcher: typeof globalThis.fetch = globalThis.fetch, ): Promise { - if (!validRegistryHandle(handle)) { - return new Response('Not found\n', { - status: 404, - headers: { ...DASHBOARD_EXCHANGE_HEADERS, 'Content-Type': 'text/plain; charset=utf-8' }, - }); - } + if (!validRegistryHandle(handle)) return notFound(); const headers = new Headers(DASHBOARD_EXCHANGE_HEADERS); headers.set('Location', dashboardPath(handle)); - const grant = new URL(request.url).searchParams.get('grant'); - if (grant) { - const session = await exchangeDashboardGrant(handle, grant, fetcher); - if (session) headers.append('Set-Cookie', dashboardSessionCookie(handle, session)); + let grant: string | null = null; + if (sameOrigin(request)) { + try { + const value = (await request.formData()).get('grant'); + grant = typeof value === 'string' ? value : null; + } catch { + grant = null; + } } + const session = grant ? await exchangeDashboardGrant(handle, grant, fetcher) : null; + headers.append( + 'Set-Cookie', + session ? dashboardSessionCookie(handle, session) : clearDashboardSessionCookie(handle), + ); return new Response(null, { status: 303, headers }); } diff --git a/web/lib/test/agent-dashboard.test.ts b/web/lib/test/agent-dashboard.test.ts index 62b146c5..01d56d4f 100644 --- a/web/lib/test/agent-dashboard.test.ts +++ b/web/lib/test/agent-dashboard.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { GET } from '../../app/u/[handle]/dashboard/exchange/route'; +import { GET, POST } from '../../app/u/[handle]/dashboard/exchange/route'; import { DASHBOARD_PAGE_HEADERS, DASHBOARD_SESSION_COOKIE, + dashboardConfirmPage, fetchDashboardStats, handleDashboardExchange, parseDashboardWindow, @@ -30,8 +31,20 @@ const STATS = { outcomes: [{ name: 'ok', count: 8 }, { name: 'timeout', count: 1 }], }; -function exchangeRequest(handle = 'acme-support', grant = GRANT) { - return new Request(`https://agentrelay.com/u/${handle}/dashboard?grant=${encodeURIComponent(grant)}`); +function linkRequest(handle = 'acme-support', grant = GRANT, method = 'GET') { + return new Request(`https://agentrelay.com/u/${handle}/dashboard?grant=${encodeURIComponent(grant)}`, { method }); +} + +function exchangeRequest(handle = 'acme-support', grant = GRANT, origin: string | null = 'https://agentrelay.com') { + const body = new URLSearchParams({ grant }); + return new Request(`https://agentrelay.com/u/${handle}/dashboard/exchange`, { + method: 'POST', + body, + headers: { + 'content-type': 'application/x-www-form-urlencoded', + ...(origin ? { origin } : {}), + }, + }); } function allHeaderText(response: Response): string { @@ -95,22 +108,27 @@ describe('dashboard grant exchange', () => { ['a registry outage', async () => { throw new Error('network down'); }], ['an already-expired session', async () => Response.json({ session: SESSION, expiresAt: '2000-01-01T00:00:00Z' })], ['a malformed session', async () => Response.json({ session: 'bad; Path=/', expiresAt: EXPIRES_AT })], - ])('redirects without a cookie on %s', async (_label, impl) => { + ])('clears any older session on %s', async (_label, impl) => { const fetcher = vi.fn(impl); const response = await handleDashboardExchange(exchangeRequest(), 'acme-support', fetcher as typeof fetch); expect(response.status).toBe(303); expect(response.headers.get('location')).toBe('/u/acme-support/dashboard'); - expect(response.headers.get('set-cookie')).toBeNull(); + const cookie = response.headers.get('set-cookie') ?? ''; + expect(cookie).toMatch(new RegExp(`^${DASHBOARD_SESSION_COOKIE}=;`)); + expect(cookie).toContain('Path=/u/acme-support/dashboard'); + expect(cookie).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT'); expect(response.headers.get('cache-control')).toBe('private, no-store'); }); - it('redirects without calling the registry when there is no grant', async () => { + it('refuses cross-origin or origin-less exchanges without calling the registry', async () => { const fetcher = vi.fn(); - const response = await handleDashboardExchange( - new Request('https://agentrelay.com/u/acme-support/dashboard'), 'acme-support', fetcher as typeof fetch, - ); - expect(response.status).toBe(303); - expect(response.headers.get('set-cookie')).toBeNull(); + for (const origin of ['https://evil.example', null, 'null']) { + const response = await handleDashboardExchange( + exchangeRequest('acme-support', GRANT, origin), 'acme-support', fetcher as typeof fetch, + ); + expect(response.status).toBe(303); + expect(response.headers.get('set-cookie')).toContain('Expires=Thu, 01 Jan 1970'); + } expect(fetcher).not.toHaveBeenCalled(); }); @@ -126,13 +144,43 @@ describe('dashboard grant exchange', () => { expect(fetcher).not.toHaveBeenCalled(); }); - it('is wired as a GET route handler using the global fetch', async () => { + it('never redeems the grant on GET or HEAD, so scanners and prefetchers cannot burn it', async () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + for (const method of ['GET', 'HEAD']) { + const response = await GET(linkRequest('acme-support', GRANT, method), { + params: Promise.resolve({ handle: 'acme-support' }), + }); + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + const csp = response.headers.get('content-security-policy') ?? ''; + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("form-action 'self'"); + expect(csp).not.toContain('script-src'); + } + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('renders a script-free confirmation that posts the grant to the exchange', async () => { + const response = dashboardConfirmPage(linkRequest(), 'acme-support'); + const html = await response.text(); + expect(html).not.toMatch(/