From 396a2bc0b279f868a6c439ee1fe3b226c52ff707 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 6 Oct 2026 15:34:15 +0100 Subject: [PATCH] fix(website): count a plugin install once per IP per hour (#6230) Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent. --- packages/website/README.md | 22 ++++ packages/website/src/plugins/installs.ts | 20 ++++ packages/website/src/plugins/published.ts | 9 +- packages/website/src/plugins/registry.test.ts | 111 +++++++++++++++++- 4 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 packages/website/README.md diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve//` or +`/plugins//.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/src/plugins/installs.ts b/packages/website/src/plugins/installs.ts index 01846e6797..78b4294639 100644 --- a/packages/website/src/plugins/installs.ts +++ b/packages/website/src/plugins/installs.ts @@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const; const totalKey = (id: string) => `plugin-installs:${id}`; const dailyKey = (id: string) => `plugin-installs-daily:${id}`; +export async function recordClientInstall({ + cache, + id, + ip, +}: { + cache: KVNamespace; + id: string; + ip: string | null; +}): Promise { + if (!ip) return; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip)); + const hash = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const key = `plugin-installs-client:${id}:${hash}`; + if ((await cache.get(key)) !== null) return; + await cache.put(key, "1", { expirationTtl: 3600 }); + await recordInstall(cache, id, new Date()); +} + export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise { const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]); await Promise.all([ diff --git a/packages/website/src/plugins/published.ts b/packages/website/src/plugins/published.ts index 4d6826f11e..ebf7e5256b 100644 --- a/packages/website/src/plugins/published.ts +++ b/packages/website/src/plugins/published.ts @@ -5,7 +5,7 @@ import { } from "@getpaseo/protocol/plugin-registry"; import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache"; import { CATEGORIES } from "./categories"; -import { type InstallCounts, readInstallCounts, recordInstall } from "./installs"; +import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs"; export interface RegistryEnvironment { PLUGINS_REGISTRY_URL?: string; WEBSITE_CACHE?: KVNamespace; @@ -111,7 +111,12 @@ export async function handlePluginRegistryRequest( url.searchParams.get("intent") === "install" ) { const cache = context.cache; - if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined)); + if (cache) + context.waitUntil( + recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch( + () => undefined, + ), + ); } return Response.json(plugin); } diff --git a/packages/website/src/plugins/registry.test.ts b/packages/website/src/plugins/registry.test.ts index d5244fcb3f..bd2dce67ec 100644 --- a/packages/website/src/plugins/registry.test.ts +++ b/packages/website/src/plugins/registry.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { describe, expect, it } from "vitest"; import { @@ -10,7 +11,7 @@ import { searchPlugins, } from "./registry"; import { readInstallCounts, recordInstall } from "./installs"; -import { loadRegistryIndex, loadRegistryPlugin } from "./published"; +import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published"; const plugin: Plugin = { id: "acme/example", name: "Example", @@ -94,6 +95,81 @@ describe("plugin registry", () => { Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")), ).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]); }); + it("counts repeated install reports from the same IP once per hour", async () => { + let now = Date.now(); + const cache = memoryKv(() => now); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + now += 3_599_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 1 }, + }); + now += 1_000; + await resolvePlugin(cache, headers); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + }); + }); + it("counts different IPs and plugins independently, including query install intent", async () => { + const cache = memoryKv(); + const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, first); + await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other" }); + await resolvePlugin(cache, first, { query: "?intent=install" }); + await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true }); + expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({ + [plugin.id]: { week: 2, month: 2, all: 2 }, + "acme/other": { week: 1, month: 1, all: 1 }, + }); + const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex"); + expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1"); + expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1"); + expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1"); + }); + it("does not count without a Cloudflare IP or install intent", async () => { + const cache = memoryKv(); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" }); + await resolvePlugin(cache, {}, { query: "?intent=install" }); + await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" }); + await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 0, month: 0, all: 0 }, + }); + expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]); + }); + it("preserves historical totals and the installs endpoint response", async () => { + const cache = memoryKv(); + await cache.put(`plugin-installs:${plugin.id}`, "40"); + const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" }; + await resolvePlugin(cache, headers); + await resolvePlugin(cache, headers); + const base = "https://registry.example.test"; + await cache.put( + `plugins:index:v1:${base}`, + JSON.stringify({ + fetchedAt: Date.now(), + value: { + schemaVersion: 1, + registry: { name: "Internal", url: base }, + categories: [], + plugins: [plugin], + generatedAt: "2026-10-03", + }, + }), + ); + const response = await handlePluginRegistryRequest( + new Request("https://paseo.sh/api/plugins/installs"), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: () => undefined }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual({ [plugin.id]: 41 }); + expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({ + [plugin.id]: { week: 1, month: 1, all: 41 }, + }); + }); it("searches name, description, ID, and author, ignoring case", () => { const other = { ...plugin, @@ -125,16 +201,43 @@ describe("plugin registry", () => { }); /** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */ -function memoryKv(): KVNamespace { +function memoryKv(now = Date.now): KVNamespace & { values: Map } { const values = new Map(); + const expiresAt = new Map(); const kv = { + values, get: async (key: string, options?: { type?: string }) => { + if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key); const value = values.get(key) ?? null; return options?.type === "json" && value !== null ? JSON.parse(value) : value; }, - put: async (key: string, value: string) => { + put: async (key: string, value: string, options?: { expirationTtl?: number }) => { values.set(key, value); + expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity); }, }; - return kv as unknown as KVNamespace; + return kv as unknown as KVNamespace & { values: Map }; +} + +async function resolvePlugin( + cache: KVNamespace, + headers: HeadersInit, + { id = plugin.id, query = "", staticDetail = false } = {}, +): Promise { + const base = "https://registry.example.test"; + const detail = { ...plugin, id, readme: "# Example" }; + await cache.put( + `plugins:detail:v1:${base}:${id}`, + JSON.stringify({ fetchedAt: Date.now(), value: detail }), + ); + const pending: Promise[] = []; + const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`; + const response = await handlePluginRegistryRequest( + new Request(`https://paseo.sh${path}${query}`, { headers }), + { PLUGINS_REGISTRY_URL: base }, + { cache, waitUntil: (promise) => pending.push(promise) }, + ); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual(detail); + await Promise.all(pending); }