Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 11 additions & 0 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
161 changes: 161 additions & 0 deletions apps/web/src/lib/leaderboard.js
Original file line number Diff line number Diff line change
@@ -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,
});
15 changes: 14 additions & 1 deletion apps/web/src/lib/pricing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/db/migrations/0005_leaderboard_badges.sql
Original file line number Diff line number Diff line change
@@ -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)
);
Loading