diff --git a/apps/web/package.json b/apps/web/package.json index df9adf8..12052bb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@simplewebauthn/browser": "^13.2.0", "hono": "^4.10.3", "@profullstack/nichedb": "workspace:*", - "@nichedb/enrichers": "workspace:*" + "@nichedb/enrichers": "workspace:*", + "@profullstack/leaderboard": "^0.3.0" } } diff --git a/apps/web/src/app.js b/apps/web/src/app.js index bfb67fa..e7929f5 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -1,6 +1,7 @@ import { config } from '@nichedb/config'; import { Hono } from 'hono'; import { isProUser, loadUser, render, wantsJson } from './lib/http.js'; +import { leaderboard } from './lib/leaderboard.js'; import { modulesFor, withModules } from './lib/modules.js'; import { gateway, gatewayFor } from './lib/pricing.js'; import { Denied } from './lib/service.js'; @@ -45,6 +46,16 @@ app.use('*', async (c, next) => { app.use('*', loadUser); +/** + * The public board: partners earning on one side, agents spending on the + * other, kept apart. Serves its own pages, JSON, RSS, per-player share cards + * and the embed widget under /leaderboard. + */ +app.use('*', async (c, next) => { + const answer = await leaderboard.handle(c.req.raw); + return answer ?? next(); +}); + /** Ads and tracking: on for free, off for Pro, the buyer's choice with a pass. */ app.use('*', async (c, next) => { const modules = await modulesFor(c, isProUser); diff --git a/apps/web/src/lib/leaderboard.js b/apps/web/src/lib/leaderboard.js new file mode 100644 index 0000000..17f28af --- /dev/null +++ b/apps/web/src/lib/leaderboard.js @@ -0,0 +1,161 @@ +import { config } from '@nichedb/config'; +import { sql } from '@nichedb/db'; +import { createLeaderboard, projectionStore } from '@profullstack/leaderboard'; + +/** + * The public board, over rows we already keep. + * + * Nothing here is written by the leaderboard. Crawl passes land in + * `crawl_sales` because an agent paid the gateway, and referral commissions + * land in `referral_usages` because someone's code was spent. Both are + * ledgers. A board that copied them would be a second ledger that disagrees + * with the first one the day a write fails, so we project instead. + * + * Badges are the exception. They are awarded at a moment rather than derived + * from a sum, so they get a table (`leaderboard_badges`, migration 0005). + */ + +const ms = (v) => (v instanceof Date ? v.getTime() : new Date(v).getTime()); + +/** + * Two methods, written against the tagged-template client we already use, so + * the board needs no second database handle and no string-built SQL. + */ +const badges = { + async awardBadge(player, badge) { + const rows = await sql` + insert into leaderboard_badges (player, badge) values (${player}, ${badge}) + on conflict (player, badge) do nothing + returning player`; + return rows.length > 0; + }, + async badges() { + const rows = await sql`select player, badge, awarded_at from leaderboard_badges`; + const out = {}; + for (const r of rows) { + out[r.player] ??= {}; + out[r.player][r.badge] = ms(r.awarded_at); + } + return out; + }, +}; + +/** + * A buyer's identity is the wallet that paid; its display name is the agent, + * because "meta-externalagent" tells a reader something and `0x46E9…6C79` + * does not. A sale with neither is skipped rather than pooled into one + * "unknown" row, which would outrank every real buyer on the board. + */ +const KNOWN_AGENTS = [ + 'meta-externalagent', + 'GPTBot', + 'ClaudeBot', + 'anthropic-ai', + 'CCBot', + 'Bytespider', + 'Applebot', + 'FacebookBot', + 'Lightpanda', + 'PerplexityBot', +]; +function agentName(userAgent) { + const ua = String(userAgent ?? ''); + const known = KNOWN_AGENTS.find((k) => ua.toLowerCase().includes(k.toLowerCase())); + return known ?? (ua.slice(0, 40).trim() || null); +} + +/** A wallet address is long and all of it is public; show the ends. */ +const shortWallet = (p) => (p.length > 14 ? `${p.slice(0, 6)}…${p.slice(-4)}` : p); + +async function events({ since }) { + const from = new Date(since || 0); + const [sales, referrals] = await Promise.all([ + sql`select payer, user_agent, total_cents, days, created_at + from crawl_sales + where created_at >= ${from}`, + // display_name or handle only. An email address is not a public name, and + // this page is public. + sql`select u.affiliate_id, u.commission_cents, u.applied_at, + coalesce(a.display_name, a.handle::text) as name + from referral_usages u + join users a on a.id = u.affiliate_id + where u.applied_at >= ${from}`, + ]); + + const out = []; + for (const s of sales) { + const player = s.payer || agentName(s.user_agent); + if (!player) continue; + const at = ms(s.created_at); + const name = agentName(s.user_agent) ?? shortWallet(String(player)); + const each = (metric, delta) => out.push({ player: String(player), name, metric, delta, at }); + each('spent', Number(s.total_cents) || 0); + each('passes', 1); + each('days', Number(s.days) || 1); + } + for (const r of referrals) { + const at = ms(r.applied_at); + // A partner who set no display name is shown by a stable short id, never + // by the email they signed up with. + const name = r.name || `Partner ${String(r.affiliate_id).slice(0, 8)}`; + const each = (metric, delta) => + out.push({ player: String(r.affiliate_id), name, metric, delta, at }); + each('earned', Number(r.commission_cents) || 0); + each('referrals', 1); + } + return out; +} + +export const leaderboard = createLeaderboard({ + siteName: config.siteName, + siteUrl: config.siteUrl, + basePath: '/leaderboard', + store: projectionStore({ events, badges }), + // Two sides of one marketplace, never in one list: a partner earning $209 + // and an agent spending $209 are not the same fact about NicheDB. + sides: { sell: 'Partners earning', buy: 'Agents spending', use: 'Crawl usage' }, + boards: { + earners: { + label: 'Top earners', + metric: 'earned', + format: 'usd', + unit: 'Earned', + side: 'sell', + actor: 'Partner', + }, + referrers: { + label: 'Most referrals', + metric: 'referrals', + format: 'integer', + unit: 'Referrals', + side: 'sell', + actor: 'Partner', + }, + spenders: { + label: 'Biggest spenders', + metric: 'spent', + format: 'usd', + unit: 'Spent', + side: 'buy', + actor: 'Agent', + }, + passes: { + label: 'Most passes bought', + metric: 'passes', + format: 'integer', + unit: 'Passes', + side: 'buy', + actor: 'Agent', + }, + days: { + label: 'Most days of access', + metric: 'days', + format: 'integer', + unit: 'Days', + side: 'use', + actor: 'Agent', + }, + }, + ladder: true, + cacheMs: 60_000, +}); diff --git a/apps/web/src/lib/pricing.js b/apps/web/src/lib/pricing.js index 31c63ca..79f8b96 100644 --- a/apps/web/src/lib/pricing.js +++ b/apps/web/src/lib/pricing.js @@ -99,7 +99,20 @@ export function gatewayOptions(priceCents) { passMinutes: config.x402.passMinutes, maxDays: config.x402.maxDays, contact: config.x402.contact || undefined, - openPaths: ['/llms.txt', '/mcp', '/api/', '/healthz', '/manifest.webmanifest'], + // The leaderboard is open on purpose, and both spellings are needed: the + // gateway prefix-matches only entries ending in a slash, so '/leaderboard' + // alone would open the index and still charge for every board on it. It is + // the public record of who pays for this data, and an agent that hits a 402 + // on the page ranking it cannot read the case for buying a pass. + openPaths: [ + '/llms.txt', + '/mcp', + '/api/', + '/healthz', + '/manifest.webmanifest', + '/leaderboard', + '/leaderboard/', + ], onSale: async (sale) => { console.log('[x402] sold a pass', { payer: sale.payer, diff --git a/bun.lock b/bun.lock index fef83a1..79a9f8d 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "@nichedb/notify": "workspace:*", "@nichedb/payments": "workspace:*", "@nichedb/queue": "workspace:*", + "@profullstack/leaderboard": "^0.3.0", "@profullstack/nichedb": "workspace:*", "@profullstack/x402-gateway": "^0.1.0", "@simplewebauthn/browser": "^13.2.0", @@ -296,6 +297,8 @@ "@profullstack/favicon-generator": ["@profullstack/favicon-generator@1.2.1", "", { "dependencies": { "inquirer": "^10.2.2", "sharp": "^0.33.5" }, "bin": { "fav": "bin/cli.js" } }, "sha512-1w+EcoEmi60TXXQd97Brs7kN/BmlaoOcNvE39ZombUWSCBFBNe6fFm2mA7HORXb7TM39mHklNTIoG20glLsgVA=="], + "@profullstack/leaderboard": ["@profullstack/leaderboard@0.3.0", "", {}, "sha512-SKPmIqhmrAhFQqHl9wJHV1NzJHUCNBsqWc1F6M5AeFfphXWGfKSmgeCC3QN37JH/cQ1NxS8kYC/DqbjQqNBoaQ=="], + "@profullstack/nichedb": ["@profullstack/nichedb@workspace:apps/cli"], "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], diff --git a/packages/db/migrations/0005_leaderboard_badges.sql b/packages/db/migrations/0005_leaderboard_badges.sql new file mode 100644 index 0000000..7b2d2ba --- /dev/null +++ b/packages/db/migrations/0005_leaderboard_badges.sql @@ -0,0 +1,12 @@ +-- Badges for the public leaderboard (@profullstack/leaderboard). +-- +-- Everything else the board shows is projected live out of crawl_sales and +-- referral_usages, so it has no tables of its own. Badges are the exception: +-- they are awarded at a moment and then kept, so "top ten this week" still +-- reads as earned after the week ends. That fact lives nowhere else. +create table if not exists leaderboard_badges ( + player text not null, + badge text not null, + awarded_at timestamptz not null default now(), + primary key (player, badge) +);