diff --git a/src/html.ts b/src/html.ts index 91e111b..61b6900 100644 --- a/src/html.ts +++ b/src/html.ts @@ -77,6 +77,7 @@ ${options.body} Privacy Connect to Moshpit Build an app + Promoter program Web ring Find a pro Claim your listing diff --git a/src/promoters-routes.ts b/src/promoters-routes.ts new file mode 100644 index 0000000..391f9db --- /dev/null +++ b/src/promoters-routes.ts @@ -0,0 +1,193 @@ +/** + * Routes for the pit.world promoter program (src/promoters.ts): + * + * /promote the offer (public, indexable); shows your link if you joined + * /r/:code a promoter's short share link -> /promote?ref=code + * POST /promote/join become a promoter (signed in) + * /promote/start start a free 30-day trial, attributing the referrer + * /promote/dashboard your link, referrals and accrued commission (signed in) + * + * Attribution rides the URL (?ref=code) through sign-in, so no tracking cookie + * is set — consistent with pit.world setting a cookie only on the account + * routes. These routes read the session cookie but never set one. + */ +import type { Context, Hono } from 'hono' +import { SESSION_COOKIE, type Account, type Accounts } from './accounts.js' +import { parseCookies } from './accounts-routes.js' +import { docPage, esc } from './html.js' +import { COMMISSION_RATE, type Promoters } from './promoters.js' + +export type PromoteDeps = { + promoters: Promoters + accounts: Accounts + /** https://pit.world: where share links point. */ + site: string +} + +const PCT = `${Math.round(COMMISSION_RATE * 100)}%` +const money = (cents: number) => `$${(cents / 100).toFixed(2)}` +const refLink = (site: string, code: string) => `${site}/r/${code}` + +export function mountPromote(app: Hono, d: PromoteDeps) { + const { promoters, accounts, site } = d + const origin = new URL(site).origin + + const current = (c: Context): Account | null => accounts.sessionAccount(parseCookies(c.req.header('cookie'))[SESSION_COOKIE]) + const cleanRef = (raw: unknown): string | null => (promoters.byCode(String(raw ?? '')) ? String(raw) : null) + const sameOrigin = (c: Context) => { + const fetchSite = c.req.header('sec-fetch-site') + if (fetchSite) return fetchSite === 'same-origin' + try { + return new URL(c.req.header('origin') ?? c.req.header('referer') ?? '').origin === origin + } catch { + return false + } + } + const page = (c: Context, title: string, body: string, opts: { noindex?: boolean; status?: number } = {}) => { + c.header('cache-control', opts.noindex ? 'no-store' : 'public, max-age=300') + return c.html(docPage({ title: `${title} · pit.world`, description: `Bring people to the Mosh Pit and keep ${PCT} of everything they spend. Free 30-day trial, then $2 a month.`, path: '/promote', ...(opts.noindex ? { noindex: true } : {}), body }), (opts.status ?? 200) as 200) + } + const form = async (c: Context): Promise> => { + const body = await c.req.parseBody().catch(() => ({})) + return Object.fromEntries(Object.entries(body).map(([k, v]) => [k, typeof v === 'string' ? v : ''])) + } + + // ---- the offer (public) -------------------------------------------------------- + + app.get('/promote', (c) => { + const me = current(c) + const ref = cleanRef(c.req.query('ref')) + const mine = me ? promoters.byAccount(me.id) : null + + const invited = ref + ? `

You were invited by a promoter. Your 30-day trial is free. Start free trial →

` + : '' + + let cta: string + if (mine) { + cta = `

Your promoter link

+

Share this. Everyone who joins through it is yours, for as long as they stay.

+
${esc(refLink(site, mine.code))}
+

Your dashboard →

` + } else if (me) { + cta = `

Become a promoter

+
+

You get a link, a dashboard, and ${PCT} of everything the people you bring spend.

` + } else { + cta = `

Become a promoter

+

Sign in to get your link →

+

Sign in with CoinPay. No card, no password.

` + } + + return page( + c, + `Promoter program: keep ${PCT} of everything your people spend`, + `

Promoter program

+

Bring people to the Mosh Pit. Keep ${PCT} of everything they spend.

+

pit.world charges a cover because you are not the product — no big-tech trackers, no profile, no selling your moves. Send people in and you keep ${PCT} of everything they spend here, for as long as they stay: builds, hosting, Moshpit names, memberships. $2 a month in, your best month out.

+${invited} +
+
Freefor 30 days, then $2/mo

Access the Mosh Pit as a business, promoter or influencer. Paid in crypto through CoinPay. No card.

+
${PCT}of everything your people spend

Not ${PCT} of one sub — ${PCT} across the whole catalog, every month they stay. Your ceiling is how good you are.

+
+${cta} +

How it works

+
    +
  1. Join. Sign in with CoinPay and get your link.
  2. +
  3. Share it. Anyone who joins through your link is attributed to you — recorded only when they join, never tracked click by click.
  4. +
  5. Earn. ${PCT} of what they spend accrues on your dashboard and pays out monthly in crypto.
  6. +
+

Questions

+

What does it cost?

+

Nothing for 30 days, then $2 a month for Mosh Pit access. The promoter program itself is free.

+

How is ${PCT} calculated?

+

${PCT} of everything a person you referred spends across pit.world and the Moshpit catalog, for as long as they stay a paying member.

+

How do I get paid?

+

In crypto through CoinPay. Commission accrues on your dashboard and is paid monthly.

`, + ) + }) + + // A promoter's short share link. + app.get('/r/:code', (c) => { + const code = c.req.param('code') + return c.redirect(promoters.byCode(code) ? `/promote?ref=${encodeURIComponent(code)}` : '/promote', 302) + }) + + // ---- become a promoter --------------------------------------------------------- + + app.post('/promote/join', async (c) => { + const me = current(c) + if (!me) return c.redirect(`/signin?next=${encodeURIComponent('/promote')}`, 302) + if (!sameOrigin(c)) return c.text('Forbidden', 403) + promoters.join(me.id) + return c.redirect('/promote/dashboard', 302) + }) + + // ---- start a free trial (attributes the referrer) ------------------------------ + + app.get('/promote/start', (c) => { + const me = current(c) + const ref = cleanRef(c.req.query('ref')) + const nextTo = `/promote/start${ref ? `?ref=${encodeURIComponent(ref)}` : ''}` + if (!me) return c.redirect(`/signin?next=${encodeURIComponent(nextTo)}`, 302) + const existing = promoters.member(me.id) + if (existing) return c.redirect('/promote/dashboard', 302) + return page( + c, + 'Start your free trial', + `

Start your free 30-day trial

+${ref ? `

Invited by a promoter.

` : ''} +

30 days free, then $2 a month for Mosh Pit access, paid in crypto through CoinPay. Cancel any time before the trial ends and you are never charged.

+
${ref ? `` : ''}
`, + { noindex: true }, + ) + }) + + app.post('/promote/start', async (c) => { + const me = current(c) + if (!me) return c.redirect(`/signin?next=${encodeURIComponent('/promote/start')}`, 302) + if (!sameOrigin(c)) return c.text('Forbidden', 403) + const ref = cleanRef((await form(c)).ref) + promoters.startMembership(me.id, ref) + return c.redirect('/promote/dashboard', 302) + }) + + // ---- your dashboard ------------------------------------------------------------ + + app.get('/promote/dashboard', (c) => { + const me = current(c) + if (!me) return c.redirect(`/signin?next=${encodeURIComponent('/promote/dashboard')}`, 302) + const stats = promoters.stats(me.id) + if (!stats) { + return page( + c, + 'Promoter dashboard', + `

Become a promoter first

+

You do not have a promoter link yet.

+
`, + { noindex: true }, + ) + } + const trial = + stats.trial_end && stats.trial_end > Date.now() + ? `Free trial — ${Math.ceil((stats.trial_end - Date.now()) / 86_400_000)} days left` + : stats.member_status + ? 'Trial ended' + : 'Not a member yet' + return page( + c, + 'Promoter dashboard', + `

Your promoter dashboard

+

Your link

+
${esc(refLink(site, stats.code))}
+

Share it anywhere. People who join through it are attributed to you.

+ + + + +
People referred${stats.referred}
Commission accrued${money(stats.commission_cents)} (${PCT} of their spend, paid monthly in crypto)
Your membership${esc(trial)}
+${stats.member_status ? '' : `

Start your own free trial →

`}`, + { noindex: true }, + ) + }) +} diff --git a/src/promoters.ts b/src/promoters.ts new file mode 100644 index 0000000..bf19068 --- /dev/null +++ b/src/promoters.ts @@ -0,0 +1,150 @@ +/** + * The pit.world promoter program: bring people to the Mosh Pit, keep 20% of + * everything they spend. + * + * It fits pit.world's no-tracking posture. Nothing is logged per click — a + * referral is recorded only at the moment a person *converts* (starts a + * membership), and the promoter code rides the URL through sign-in, so no + * tracking cookie is ever set. A promoter is just an account that joined; the + * code is its public, shareable id. + * + * Billing is staged: a membership starts as a free 30-day trial here. The $2/mo + * charge after the trial and the 20% payout execution are a separate CoinPay + * integration (none exists in this repo yet). Commission is accrued in a ledger + * the moment a referred account spends — recordCommission() — so the dashboard + * is honest from day one even before payouts are automated. + */ +import { Database } from 'bun:sqlite' +import { randomBytes } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +export type Promoter = { account_id: string; code: string; created_at: number } +export type Membership = { status: string; trial_end: number | null; referred_by: string | null } +export type PromoterStats = { + code: string + referred: number + commission_cents: number + trial_end: number | null + member_status: string | null +} + +/** Trial length before the $2/mo membership would begin. */ +export const TRIAL_MS = 30 * 24 * 60 * 60 * 1000 +/** The house commission rate, applied to everything a referral spends. */ +export const COMMISSION_RATE = 0.2 + +// No vowels and no ambiguous glyphs, so a code is safe to read aloud and type. +const CODE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789' +export const CODE_RE = /^[a-hj-km-np-z2-9]{8}$/ + +export class Promoters { + readonly db: Database + + constructor(path = ':memory:', private now: () => number = Date.now) { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }) + this.db = new Database(path, { create: true }) + this.db.run('PRAGMA journal_mode = WAL') + this.db.run( + `CREATE TABLE IF NOT EXISTS promoters ( + account_id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL)`, + ) + this.db.run( + `CREATE TABLE IF NOT EXISTS members ( + account_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + trial_end INTEGER, + referred_by TEXT, + created_at INTEGER NOT NULL)`, + ) + // One row per attributed spend, so a payout run is a sum over a promoter. + this.db.run( + `CREATE TABLE IF NOT EXISTS commissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + promoter_code TEXT NOT NULL, + referred_account_id TEXT NOT NULL, + amount_cents INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL)`, + ) + this.db.run('CREATE INDEX IF NOT EXISTS idx_members_ref ON members(referred_by)') + this.db.run('CREATE INDEX IF NOT EXISTS idx_commissions_code ON commissions(promoter_code)') + } + + private genCode(): string { + for (;;) { + let code = '' + for (const b of randomBytes(8)) code += CODE_ALPHABET[b % CODE_ALPHABET.length] + if (!this.byCode(code)) return code + } + } + + /** Make this account a promoter, or return the one it already is. Idempotent. */ + join(accountId: string): Promoter { + const existing = this.byAccount(accountId) + if (existing) return existing + const promoter: Promoter = { account_id: accountId, code: this.genCode(), created_at: this.now() } + this.db.run('INSERT INTO promoters (account_id, code, created_at) VALUES (?, ?, ?)', [promoter.account_id, promoter.code, promoter.created_at]) + return promoter + } + + byAccount(accountId: string): Promoter | null { + return this.db.query('SELECT account_id, code, created_at FROM promoters WHERE account_id = ?').get(accountId) ?? null + } + + byCode(code: string): Promoter | null { + if (!CODE_RE.test(code)) return null + return this.db.query('SELECT account_id, code, created_at FROM promoters WHERE code = ?').get(code) ?? null + } + + member(accountId: string): Membership | null { + return this.db.query('SELECT status, trial_end, referred_by FROM members WHERE account_id = ?').get(accountId) ?? null + } + + /** + * Start a membership on a free 30-day trial, or return the existing one. The + * referrer is attributed exactly once, here, and only when the code is real + * and is not the member's own. + */ + startMembership(accountId: string, refCode?: string | null): Membership { + const existing = this.member(accountId) + if (existing) return existing + let referred_by: string | null = null + if (refCode) { + const promoter = this.byCode(refCode) + if (promoter && promoter.account_id !== accountId) referred_by = promoter.code + } + const now = this.now() + const membership: Membership = { status: 'trialing', trial_end: now + TRIAL_MS, referred_by } + this.db.run('INSERT INTO members (account_id, status, trial_end, referred_by, created_at) VALUES (?, ?, ?, ?, ?)', [accountId, membership.status, membership.trial_end, referred_by, now]) + return membership + } + + /** + * Accrue commission owed to a promoter for a referred account's spend. Called + * when a charge lands (staged billing); 20% of the amount by house rate. + */ + recordSpend(referredAccountId: string, amountCents: number, note = ''): number { + const m = this.member(referredAccountId) + if (!m?.referred_by) return 0 + const commission = Math.round(amountCents * COMMISSION_RATE) + if (commission <= 0) return 0 + this.db.run('INSERT INTO commissions (promoter_code, referred_account_id, amount_cents, note, created_at) VALUES (?, ?, ?, ?, ?)', [m.referred_by, referredAccountId, commission, note, this.now()]) + return commission + } + + stats(accountId: string): PromoterStats | null { + const promoter = this.byAccount(accountId) + if (!promoter) return null + const referred = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM members WHERE referred_by = ?').get(promoter.code)?.n ?? 0 + const commission_cents = this.db.query<{ s: number }, [string]>('SELECT COALESCE(SUM(amount_cents), 0) AS s FROM commissions WHERE promoter_code = ?').get(promoter.code)?.s ?? 0 + const m = this.member(accountId) + return { code: promoter.code, referred, commission_cents, trial_end: m?.trial_end ?? null, member_status: m?.status ?? null } + } + + count(): number { + return this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM promoters').get()?.n ?? 0 + } +} diff --git a/src/server.ts b/src/server.ts index 9d0bd99..e2f58dd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,8 @@ import { COOKIE_PATHS, mountAccounts, parseCookies, type AccountDeps } from './a import { SESSION_COOKIE } from './accounts.js' import { Promotion } from './promotion.js' import { mountPromotion } from './promotion-routes.js' +import { Promoters } from './promoters.js' +import { mountPromote } from './promoters-routes.js' import { Boards, serviceRequest } from './services.js' import { Cache, normalizeQuery, search, SearchUnavailable, type Keys, type SearchResult } from './search.js' import { parseVertical, searchMedia, thumbAllowed, type MediaResult, type Vertical } from './verticals.js' @@ -47,6 +49,8 @@ export type Deps = { accounts?: AccountDeps /** Promotion campaigns over the listings (needs listings); unset means off. */ promotion?: Promotion + /** Promoter/affiliate program (needs accounts); unset means off. */ + promoters?: Promoters /** Addresses whose visits are ours, excluded from promotion measurement. */ internalIps?: string[] } @@ -137,6 +141,7 @@ export function createApp(deps: Deps) { app.get('/about', (c) => c.html(aboutPage())) app.get('/connect', (c) => c.html(connectPage())) app.get('/build', (c) => c.html(buildPage())) + // /promote and its sub-routes are mounted by mountPromote when accounts are on. // --- the pit.world ring ---------------------------------------------------------- useListings(deps.listings ? () => deps.listings!.ringMembers() : null) @@ -161,7 +166,7 @@ export function createApp(deps: Deps) { ) app.get('/sitemap.xml', (c) => c.body( - `\n${['/', '/connect', '/build', '/ring', '/pros', '/b', '/about', '/privacy', ...(deps.listings ? deps.listings.directoryDomains().map((d) => `/b/${encodeURIComponent(d)}`) : [])] + `\n${['/', '/connect', '/build', ...(deps.promoters ? ['/promote'] : []), '/ring', '/pros', '/b', '/about', '/privacy', ...(deps.listings ? deps.listings.directoryDomains().map((d) => `/b/${encodeURIComponent(d)}`) : [])] .map((p) => `https://pit.world${p}`) .join('')}`, 200, @@ -280,6 +285,7 @@ export function createApp(deps: Deps) { ) if (deps.accounts) mountAccounts(app, deps.accounts) + if (deps.promoters && deps.accounts) mountPromote(app, { promoters: deps.promoters, accounts: deps.accounts.accounts, site: deps.accounts.site }) const promoHooks = deps.listings && deps.promotion ? mountPromotion(app, { @@ -340,8 +346,9 @@ export function serve(options: { port?: number; hostname?: string } = {}) { admins: (env.PIT_ADMIN_SUBS ?? '').split(',').map((s) => s.trim()).filter(Boolean), } const promotion = new Promotion(listings, site) + const promoters = new Promoters(env.PIT_PROMOTERS_DB ?? `${dataDir}/promoters.db`) const internalIps = (env.PIT_INTERNAL_IPS ?? '').split(',').map((s) => s.trim()).filter(Boolean) - const app = createApp({ keys: keysFromEnv(), newsletter, listings, accounts, promotion, internalIps, ...(ringSync ? { ringSync } : {}), ...(env.PIT_ADMIN_TOKEN ? { adminToken: env.PIT_ADMIN_TOKEN } : {}) }) + const app = createApp({ keys: keysFromEnv(), newsletter, listings, accounts, promotion, promoters, internalIps, ...(ringSync ? { ringSync } : {}), ...(env.PIT_ADMIN_TOKEN ? { adminToken: env.PIT_ADMIN_TOKEN } : {}) }) // Every hour: start measurement windows that are ready, mark ended ones due and write their reports and drafts. Sends nothing. const hourly = () => { try { diff --git a/test/promoters.test.ts b/test/promoters.test.ts new file mode 100644 index 0000000..b3ee51f --- /dev/null +++ b/test/promoters.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'bun:test' +import { Accounts } from '../src/accounts.js' +import { createApp } from '../src/server.js' +import { CODE_RE, COMMISSION_RATE, Promoters, TRIAL_MS } from '../src/promoters.js' + +function app() { + const promoters = new Promoters(':memory:') + const accounts: Accounts = new Accounts(':memory:') + const hono = createApp({ keys: {}, promoters, accounts: { accounts, site: 'https://pit.world', coinpay: null, bridgeKey: null } }) + const get = (path: string, ip = '203.0.113.7') => hono.request(path, {}, { ip }) + const post = (path: string, ip = '203.0.113.7') => hono.request(path, { method: 'POST' }, { ip }) + return { promoters, get, post } +} + +describe('Promoters', () => { + it('join is idempotent and issues a readable code', () => { + const p = new Promoters(':memory:') + const a = p.join('acc-1') + const b = p.join('acc-1') + expect(a.code).toBe(b.code) + expect(CODE_RE.test(a.code)).toBe(true) + expect(p.count()).toBe(1) + }) + + it('byCode rejects malformed codes without a DB hit', () => { + const p = new Promoters(':memory:') + expect(p.byCode('not a code')).toBeNull() + expect(p.byCode('')).toBeNull() + }) + + it('starts a 30-day trial and attributes a valid referrer exactly once', () => { + const now = Date.UTC(2026, 0, 1) + const p = new Promoters(':memory:', () => now) + const promoter = p.join('promoter-1') + const m = p.startMembership('visitor-1', promoter.code) + expect(m.status).toBe('trialing') + expect(m.trial_end).toBe(now + TRIAL_MS) + expect(m.referred_by).toBe(promoter.code) + // Idempotent: a second start keeps the first attribution. + expect(p.startMembership('visitor-1', 'anothercode').referred_by).toBe(promoter.code) + }) + + it('never attributes a self-referral or an unknown code', () => { + const p = new Promoters(':memory:') + const promoter = p.join('promoter-1') + expect(p.startMembership('promoter-1', promoter.code).referred_by).toBeNull() + expect(p.startMembership('visitor-2', 'boguscode').referred_by).toBeNull() + }) + + it('accrues 20% commission only for a referred member and only on spend', () => { + const p = new Promoters(':memory:') + const promoter = p.join('promoter-1') + p.startMembership('visitor-1', promoter.code) + expect(p.recordSpend('visitor-1', 4900, 'build')).toBe(Math.round(4900 * COMMISSION_RATE)) // $49 build -> $9.80 + p.recordSpend('visitor-1', 200, 'hosting') // $2/mo -> $0.40 + const stats = p.stats('promoter-1')! + expect(stats.referred).toBe(1) + expect(stats.commission_cents).toBe(980 + 40) + // An un-referred member earns the promoter nothing. + p.startMembership('visitor-3', null) + expect(p.recordSpend('visitor-3', 10000)).toBe(0) + }) +}) + +describe('/promote routes', () => { + it('serves the public offer page with the rate and prices', async () => { + const { get } = app() + const res = await get('/promote') + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('20%') + expect(html).toContain('Promoter program') + expect(html).toContain('30 days') + }) + + it('requires sign-in to become a promoter', async () => { + const { post } = app() + const res = await post('/promote/join') + expect(res.status).toBe(302) + expect(res.headers.get('location')).toContain('/signin') + }) + + it('redirects an unknown short link to the offer', async () => { + const { get } = app() + const res = await get('/r/zzzzzzzz') + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/promote') + }) +})