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.
This commit is contained in:
Mohamed Boudra
2026-10-06 15:34:15 +01:00
committed by GitHub
parent f5c55dfc55
commit 396a2bc0b2
4 changed files with 156 additions and 6 deletions
+22
View File
@@ -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/<owner>/<slug>` or
`/plugins/<owner>/<slug>.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.
+20
View File
@@ -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<void> {
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<void> {
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
await Promise.all([
+7 -2
View File
@@ -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);
}
+107 -4
View File
@@ -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<string, string> } {
const values = new Map<string, string>();
const expiresAt = new Map<string, number>();
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<string, string> };
}
async function resolvePlugin(
cache: KVNamespace,
headers: HeadersInit,
{ id = plugin.id, query = "", staticDetail = false } = {},
): Promise<void> {
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<unknown>[] = [];
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);
}