From b176698714a2c8b83500a0a37d46baa084f2347c Mon Sep 17 00:00:00 2001 From: zingzy Date: Wed, 22 Jul 2026 18:41:42 +0530 Subject: [PATCH] feat: send X-Spoo-Client header on all spoo.me API requests Adds a client attribution header (snap/) to every request hitting the spoo.me backend, injected centrally in the api client and on the few raw fetch call sites (device auth, refresh, export, v0 stats). QR image URLs and third-party hosts are untouched. --- src/api/auth.ts | 4 ++-- src/api/client.ts | 7 +++++-- src/api/export.ts | 4 ++-- src/api/stats.ts | 4 ++-- src/entrypoints/background/index.ts | 4 +++- src/lib/constants.ts | 4 ++++ 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index c6f44be..d9389be 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -13,7 +13,7 @@ import type { VerifyEmailRequest, VerifyEmailResponse, } from "@/api/types"; -import { AUTH_ENDPOINTS } from "@/lib/constants"; +import { AUTH_ENDPOINTS, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants"; import { accessTokenStorage, refreshTokenStorage } from "@/lib/storage"; import { loginResponseSchema, @@ -99,7 +99,7 @@ export async function refreshAccessToken(): Promise { try { const res = await fetch(AUTH_ENDPOINTS.deviceRefresh, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", [CLIENT_HEADER]: CLIENT_HEADER_VALUE }, body: JSON.stringify({ refresh_token: refreshToken }), signal: AbortSignal.timeout(10_000), }); diff --git a/src/api/client.ts b/src/api/client.ts index ee3f9c8..f1af3f3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import type { ApiErrorResponse } from "@/api/types"; -import { AUTH_ENDPOINTS } from "@/lib/constants"; +import { AUTH_ENDPOINTS, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants"; import { ApiError, isOffline, NetworkError } from "@/lib/errors"; import { accessTokenStorage, @@ -43,7 +43,7 @@ async function refreshAccessToken(): Promise { try { const res = await fetch(AUTH_ENDPOINTS.deviceRefresh, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", [CLIENT_HEADER]: CLIENT_HEADER_VALUE }, body: JSON.stringify({ refresh_token: refreshToken }), }); @@ -88,6 +88,9 @@ export async function request( } } + // Client attribution header (all request() URLs point at the spoo.me backend) + headers[CLIENT_HEADER] = CLIENT_HEADER_VALUE; + // Auth header if (!noAuth) { const authHeader = await getAuthHeader(); diff --git a/src/api/export.ts b/src/api/export.ts index c8e835d..3c4d1a6 100644 --- a/src/api/export.ts +++ b/src/api/export.ts @@ -1,5 +1,5 @@ import type { ExportQuery } from "@/api/types"; -import { API_V1 } from "@/lib/constants"; +import { API_V1, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants"; import { isOffline, NetworkError } from "@/lib/errors"; import { accessTokenStorage, apiKeyStorage, authModeStorage } from "@/lib/storage"; @@ -17,7 +17,7 @@ export async function exportStats(query: ExportQuery): Promise { } } - const headers: Record = {}; + const headers: Record = { [CLIENT_HEADER]: CLIENT_HEADER_VALUE }; const mode = await authModeStorage.getValue(); if (mode === "apikey") { const key = await apiKeyStorage.getValue(); diff --git a/src/api/stats.ts b/src/api/stats.ts index 981db43..cef117d 100644 --- a/src/api/stats.ts +++ b/src/api/stats.ts @@ -1,6 +1,6 @@ import { request } from "@/api/client"; import type { StatsQuery, StatsResponse } from "@/api/types"; -import { API_BASE_URL, API_V1 } from "@/lib/constants"; +import { API_BASE_URL, API_V1, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants"; import { ApiError } from "@/lib/errors"; import { statsResponseSchema } from "@/schemas/api"; @@ -50,7 +50,7 @@ export interface V0StatsResponse { */ export async function getStatsV0(shortCode: string, password?: string): Promise { const url = `${API_BASE_URL}/stats/${shortCode}`; - const headers: Record = {}; + const headers: Record = { [CLIENT_HEADER]: CLIENT_HEADER_VALUE }; const body = password ? new URLSearchParams({ password }) : undefined; if (body) { diff --git a/src/entrypoints/background/index.ts b/src/entrypoints/background/index.ts index d20b4a7..48fcb22 100644 --- a/src/entrypoints/background/index.ts +++ b/src/entrypoints/background/index.ts @@ -5,6 +5,8 @@ import { ACCESS_TOKEN_TTL_MS, API_BASE_URL, AUTH_ENDPOINTS, + CLIENT_HEADER, + CLIENT_HEADER_VALUE, HISTORY_MAX_ITEMS, QR_BRAND, TOKEN_REFRESH_BUFFER_MS, @@ -139,7 +141,7 @@ async function handleTokenRefresh(): Promise { async function exchangeDeviceCode(code: string): Promise { const res = await fetch(AUTH_ENDPOINTS.deviceToken, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", [CLIENT_HEADER]: CLIENT_HEADER_VALUE }, body: JSON.stringify({ code }), }); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9e45ad0..d29517e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,6 +4,10 @@ export const QR_API_BASE_URL = import.meta.env.VITE_QR_API_BASE_URL || "https:// export const API_V1 = `${API_BASE_URL}/api/v1`; export const QR_API_V1 = `${QR_API_BASE_URL}/api/v1`; +/** Identifies this client to the spoo.me API. Never sent to third-party hosts. */ +export const CLIENT_HEADER = "X-Spoo-Client"; +export const CLIENT_HEADER_VALUE = `snap/${browser.runtime.getManifest().version}`; + export const AUTH_ENDPOINTS = { login: `${API_BASE_URL}/auth/login`, register: `${API_BASE_URL}/auth/register`,