From bf82bb438b4e35c424fb76b3092f1ff913c542ae Mon Sep 17 00:00:00 2001 From: zingzy Date: Wed, 22 Jul 2026 18:41:59 +0530 Subject: [PATCH] feat: send X-Spoo-Client attribution header on backend calls Every request to the spoo.me backend now carries X-Spoo-Client so the API can attribute traffic by client. One wrapper (apiFetch) stamps the header; the surface is inferred from the route: /dashboard and /onboarding send dashboard, everything else sends landing. Third-party fetches (favicon upstream, geo assets) stay untouched. --- components/errors/alias-claim.tsx | 4 +- components/errors/not-found-body.tsx | 3 +- components/sections/instant-shortener.tsx | 3 +- lib/api/auth.ts | 12 ++-- lib/api/client.test.ts | 68 +++++++++++++++++++++++ lib/api/client.ts | 33 ++++++++++- lib/api/links.ts | 6 +- lib/api/public-preview.ts | 4 +- lib/api/public-stats.ts | 4 +- lib/api/reports.ts | 6 +- 10 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 lib/api/client.test.ts diff --git a/components/errors/alias-claim.tsx b/components/errors/alias-claim.tsx index b34d630..869d16c 100644 --- a/components/errors/alias-claim.tsx +++ b/components/errors/alias-claim.tsx @@ -12,7 +12,7 @@ import { Loader2, } from "lucide-react" -import { jsonInit, parse } from "@/lib/api/client" +import { apiFetch, jsonInit, parse } from "@/lib/api/client" import { celebrate } from "@/lib/confetti" import { trackUiAction } from "@/lib/analytics" import { normalizeUrl, urlProblem, validDestinationUrl } from "@/lib/validation" @@ -56,7 +56,7 @@ export function AliasClaim({ } setState({ kind: "claiming" }) try { - const res = await fetch( + const res = await apiFetch( "/api/v1/shorten", jsonInit("POST", alias ? { url: wire, alias } : { url: wire }) ) diff --git a/components/errors/not-found-body.tsx b/components/errors/not-found-body.tsx index d5a583f..2dc3880 100644 --- a/components/errors/not-found-body.tsx +++ b/components/errors/not-found-body.tsx @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation" import { Button } from "@/components/ui/button" import { AliasClaim } from "@/components/errors/alias-claim" +import { apiFetch } from "@/lib/api/client" /** Creatable-alias shape (mirrors the backend + mock validator). */ const ALIAS_RE = /^[a-zA-Z0-9_-]{3,16}$/ @@ -47,7 +48,7 @@ export function NotFoundBody({ from }: { from?: string }) { React.useEffect(() => { if (!alias) return let cancelled = false - fetch(`/api/v1/shorten/check-alias?alias=${encodeURIComponent(alias)}`) + apiFetch(`/api/v1/shorten/check-alias?alias=${encodeURIComponent(alias)}`) .then((r) => r.json()) .then((d: { available?: boolean }) => { if (!cancelled) setAvailable(Boolean(d.available)) diff --git a/components/sections/instant-shortener.tsx b/components/sections/instant-shortener.tsx index 942db10..324bd90 100644 --- a/components/sections/instant-shortener.tsx +++ b/components/sections/instant-shortener.tsx @@ -24,6 +24,7 @@ import { trackResultCardViewed, } from "@/lib/analytics" import { addRecentLink } from "@/lib/recent-links" +import { apiFetch } from "@/lib/api/client" /* The legacy API reports field errors as { AliasError: "..." } etc. Map them to fields (so a hidden options fold can open itself) and to @@ -155,7 +156,7 @@ export function InstantShortener({ try { // Same-origin proxy (next.config.mjs): dev -> the local backend, // mock -> the in-repo handler, prod -> spoo.me. No CORS anywhere. - const res = await fetch("/shorten", { + const res = await apiFetch("/shorten", { method: "POST", headers: { Accept: "application/json", diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 5b85c8e..d2d9fc4 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -1,4 +1,4 @@ -import { authedFetch, jsonInit, parse } from "./client" +import { apiFetch, authedFetch, jsonInit, parse } from "./client" export type AuthProvider = { provider: "google" | "github" | "discord" @@ -24,7 +24,7 @@ export function register(input: { password: string user_name?: string }) { - return fetch("/auth/register", jsonInit("POST", input)).then((r) => + return apiFetch("/auth/register", jsonInit("POST", input)).then((r) => parse<{ access_token: string user: AuthUser @@ -35,13 +35,13 @@ export function register(input: { } export function login(input: { email: string; password: string }) { - return fetch("/auth/login", jsonInit("POST", input)).then((r) => + return apiFetch("/auth/login", jsonInit("POST", input)).then((r) => parse<{ access_token: string; user: AuthUser }>(r) ) } export function logout() { - return fetch("/auth/logout", { method: "POST" }).then((r) => + return apiFetch("/auth/logout", { method: "POST" }).then((r) => parse<{ success: boolean }>(r) ) } @@ -141,7 +141,7 @@ export function removeProfilePicture() { } export function requestPasswordReset(email: string) { - return fetch( + return apiFetch( "/auth/request-password-reset", jsonInit("POST", { email }) ).then((r) => parse<{ success: boolean }>(r)) @@ -152,7 +152,7 @@ export function resetPassword(input: { code: string password: string }) { - return fetch("/auth/reset-password", jsonInit("POST", input)).then((r) => + return apiFetch("/auth/reset-password", jsonInit("POST", input)).then((r) => parse<{ success: boolean }>(r) ) } diff --git a/lib/api/client.test.ts b/lib/api/client.test.ts new file mode 100644 index 0000000..ae5bcdf --- /dev/null +++ b/lib/api/client.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { apiFetch } from "./client" + +/** Capture what apiFetch hands to the real fetch. */ +function stubFetch() { + const calls: { path: string; headers: Headers }[] = [] + vi.stubGlobal( + "fetch", + vi.fn((path: string, init?: RequestInit) => { + calls.push({ path, headers: new Headers(init?.headers) }) + return Promise.resolve(new Response(null, { status: 200 })) + }) + ) + return calls +} + +function stubPathname(pathname: string) { + vi.stubGlobal("window", { location: { pathname } }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("apiFetch — X-Spoo-Client attribution", () => { + it("tags dashboard routes as dashboard", async () => { + const calls = stubFetch() + stubPathname("/dashboard/links") + await apiFetch("/api/v1/urls", { method: "GET" }) + expect(calls[0].headers.get("X-Spoo-Client")).toBe("dashboard") + }) + + it("tags onboarding as the signed-in app too", async () => { + const calls = stubFetch() + stubPathname("/onboarding/recap") + await apiFetch("/auth/onboarding", { method: "GET" }) + expect(calls[0].headers.get("X-Spoo-Client")).toBe("dashboard") + }) + + it("tags everything else as landing", async () => { + const calls = stubFetch() + for (const path of ["/", "/report", "/stats/abc", "/dashboardish"]) { + stubPathname(path) + await apiFetch("/api/v1/shorten", { method: "POST" }) + } + for (const call of calls) { + expect(call.headers.get("X-Spoo-Client")).toBe("landing") + } + }) + + it("defaults to landing without a window (server-side public pages)", async () => { + const calls = stubFetch() + await apiFetch("http://localhost:8000/api/v1/public/preview/abc") + expect(calls[0].headers.get("X-Spoo-Client")).toBe("landing") + }) + + it("keeps caller-provided headers intact", async () => { + const calls = stubFetch() + stubPathname("/") + await apiFetch("/api/v1/reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + expect(calls[0].headers.get("Content-Type")).toBe("application/json") + expect(calls[0].headers.get("X-Spoo-Client")).toBe("landing") + }) +}) diff --git a/lib/api/client.ts b/lib/api/client.ts index c771936..340642c 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -56,11 +56,38 @@ export function jsonInit(method: string, body?: unknown): RequestInit { } } +/** + * Which app surface this request originates from, for the X-Spoo-Client + * attribution header. Browser-side it's inferred from the current route: + * /dashboard and /onboarding are the signed-in app, everything else is the + * public landing surface. Server-side callers are the public pages + * (/stats/{code}, /{code}+), so no window means "landing". + */ +function clientTag(): "dashboard" | "landing" { + if (typeof window === "undefined") return "landing" + const path = window.location.pathname + return /^\/(dashboard|onboarding)(\/|$)/.test(path) ? "dashboard" : "landing" +} + +/** + * fetch with the X-Spoo-Client attribution header stamped on. Every call to + * the spoo.me backend goes through here (directly or via authedFetch) — + * never use it for third-party hosts, the header is ours alone. + */ +export function apiFetch( + path: string, + init: RequestInit = {} +): Promise { + const headers = new Headers(init.headers) + headers.set("X-Spoo-Client", clientTag()) + return fetch(path, { ...init, headers }) +} + /** Single-flight refresh: all concurrent 401 handlers share one attempt. */ let refreshInFlight: Promise | null = null function refreshSession(): Promise { - refreshInFlight ??= fetch("/auth/refresh", { method: "POST" }) + refreshInFlight ??= apiFetch("/auth/refresh", { method: "POST" }) .then((r) => r.ok) .catch(() => false) .finally(() => { @@ -74,9 +101,9 @@ export async function authedFetch( path: string, init: RequestInit ): Promise { - const res = await fetch(path, init) + const res = await apiFetch(path, init) if (res.status !== 401) return res const refreshed = await refreshSession() if (!refreshed) return res - return fetch(path, init) + return apiFetch(path, init) } diff --git a/lib/api/links.ts b/lib/api/links.ts index 495c073..92d8ae5 100644 --- a/lib/api/links.ts +++ b/lib/api/links.ts @@ -1,4 +1,4 @@ -import { authedFetch, jsonInit, parse } from "./client" +import { apiFetch, authedFetch, jsonInit, parse } from "./client" export type ShortUrl = { alias: string @@ -148,7 +148,7 @@ export type CheckAliasReason = export function checkAlias(alias: string, domain?: string) { const q = new URLSearchParams({ alias }) if (domain) q.set("domain", domain) - return fetch(`/api/v1/shorten/check-alias?${q}`).then((r) => + return apiFetch(`/api/v1/shorten/check-alias?${q}`).then((r) => parse<{ available: boolean; reason: CheckAliasReason | null }>(r) ) } @@ -187,7 +187,7 @@ export type EmojiSet = { let emojiSetPromise: Promise | null = null export function getEmojiSet(): Promise { if (!emojiSetPromise) { - emojiSetPromise = fetch("/api/v1/emoji-set") + emojiSetPromise = apiFetch("/api/v1/emoji-set") .then((r) => parse(r)) .catch((e) => { emojiSetPromise = null diff --git a/lib/api/public-preview.ts b/lib/api/public-preview.ts index d43375b..560714d 100644 --- a/lib/api/public-preview.ts +++ b/lib/api/public-preview.ts @@ -1,4 +1,4 @@ -import { parse } from "./client" +import { apiFetch, parse } from "./client" /** * Public link preview — the contract behind /{code}+ @@ -46,7 +46,7 @@ export async function getPublicPreview( code: string, baseUrl = "/api" ): Promise { - const res = await fetch( + const res = await apiFetch( `${baseUrl}/v1/public/preview/${encodeURIComponent(code)}`, { method: "GET", cache: "no-store" } ) diff --git a/lib/api/public-stats.ts b/lib/api/public-stats.ts index 2e6df66..ce9ca61 100644 --- a/lib/api/public-stats.ts +++ b/lib/api/public-stats.ts @@ -1,4 +1,4 @@ -import { jsonInit, parse } from "./client" +import { apiFetch, jsonInit, parse } from "./client" import { adaptStats, type StatsResponse, type StatsWire } from "./stats" /** @@ -64,7 +64,7 @@ export async function getPublicStats( // re-buckets the moment the browser's timezone gets a say. if (params.timezone) q.set("timezone", params.timezone) const url = `${baseUrl}/v1/public/stats/${encodeURIComponent(code)}?${q}` - const res = await fetch(url, { + const res = await apiFetch(url, { ...(params.password ? jsonInit("POST", { password: params.password }) : { method: "GET" }), diff --git a/lib/api/reports.ts b/lib/api/reports.ts index a277b13..bcd812f 100644 --- a/lib/api/reports.ts +++ b/lib/api/reports.ts @@ -1,4 +1,4 @@ -import { jsonInit, parse, SpooApiError } from "./client" +import { apiFetch, jsonInit, parse, SpooApiError } from "./client" /** * Abuse-report intake + contact — the frozen wire contract from @@ -79,7 +79,7 @@ export type ReportSubmissionResult = { export async function submitReports( input: ReportSubmissionInput ): Promise { - const res = await fetch("/api/v1/reports", jsonInit("POST", input)) + const res = await apiFetch("/api/v1/reports", jsonInit("POST", input)) return parse(res) } @@ -92,7 +92,7 @@ export type ContactInput = { export async function sendContactMessage( input: ContactInput ): Promise<{ ok: true }> { - const res = await fetch("/api/v1/contact", jsonInit("POST", input)) + const res = await apiFetch("/api/v1/contact", jsonInit("POST", input)) return parse<{ ok: true }>(res) }