diff --git a/.github/workflows/deploy-router.yml b/.github/workflows/deploy-router.yml index 56c23647..3ed90bc9 100644 --- a/.github/workflows/deploy-router.yml +++ b/.github/workflows/deploy-router.yml @@ -59,9 +59,36 @@ jobs: - name: Install dependencies run: npm ci + # Worker secrets ship atomically with the deploy. Only secrets that are + # present are written; an absent ANALYTICS_VISITOR_HASH_SECRET leaves + # guide-fetch analytics failing closed (no data points). + - name: Prepare optional Worker secrets + shell: bash + env: + ANALYTICS_VISITOR_HASH_SECRET: ${{ secrets.ANALYTICS_VISITOR_HASH_SECRET }} + run: | + set -euo pipefail + umask 077 + export ROUTER_SECRETS_FILE="$RUNNER_TEMP/router-secrets.json" + node --input-type=module <<'NODE' + import { writeFileSync } from "node:fs"; + + const names = ["ANALYTICS_VISITOR_HASH_SECRET"]; + const secrets = Object.fromEntries(names.flatMap((name) => { + const value = process.env[name]; + return value ? [[name, value]] : []; + })); + writeFileSync(process.env.ROUTER_SECRETS_FILE, JSON.stringify(secrets), { mode: 0o600 }); + NODE + - name: Deploy router to Cloudflare working-directory: router env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - run: ../node_modules/.bin/wrangler deploy + run: ../node_modules/.bin/wrangler deploy --secrets-file "$RUNNER_TEMP/router-secrets.json" + + - name: Remove temporary Worker secrets file + if: always() + shell: bash + run: rm -f "$RUNNER_TEMP/router-secrets.json" diff --git a/router/index.ts b/router/index.ts index 93e1c362..4b660814 100644 --- a/router/index.ts +++ b/router/index.ts @@ -1,7 +1,8 @@ import { maybeRecord, type RecorderEnv } from "./src/recorder.js"; import { maybeRateLimit, type RateLimitEnv } from "./src/rate-limit.js"; +import { recordGuideFetched, type AnalyticsEnv } from "./src/analytics.js"; -interface Env { +interface Env extends AnalyticsEnv { CLOUD_APP_ORIGIN: string; CLOUD_WEB_WORKER?: { fetch(request: Request): Promise; @@ -214,9 +215,33 @@ export function prefersHtmlOverMarkdown(accept: string | null): boolean { return acceptQuality(accept, "text/html") > acceptQuality(accept, "text/markdown"); } -// Any /u/ page or guide, on any host, after the rewrite above. +// Any /u/ page, guide, or owner dashboard, on any host, after the +// rewrite above. The whole /u/ namespace is excluded, not a list of known +// suffixes, so no dashboard variant can slip past the recorder. export function isAgentPageRequestPath(pathname: string): boolean { - return /^\/u\/[^/]+(?:\/agent\.md)?\/?$/i.test(pathname); + return /^\/u(?:\/|$)/i.test(pathname); +} + +// Dashboard grants are single-use bearer credentials. Any request carrying a +// grant parameter, on any path, stays out of the replay corpus. +export function carriesDashboardGrant(url: URL): boolean { + let found = false; + url.searchParams.forEach((_value, key) => { + if (key.toLowerCase() === "grant") found = true; + }); + return found; +} + +// The handle whose agent guide this request negotiated, when it is an external +// GET that analytics may count. HTML, HEAD and Worker subrequests never count. +export function getGuideAnalyticsHandle( + request: Request, + agentPagePath: string | undefined, +): string | undefined { + if (request.method !== "GET" || !agentPagePath || request.headers.has("cf-worker")) { + return undefined; + } + return /^\/u\/([a-z0-9-]+)\/agent\.md$/.exec(agentPagePath)?.[1]; } // The page first shipped at agentrelay.com/agent-relay; keep that link working. @@ -630,6 +655,11 @@ export default { return Response.redirect(legacyAgentPage, 301); } + const guideAnalyticsHandle = getGuideAnalyticsHandle(request, agentPagePath); + // Keep the original request: rewrites build new Requests, which do not + // carry the incoming request.cf (and with it the country). + const guideAnalyticsRequest = request; + if (agentPagePath && agentPagePath !== url.pathname.replace(/\/$/, "")) { url.pathname = agentPagePath; request = new Request(url.toString(), request); @@ -656,7 +686,10 @@ export default { // content, so neither the new route nor the existing Cloud fallback belongs // in the replay corpus. Agent pages and guides mint a conversation URL in // every response, so they stay out too, however they were reached. - const recorderRequestClone = recorderEnv && !relayAgentRoute && !isAgentPageRequestPath(url.pathname) + const recorderRequestClone = recorderEnv + && !relayAgentRoute + && !isAgentPageRequestPath(url.pathname) + && !carriesDashboardGrant(url) ? (request.clone() as unknown as Request) : null; @@ -827,6 +860,12 @@ export default { ctx.waitUntil(maybeRecord(recordingRequest, response.clone(), env, ctx)); } + // Count a guide fetch only once the upstream guide answered 2xx; + // not-found, suspended, redirect and error responses write nothing. + if (guideAnalyticsHandle && upstreamResponse.status >= 200 && upstreamResponse.status < 300) { + ctx.waitUntil(recordGuideFetched(guideAnalyticsRequest, guideAnalyticsHandle, env)); + } + return response; } catch (error) { return new Response(JSON.stringify({ error: (error as Error).message }), { diff --git a/router/src/analytics.ts b/router/src/analytics.ts new file mode 100644 index 00000000..e41f8c56 --- /dev/null +++ b/router/src/analytics.ts @@ -0,0 +1,148 @@ +// Server-side arelay.to analytics: the router's half of relay-agent's +// docs/ANALYTICS_BRIEF.md. The router writes exactly one event, +// agent_guide_fetched, using the same ordered v1 schema as relay-agent's +// src/analytics.ts. Keep the two layouts identical; the shared tests pin it. +// +// Writes are best-effort and never change a response. No data point carries a +// URL, query string, grant, raw IP, raw User-Agent, or caller-supplied header. + +export const ANALYTICS_SCHEMA_VERSION = "v1"; +export const ANALYTICS_WRITER = "router"; + +export type AnalyticsClientClass = "curl" | "claude-code" | "codex" | "grok" | "other"; + +export interface AnalyticsDataset { + writeDataPoint(point: { + indexes?: string[]; + blobs?: string[]; + doubles?: number[]; + }): void; +} + +export interface AnalyticsEnv { + ARELAY_ANALYTICS?: AnalyticsDataset; + ANALYTICS_VISITOR_HASH_SECRET?: string; +} + +const HANDLE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])$/; +const COUNTRY = /^[A-Z][A-Z0-9]$/; + +const CLIENT_MARKERS: ReadonlyArray<[AnalyticsClientClass, readonly string[]]> = [ + ["claude-code", ["claude-code", "claude-cli", "claudecode", "claude-user"]], + ["codex", ["codex"]], + ["grok", ["grok", "xai-"]], + ["curl", ["curl/"]], +]; + +export function classifyClient(userAgent: string | null | undefined): AnalyticsClientClass { + if (typeof userAgent !== "string" || userAgent.length === 0 || userAgent.length > 1_024) { + return "other"; + } + const lowered = userAgent.toLowerCase(); + for (const [client, markers] of CLIENT_MARKERS) { + if (markers.some((marker) => lowered.includes(marker))) return client; + } + return "other"; +} + +/** Reads `request.cf.country`; caller-controlled headers are never consulted. */ +export function requestCountry(request: Request): string { + const country = (request as Request & { cf?: { country?: unknown } }).cf?.country; + if (typeof country !== "string") return "unknown"; + const upper = country.toUpperCase(); + return COUNTRY.test(upper) ? upper : "unknown"; +} + +const encoder = new TextEncoder(); + +async function hmac(key: ArrayBuffer | Uint8Array, message: string): Promise { + const imported = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return crypto.subtle.sign("HMAC", imported, encoder.encode(message)); +} + +function hex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Daily, handle-scoped visitor pseudonym (identical to relay-agent's). Returns + * undefined when the secret is unavailable or hashing fails; the caller then + * writes nothing. + */ +export async function visitorPseudonym( + secret: string | undefined, + handle: string, + ip: string, + userAgent: string, + now = Date.now(), +): Promise { + if (!secret) return undefined; + try { + const day = new Date(now).toISOString().slice(0, 10); + const dayKey = await hmac(encoder.encode(secret), `arelay-visitors-v1\0${day}`); + return hex(await hmac(dayKey, `${handle}\0${ip}\0${userAgent}`)); + } catch { + return undefined; + } +} + +export function guideFetchedDataPoint( + handle: string, + client: AnalyticsClientClass, + country: string, + visitor: string, +): { indexes: string[]; blobs: string[]; doubles: number[] } | null { + if (!HANDLE.test(handle) || !/^[0-9a-f]{64}$/.test(visitor)) return null; + return { + indexes: [handle], + blobs: [ + ANALYTICS_SCHEMA_VERSION, + "agent_guide_fetched", + handle, + "not_applicable", + client, + COUNTRY.test(country) ? country : "unknown", + "none", + visitor, + ANALYTICS_WRITER, + ], + doubles: [1, -1], + }; +} + +/** + * Records one successful agent guide fetch. Call only after the rewritten + * /u//agent.md upstream returned 2xx for an external GET. + */ +export async function recordGuideFetched( + request: Request, + handle: string, + env: AnalyticsEnv, +): Promise { + const dataset = env.ARELAY_ANALYTICS; + if (!dataset) return; + try { + const visitor = await visitorPseudonym( + env.ANALYTICS_VISITOR_HASH_SECRET, + handle, + request.headers.get("cf-connecting-ip")?.trim() || "unknown", + request.headers.get("user-agent") ?? "", + ); + if (!visitor) return; + const point = guideFetchedDataPoint( + handle, + classifyClient(request.headers.get("user-agent")), + requestCountry(request), + visitor, + ); + if (point) dataset.writeDataPoint(point); + } catch { + console.warn("arelay analytics: data point write failed"); + } +} diff --git a/router/test/guide-analytics.test.ts b/router/test/guide-analytics.test.ts new file mode 100644 index 00000000..0c534b1c --- /dev/null +++ b/router/test/guide-analytics.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import worker, { + carriesDashboardGrant, + getGuideAnalyticsHandle, + isAgentPageRequestPath, +} from "../index.js"; +import { + classifyClient, + guideFetchedDataPoint, + requestCountry, + visitorPseudonym, +} from "../src/analytics.js"; + +const SECRET = "router-test-visitor-hash-secret-0123"; + +type Point = { indexes?: string[]; blobs?: string[]; doubles?: number[] }; + +function setup(status = 200, extra: Record = {}) { + const points: Point[] = []; + const put = vi.fn(); + const upstream = vi.spyOn(globalThis, "fetch") + .mockImplementation(async () => new Response("guide", { status })); + const env = { + CLOUD_APP_ORIGIN: "https://origin.test.invalid", + CLOUD_WEB_WORKER: { fetch: vi.fn() }, + ARELAY_ANALYTICS: { writeDataPoint: (point: Point) => { points.push(point); } }, + ANALYTICS_VISITOR_HASH_SECRET: SECRET, + TRAFFIC_RECORDER: { put }, + ROUTER_CONFIG: { get: vi.fn(async () => "100") }, + ...extra, + } as unknown as Parameters[1]; + const waits: Promise[] = []; + const ctx = { + waitUntil: (promise: Promise) => { waits.push(promise); }, + passThroughOnException: () => undefined, + } as unknown as ExecutionContext; + const fetch = async (url: string | Request, init: RequestInit = {}) => { + const request = typeof url === "string" ? new Request(url, init) : url; + const response = await worker.fetch(request, env, ctx); + await Promise.all(waits); + return response; + }; + return { points, put, upstream, fetch }; +} + +const AGENT = { "user-agent": "curl/8.7.1", accept: "*/*", "cf-connecting-ip": "203.0.113.5" }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("router guide analytics", () => { + it("writes one agent_guide_fetched per successful negotiated guide GET on either host", async () => { + const { points, fetch } = setup(); + await fetch("https://arelay.to/agent-relay", { headers: AGENT }); + await fetch("https://www.arelay.to/acme-support", { headers: { ...AGENT, accept: "text/markdown" } }); + await fetch("https://agentrelay.com/u/agent-relay", { headers: AGENT }); + expect(points).toHaveLength(3); + expect(points.map((point) => point.indexes?.[0])).toEqual(["agent-relay", "acme-support", "agent-relay"]); + for (const point of points) { + expect(point.blobs?.slice(0, 2)).toEqual(["v1", "agent_guide_fetched"]); + expect(point.blobs?.slice(3, 7)).toEqual(["not_applicable", "curl", "unknown", "none"]); + expect(point.blobs?.[7]).toMatch(/^[0-9a-f]{64}$/); + expect(point.blobs?.[8]).toBe("router"); + expect(point.doubles).toEqual([1, -1]); + } + }); + + it("records the incoming request's cf.country even though the guide path is rewritten", async () => { + const { points, fetch } = setup(); + // Like Workers, rebuilt Requests here do not inherit the incoming cf. + const request = new Request("https://arelay.to/agent-relay", { headers: AGENT }); + Object.defineProperty(request, "cf", { value: { country: "NZ" } }); + await fetch(request); + expect(points).toHaveLength(1); + expect(points[0]?.blobs?.[5]).toBe("NZ"); + }); + + it("writes nothing for HTML, HEAD, direct guide paths, or Worker subrequests", async () => { + const { points, fetch } = setup(); + await fetch("https://arelay.to/agent-relay", { headers: { ...AGENT, accept: "text/html,*/*;q=0.8" } }); + await fetch("https://arelay.to/agent-relay", { method: "HEAD", headers: AGENT }); + await fetch("https://agentrelay.com/u/agent-relay/agent.md", { headers: AGENT }); + await fetch("https://arelay.to/agent-relay", { headers: { ...AGENT, "cf-worker": "agentrelay.com" } }); + expect(points).toEqual([]); + }); + + it.each([301, 302, 404, 410, 500, 503])("writes nothing when the guide upstream answers %i", async (status) => { + const { points, fetch } = setup(status); + await fetch("https://arelay.to/agent-relay", { headers: AGENT }); + expect(points).toEqual([]); + }); + + it("fails closed without the visitor hash secret or the binding", async () => { + const withoutSecret = setup(200, { ANALYTICS_VISITOR_HASH_SECRET: undefined }); + const response = await withoutSecret.fetch("https://arelay.to/agent-relay", { headers: AGENT }); + expect(response.status).toBe(200); + expect(withoutSecret.points).toEqual([]); + vi.restoreAllMocks(); + + const withoutBinding = setup(200, { ARELAY_ANALYTICS: undefined }); + expect((await withoutBinding.fetch("https://arelay.to/agent-relay", { headers: AGENT })).status).toBe(200); + }); + + it("never changes the guide response when writeDataPoint throws", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { fetch } = setup(200, { + ARELAY_ANALYTICS: { writeDataPoint: () => { throw new Error("boom"); } }, + }); + const response = await fetch("https://arelay.to/agent-relay", { headers: AGENT }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("guide"); + expect(warn).toHaveBeenCalledWith("arelay analytics: data point write failed"); + }); + + it("never writes IPs, user agents, URLs, or query strings", async () => { + const { points, fetch } = setup(); + await fetch("https://arelay.to/agent-relay?SENTINEL-QUERY=1", { + headers: { + accept: "*/*", + "user-agent": "SENTINEL-UA/1.0", + "cf-connecting-ip": "198.51.100.77", + "cf-ipcountry": "SENTINEL-COUNTRY", + }, + }); + expect(points).toHaveLength(1); + const serialized = JSON.stringify(points); + for (const sentinel of ["SENTINEL", "198.51.100.77", "arelay.to", "?", SECRET]) { + expect(serialized).not.toContain(sentinel); + } + }); + + it("only counts GETs negotiated to the guide", () => { + const get = new Request("https://arelay.to/agent-relay"); + expect(getGuideAnalyticsHandle(get, "/u/agent-relay/agent.md")).toBe("agent-relay"); + expect(getGuideAnalyticsHandle(get, "/u/agent-relay")).toBeUndefined(); + expect(getGuideAnalyticsHandle(get, undefined)).toBeUndefined(); + expect(getGuideAnalyticsHandle(new Request("https://arelay.to/agent-relay", { method: "HEAD" }), "/u/agent-relay/agent.md")) + .toBeUndefined(); + }); +}); + +describe("router dashboard recorder exclusion", () => { + it("never clones or records dashboard requests or their grants", async () => { + const { points, put, upstream, fetch } = setup(); + const grant = "SENTINEL-GRANT-abc.def"; + await fetch(`https://agentrelay.com/u/acme-support/dashboard?grant=${grant}`, { headers: AGENT }); + await fetch("https://agentrelay.com/u/acme-support/dashboard", { headers: AGENT }); + await fetch("https://agentrelay.com/u/acme-support/dashboard/", { headers: AGENT }); + await fetch(`https://agentrelay.com/u/acme-support/dashboard.json?grant=${grant}`, { headers: AGENT }); + await fetch(`https://agentrelay.com/U/acme-support/Dashboard?grant=${grant}`, { headers: AGENT }); + await fetch(`https://agentrelay.com/pricing?GRANT=${grant}`, { headers: AGENT }); + expect(upstream).toHaveBeenCalledTimes(6); + expect(put).not.toHaveBeenCalled(); + expect(JSON.stringify(put.mock.calls)).not.toContain(grant); + expect(points).toEqual([]); + }); + + it("excludes the whole /u/ namespace and any grant-bearing URL", () => { + expect(isAgentPageRequestPath("/u/acme/dashboard")).toBe(true); + expect(isAgentPageRequestPath("/u/acme/dashboard/")).toBe(true); + expect(isAgentPageRequestPath("/u/acme/dashboard/data")).toBe(true); + expect(isAgentPageRequestPath("/u/acme/agent.md")).toBe(true); + expect(isAgentPageRequestPath("/u/acme/dashboard.json")).toBe(true); + expect(isAgentPageRequestPath("/U/acme/Dashboard")).toBe(true); + expect(isAgentPageRequestPath("/pricing")).toBe(false); + expect(isAgentPageRequestPath("/user")).toBe(false); + expect(carriesDashboardGrant(new URL("https://agentrelay.com/x?Grant=1"))).toBe(true); + expect(carriesDashboardGrant(new URL("https://agentrelay.com/x?granted=1"))).toBe(false); + }); +}); + +describe("router analytics schema parity with relay-agent", () => { + it("uses the shared ordered v1 layout", () => { + expect(guideFetchedDataPoint("acme", "codex", "DE", "a".repeat(64))).toEqual({ + indexes: ["acme"], + blobs: ["v1", "agent_guide_fetched", "acme", "not_applicable", "codex", "DE", "none", "a".repeat(64), "router"], + doubles: [1, -1], + }); + expect(guideFetchedDataPoint("Bad Handle", "curl", "DE", "a".repeat(64))).toBeNull(); + expect(guideFetchedDataPoint("acme", "curl", "DE", "nope")).toBeNull(); + }); + + it("classifies clients and reads only cf.country", () => { + expect(classifyClient("claude-cli/2.1.0 (external, cli)")).toBe("claude-code"); + expect(classifyClient("codex_cli_rs/0.40.0")).toBe("codex"); + expect(classifyClient("Grok-Agent/1.0")).toBe("grok"); + expect(classifyClient("curl/8.7.1")).toBe("curl"); + expect(classifyClient(null)).toBe("other"); + const spoofed = new Request("https://arelay.to/", { headers: { "cf-ipcountry": "FR" } }); + expect(requestCountry(spoofed)).toBe("unknown"); + Object.defineProperty(spoofed, "cf", { value: { country: "de" } }); + expect(requestCountry(spoofed)).toBe("DE"); + }); + + it("matches relay-agent's daily pseudonym: stable per UTC day, rotating, per handle", async () => { + const day = Date.UTC(2026, 9, 7, 1); + const first = await visitorPseudonym(SECRET, "acme", "203.0.113.9", "curl/8", day); + // Known answer pinned identically in relay-agent's test/analytics.test.ts. + expect(first).toBe("8eb91751cc9174b9b70b3c032b996d0d0d5d3a1d98be2451e1af72e774859dbb"); + expect(await visitorPseudonym(SECRET, "acme", "203.0.113.9", "curl/8", day + 22 * 3_600_000)).toBe(first); + expect(await visitorPseudonym(SECRET, "acme", "203.0.113.9", "curl/8", day + 23 * 3_600_000)).not.toBe(first); + expect(await visitorPseudonym(SECRET, "other", "203.0.113.9", "curl/8", day)).not.toBe(first); + expect(await visitorPseudonym(undefined, "acme", "203.0.113.9", "curl/8", day)).toBeUndefined(); + }); +}); diff --git a/router/wrangler.jsonc b/router/wrangler.jsonc index dd3fae49..9cca481d 100644 --- a/router/wrangler.jsonc +++ b/router/wrangler.jsonc @@ -39,6 +39,13 @@ { "binding": "TRAFFIC_RECORDER", "bucket_name": "traffic-recorder" } ], + // Server-side arelay.to analytics (relay-agent docs/ANALYTICS_BRIEF.md). Same + // dataset relay-agent writes. agent_guide_fetched also needs the + // ANALYTICS_VISITOR_HASH_SECRET Worker secret; without it nothing is written. + "analytics_engine_datasets": [ + { "binding": "ARELAY_ANALYTICS", "dataset": "arelay_agent_analytics" } + ], + "vars": { "CLOUD_APP_ORIGIN": "https://origin.agentrelay.cloud", "FILE_OBSERVER_ORIGIN": "https://relayfile-file-observer.pages.dev",