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..d180e5d4 --- /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: 0; + 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..e081a32c --- /dev/null +++ b/web/app/u/[handle]/dashboard/exchange/route.ts @@ -0,0 +1,22 @@ +import { dashboardConfirmPage, handleDashboardExchange } from '../../../../../lib/agent-dashboard'; + +// `/u//dashboard?grant=...` is rewritten here by next.config.mjs +// (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 = { + params: Promise<{ handle: string }>; +}; + +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 new file mode 100644 index 00000000..c94071b1 --- /dev/null +++ b/web/app/u/[handle]/dashboard/page.tsx @@ -0,0 +1,329 @@ +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, + DASHBOARD_LINK_USED_PARAM, + DASHBOARD_LINK_USED_VALUE, + 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 query = await searchParams; + const selected = parseDashboardWindow(query.window); + // Set only by the exchange when a link could not be redeemed (fixed value, + // never echoed): an older session may still be shown, with this notice. + const linkUsed = query[DASHBOARD_LINK_USED_PARAM] === DASHBOARD_LINK_USED_VALUE; + + // 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 linkNotice = linkUsed ? ( +

+ That dashboard link was already used or has expired. Showing your current session instead. +

+ ) : null; + + 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 ( + + {linkNotice} + + + + + ); +} + +function Shell({ handle, children }: { handle: string; children: ReactNode }) { + return ( +
+ Skip to content + + {/* ph-sensitive/ph-no-capture: session replay must never record dashboard data. */} +
+
+

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 = 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 ( +
+

+ 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..0bc319d1 --- /dev/null +++ b/web/lib/agent-dashboard.ts @@ -0,0 +1,306 @@ +// 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 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', + '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('; '); +} + +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' }, + }); +} + +/** Query marker the exchange adds when a link could not be redeemed. */ +export const DASHBOARD_LINK_USED_PARAM = 'link'; +export const DASHBOARD_LINK_USED_VALUE = 'used'; + +/** Exact HTTPS origin match (scheme, host, and port) against this site. */ +function sameOrigin(request: Request): boolean { + const origin = request.headers.get('origin'); + if (!origin || origin === 'null') return false; + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return false; + } + if (parsed.protocol !== 'https:' || parsed.origin !== origin) return false; + const allowed = new Set([new URL(request.url).origin]); + const forwardedHost = request.headers.get('x-forwarded-host'); + if (forwardedHost) allowed.add(`https://${forwardedHost}`); + return allowed.has(parsed.origin); +} + +/** + * POST `/u//dashboard/exchange` from the confirmation page: redeem the + * grant server-to-server, then 303 to the dashboard. Success sets the session + * cookie. Failure never touches the cookie (Back or a double submit replays an + * already-used grant, and a cross-site form must not be able to log the owner + * out); it redirects with `?link=used` so the page says the link did not work. + * 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 notFound(); + const headers = new Headers(DASHBOARD_EXCHANGE_HEADERS); + 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; + if (session) { + headers.set('Location', dashboardPath(handle)); + headers.append('Set-Cookie', dashboardSessionCookie(handle, session)); + } else { + headers.set('Location', `${dashboardPath(handle)}?${DASHBOARD_LINK_USED_PARAM}=${DASHBOARD_LINK_USED_VALUE}`); + } + 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..9240f298 --- /dev/null +++ b/web/lib/test/agent-dashboard-page.test.tsx @@ -0,0 +1,135 @@ +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('notes an already-used link while keeping the current session', async () => { + vi.stubGlobal('fetch', statsFetch((windowDays) => Response.json(stats(windowDays)))); + const html = await render({ link: 'used' }); + expect(html).toContain('That dashboard link was already used or has expired.'); + expect(html).toContain('Last 7 days'); + const plain = await render({ link: 'something-else' }); + expect(plain).not.toContain('already used or has expired. Showing'); + }); + + 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..7482cb93 --- /dev/null +++ b/web/lib/test/agent-dashboard.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { GET, POST } from '../../app/u/[handle]/dashboard/exchange/route'; +import { + DASHBOARD_PAGE_HEADERS, + DASHBOARD_SESSION_COOKIE, + dashboardConfirmPage, + 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 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 { + 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 })], + ])('flags the used link and leaves any existing session alone 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?link=used'); + // Back or a double submit replays a used grant; it must not log the owner out. + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('refuses cross-origin or origin-less exchanges without calling the registry', async () => { + const fetcher = vi.fn(); + for (const origin of ['https://evil.example', 'http://agentrelay.com', 'https://agentrelay.com:8443', null, 'null']) { + const response = await handleDashboardExchange( + exchangeRequest('acme-support', GRANT, origin), 'acme-support', fetcher as typeof fetch, + ); + expect(response.status).toBe(303); + // A cross-site form can neither redeem a grant nor clear the owner's session. + 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('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(/