> => {
+ 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/moAccess the Mosh Pit as a business, promoter or influencer. Paid in crypto through CoinPay. No card.
+
${PCT}of everything your people spendNot ${PCT} of one sub — ${PCT} across the whole catalog, every month they stay. Your ceiling is how good you are.
+
+${cta}
+How it works
+
+- Join. Sign in with CoinPay and get your link.
+- Share it. Anyone who joins through your link is attributed to you — recorded only when they join, never tracked click by click.
+- Earn. ${PCT} of what they spend accrues on your dashboard and pays out monthly in crypto.
+
+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.
+`,
+ { 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')
+ })
+})