fix: use stable site hash for install telemetry deduplication (#298)

* fix: use stable site hash for install telemetry deduplication (#297)

generateSiteHash() used Date.now() as the hash seed, producing a different
hash on every call. Since the installs table uses PRIMARY KEY (plugin_id,
site_hash), the same site could insert unlimited rows, inflating install
counts and making "Most Popular" sorting meaningless.

Fix: use the site's request origin as a stable hash seed. The same origin
always produces the same hash, so the marketplace deduplicates correctly.

Also denormalizes install_count on the plugins table to avoid a COUNT(*)
subquery per row in searchPlugins(). The count is recalculated atomically
on each upsertInstall() call.

Fixes #297

* chore: add changeset for install telemetry fix

* fix: address review feedback on install telemetry

- Replace crypto.subtle fallback with FNV-1a hash to avoid origin
  leakage and collisions from truncated seed strings
- Remove duplicate p.install_count from SELECT (p.* already includes it)
- Use explicit p.install_count in ORDER BY clause
- Use db.batch() for atomic upsert + count recomputation instead of
  separate statements with misleading meta.changes check
This commit is contained in:
Benjamin Price
2026-04-08 06:37:00 +09:00
committed by GitHub
parent 91e31fb2ca
commit f112ac4819
9 changed files with 94 additions and 36 deletions

View File

@@ -21,6 +21,7 @@ import {
} from "../../../src/plugins/marketplace.js";
const HEX_64_PATTERN = /^[a-f0-9]{64}$/;
const HEX_16_PATTERN = /^[a-f0-9]{16}$/;
// ── Helpers ───────────<E29480><E29480><EFBFBD>────────────────────────────────────────────
@@ -432,6 +433,40 @@ describe("MarketplaceClient", () => {
// Should not throw
await client.reportInstall("test-seo", "1.0.0");
});
it("sends a stable site hash across multiple calls", async () => {
const clientWithOrigin = createMarketplaceClient(BASE_URL, "https://myblog.example.com");
fetchSpy.mockResolvedValue(new Response("OK", { status: 200 }));
await clientWithOrigin.reportInstall("test-seo", "1.0.0");
await clientWithOrigin.reportInstall("test-seo", "1.0.0");
const calls = fetchSpy.mock.calls;
expect(calls.length).toBe(2);
const body1 = JSON.parse(calls[0]![1]!.body as string);
const body2 = JSON.parse(calls[1]![1]!.body as string);
// Same origin produces the same hash every time
expect(body1.siteHash).toBe(body2.siteHash);
expect(body1.siteHash).toMatch(HEX_16_PATTERN);
});
it("produces different hashes for different site origins", async () => {
const client1 = createMarketplaceClient(BASE_URL, "https://site-a.example.com");
const client2 = createMarketplaceClient(BASE_URL, "https://site-b.example.com");
fetchSpy.mockResolvedValue(new Response("OK", { status: 200 }));
await client1.reportInstall("test-seo", "1.0.0");
await client2.reportInstall("test-seo", "1.0.0");
const body1 = JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string);
const body2 = JSON.parse(fetchSpy.mock.calls[1]![1]!.body as string);
expect(body1.siteHash).not.toBe(body2.siteHash);
});
});
describe("trailing slash handling", () => {