Files
970e9d5ab3 feat(waitlist): neighborhood + award + social/hanzod on the client and spec (#2)
Track the Base points engine on the wire:
- client: add WaitlistClient.neighborhood() (the scalable rank +/- window "around
  me" view backed by keyset seeks) + NeighborhoodInput/NeighborRow/
  NeighborhoodResponse; extend ActivityType with 'social' | 'hanzod' and
  PointBreakdown with optional social/hanzod subtotals.
- mock-api (executable spec): add GET /neighborhood and the server-to-server
  POST /award (Bearer WAITLIST_AWARD_SECRET, source->points, deduped per
  (source,dedupKey)); add SOCIAL/HANZOD point values, social/hanzod breakdown
  categories, and social/hanzod activity types. The mock stays byte-for-byte
  with plugins/waitlist in hanzoai/base.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-08 09:21:51 -07:00

717 lines
29 KiB
JavaScript

#!/usr/bin/env node
// Reference implementation of the /v1/waitlist/* contract.
//
// Mirrors the Hanzo Base plugin at ~/work/hanzo/base/plugins/waitlist
// byte-for-byte on the wire format. In-memory only — restart drops state.
// Used by the demo and serves as an executable spec for anyone reimplementing
// the contract.
//
// Run:
// PORT=8090 node server.js
//
// Endpoints:
// POST /v1/waitlist/join { waitlist, email, referrerCode?, turnstileToken? }
// GET /v1/waitlist/status?waitlist=&email=
// GET /v1/waitlist/neighborhood?waitlist=&email=&window= (rank +/- window)
// GET /v1/waitlist/list?waitlist=&page=&pageSize=
// GET /v1/waitlist/activity?waitlist=&limit=&types=
// POST /v1/waitlist/track-share { waitlist, refCode, platform }
// POST /v1/waitlist/invite { waitlist, refCode, emails: [...], message? }
// POST /v1/waitlist/award { waitlist, email|refCode, source, dedupKey?, points? }
// (Bearer auth via WAITLIST_AWARD_SECRET — verified social/hanzod)
// GET /v1/waitlist/export?waitlist= (Bearer auth via WAITLIST_ADMIN_SECRET)
//
// Points engine:
// • each entry has `points` and a per-source breakdown
// • referral (someone joins via your refCode): POINTS_REFERRAL (default 10)
// • share-action click (per platform per day): POINTS_SHARE (default 2)
// • invite-sent (per valid email submitted): POINTS_INVITE_SENT (default 1)
// • invited friend joins: POINTS_INVITE_CONVERTED (default 5)
// Leaderboard sorts by points DESC, createdAt ASC.
import http from 'node:http'
import crypto from 'node:crypto'
const PORT = Number(process.env.PORT ?? 8090)
const ADMIN_SECRET = process.env.WAITLIST_ADMIN_SECRET ?? ''
// AWARD_SECRET gates the server-to-server POST /award (verified social / hanzod
// events). Empty -> /award disabled (404), so it can never be forged publicly.
const AWARD_SECRET = process.env.WAITLIST_AWARD_SECRET ?? ''
const RATE_LIMIT = Number(process.env.WAITLIST_RATE_LIMIT ?? 5)
const RATE_WINDOW_MS = 60 * 60 * 1000
// Point values — overridable via env so each consumer can tune.
const POINTS = {
REFERRAL: Number(process.env.POINTS_REFERRAL ?? 10),
SHARE: Number(process.env.POINTS_SHARE ?? 2),
INVITE_SENT: Number(process.env.POINTS_INVITE_SENT ?? 1),
INVITE_CONVERTED: Number(process.env.POINTS_INVITE_CONVERTED ?? 5),
SOCIAL: Number(process.env.POINTS_SOCIAL ?? 15),
HANZOD: Number(process.env.POINTS_HANZOD ?? 25),
}
const INVITE_MAX_BATCH = Number(process.env.WAITLIST_INVITE_MAX ?? 50)
const ACTIVITY_MAX_HISTORY = Number(process.env.ACTIVITY_MAX_HISTORY ?? 200)
const ACTIVITY_LIMIT_CAP = Number(process.env.ACTIVITY_LIMIT_CAP ?? 100)
const ACTIVITY_TYPES = new Set(['join', 'share', 'invite', 'referral', 'social', 'hanzod'])
const SHARE_PLATFORMS = new Set([
'webshare','email','x','twitter','linkedin','facebook',
'reddit','telegram','whatsapp','sms','copy','mastodon','bluesky','threads',
])
const REF_ALPHABET = '6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz'
const DISPOSABLE = new Set([
'tempmail.com','guerrillamail.com','10minutemail.com','mailinator.com',
'trashmail.com','getairmail.com','yopmail.com','maildrop.cc',
'throwaway.email','fakeinbox.com',
])
// In-memory store: slug -> { entries: Map<email, Entry>, activity: [Event...] }
const lists = new Map()
function listOf(slug) {
let l = lists.get(slug)
if (!l) {
l = { entries: new Map(), activity: [] }
lists.set(slug, l)
}
if (!l.activity) l.activity = []
return l
}
// Push an event; ring-buffer to ACTIVITY_MAX_HISTORY.
function pushActivity(list, evt) {
list.activity.push({ ts: Date.now(), ...evt })
if (list.activity.length > ACTIVITY_MAX_HISTORY) {
list.activity.splice(0, list.activity.length - ACTIVITY_MAX_HISTORY)
}
}
// Sliding-window rate limit keyed by IP.
const hits = new Map()
function allow(key) {
if (RATE_LIMIT <= 0) return true
const now = Date.now()
const cutoff = now - RATE_WINDOW_MS
const arr = (hits.get(key) || []).filter((t) => t > cutoff)
if (arr.length >= RATE_LIMIT) {
hits.set(key, arr)
return false
}
arr.push(now)
hits.set(key, arr)
return true
}
function genRefCode() {
const bytes = crypto.randomBytes(8)
let out = ''
for (let i = 0; i < 8; i++) out += REF_ALPHABET[bytes[i] % REF_ALPHABET.length]
return out
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function isValidEmail(s) { return typeof s === 'string' && s.length <= 254 && EMAIL_RE.test(s) }
function domain(email) { const i = email.lastIndexOf('@'); return i < 0 ? '' : email.slice(i+1).toLowerCase() }
function totalPoints(entry) {
const b = entry.pointBreakdown
return (b.referrals + b.shares + b.invitesSent + b.invitesConverted
+ (b.social || 0) + (b.hanzod || 0))
}
// categoryOf buckets an award source into a breakdown category. Open-ended:
// a new social network is still "social", a new source adds its own key.
function categoryOf(source) {
if (source === 'referral') return 'referrals'
if (source.startsWith('share:')) return 'shares'
if (source === 'invite_sent') return 'invitesSent'
if (source === 'invite_converted') return 'invitesConverted'
if (source.startsWith('social:')) return 'social'
if (source.startsWith('hanzod')) return 'hanzod'
return 'other'
}
// sourcePoints is the server-controlled award value — a caller of /award cannot
// mint arbitrary points for a known source (only an explicit "grant" does).
function sourcePoints(source) {
if (source === 'referral') return POINTS.REFERRAL
if (source.startsWith('share:')) return POINTS.SHARE
if (source === 'invite_sent') return POINTS.INVITE_SENT
if (source === 'invite_converted') return POINTS.INVITE_CONVERTED
if (source.startsWith('social:')) return POINTS.SOCIAL
if (source.startsWith('hanzod')) return POINTS.HANZOD
return 0
}
function validAwardSource(source) {
return source === 'referral' || source === 'invite_sent' || source === 'invite_converted' ||
source === 'grant' || source.startsWith('share:') || source.startsWith('social:') ||
source.startsWith('hanzod')
}
function computeRank(list, entry) {
const all = [...list.entries.values()]
const total = all.length
// Sort: points DESC, createdAt ASC (earlier joiners break ties)
all.sort((a, b) => totalPoints(b) - totalPoints(a) || a.createdAt - b.createdAt)
const rank = all.findIndex((e) => e.email === entry.email) + 1
return { rank, total }
}
function findEntryByRefCode(list, refCode) {
for (const e of list.entries.values()) {
if (e.refCode === refCode) return e
}
return null
}
function entryToJoinResponse(list, entry, alreadyJoined) {
const { rank, total } = computeRank(list, entry)
return {
ok: true,
waitlist: entry.waitlist,
email: entry.email,
refCode: entry.refCode,
rank,
total,
points: totalPoints(entry),
pointBreakdown: { ...entry.pointBreakdown },
pointValues: { ...POINTS },
referralCount: entry.referralCount,
shareUrl: `?ref=${entry.refCode}`,
...(alreadyJoined ? { alreadyJoined: true } : {}),
}
}
// Mask local-part of email for non-admin views: [email protected] -> a***[email protected].
function maskEmail(email) {
const at = email.indexOf('@')
if (at < 2) return '*'.repeat(Math.max(1, at)) + email.slice(at)
const local = email.slice(0, at)
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local[local.length - 1] + email.slice(at)
}
function entryToStatusResponse(list, entry) {
const { rank, total } = computeRank(list, entry)
return {
ok: true,
waitlist: entry.waitlist,
email: entry.email,
refCode: entry.refCode,
rank,
total,
aheadOf: Math.max(0, total - rank),
points: totalPoints(entry),
pointBreakdown: { ...entry.pointBreakdown },
pointValues: { ...POINTS },
referralCount: entry.referralCount,
shareUrl: `?ref=${entry.refCode}`,
}
}
// --- HTTP helpers ----------------------------------------------------------
function send(res, status, body, headers = {}) {
const isString = typeof body === 'string'
res.writeHead(status, {
'Content-Type': isString ? 'text/plain; charset=utf-8' : 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
...headers,
})
res.end(isString ? body : JSON.stringify(body))
}
function err(res, status, message, data) {
send(res, status, { status, message, data })
}
async function readJson(req) {
return new Promise((resolve, reject) => {
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8')
if (!raw) return resolve({})
try { resolve(JSON.parse(raw)) } catch (e) { reject(e) }
})
req.on('error', reject)
})
}
function clientIp(req) {
return (req.headers['x-forwarded-for']?.toString().split(',')[0].trim()
|| req.socket.remoteAddress
|| 'unknown')
}
// --- Handlers --------------------------------------------------------------
async function handleJoin(req, res) {
let body
try { body = await readJson(req) } catch { return err(res, 400, 'invalid json') }
const slug = (body.waitlist || '').toString().trim()
const email = (body.email || '').toString().trim().toLowerCase()
const referrerCode = (body.referrerCode || '').toString().trim()
if (!slug || !email) return err(res, 400, 'waitlist and email are required')
if (!isValidEmail(email)) return err(res, 400, 'invalid email')
if (DISPOSABLE.has(domain(email))) return err(res, 400, 'disposable email not allowed')
const ip = clientIp(req)
if (!allow(`join:${ip}`)) return err(res, 429, 'rate limit exceeded')
// Turnstile is a no-op in the mock — host-side concern.
const list = listOf(slug)
const existing = list.entries.get(email)
if (existing) return send(res, 200, entryToJoinResponse(list, existing, true))
// Credit referrer (if any, and not self-ref).
let referrerEntry = null
if (referrerCode) {
referrerEntry = findEntryByRefCode(list, referrerCode)
if (referrerEntry && referrerEntry.email !== email) {
referrerEntry.referralCount += 1
referrerEntry.pointBreakdown.referrals += POINTS.REFERRAL
// If this email was previously invited by the referrer, also credit
// the conversion bonus.
if (referrerEntry.invitedEmails?.has(email)) {
referrerEntry.pointBreakdown.invitesConverted += POINTS.INVITE_CONVERTED
}
} else {
referrerEntry = null
}
}
// Allocate unique refCode.
let refCode
for (let i = 0; i < 8; i++) {
const cand = genRefCode()
if (![...list.entries.values()].some((e) => e.refCode === cand)) { refCode = cand; break }
}
if (!refCode) return err(res, 500, 'failed to allocate refCode')
const entry = {
waitlist: slug,
email,
refCode,
referredBy: referrerCode || '',
referralCount: 0,
createdAt: Date.now(),
// Gamification state.
pointBreakdown: { referrals: 0, shares: 0, invitesSent: 0, invitesConverted: 0, social: 0, hanzod: 0 },
sharedPlatforms: new Map(), // platform -> last ISO date (yyyy-mm-dd)
invitedEmails: new Set(), // emails this entry has sent invitations to
awardedKeys: new Set(), // (source, dedupKey) already credited — dedup spine
}
list.entries.set(email, entry)
// Activity log: a join, and a separate referral event if applicable.
pushActivity(list, { type: 'join', who: maskEmail(email), source: referrerEntry ? 'referral' : 'direct' })
if (referrerEntry) {
pushActivity(list, { type: 'referral', who: maskEmail(referrerEntry.email), invited: maskEmail(email) })
}
return send(res, 200, entryToJoinResponse(list, entry, false))
}
// --- POST /v1/waitlist/track-share ---
async function handleTrackShare(req, res) {
let body
try { body = await readJson(req) } catch { return err(res, 400, 'invalid json') }
const slug = (body.waitlist || '').toString().trim()
const refCode = (body.refCode || '').toString().trim()
const platform = (body.platform || '').toString().trim().toLowerCase()
if (!slug || !refCode || !platform) return err(res, 400, 'waitlist, refCode, platform required')
if (!SHARE_PLATFORMS.has(platform)) return err(res, 400, `unknown platform: ${platform}`)
const list = lists.get(slug)
if (!list) return err(res, 404, 'waitlist not found')
const entry = findEntryByRefCode(list, refCode)
if (!entry) return err(res, 404, 'entry not found')
const today = new Date().toISOString().slice(0, 10)
const lastDay = entry.sharedPlatforms.get(platform)
const alreadyToday = lastDay === today
if (!alreadyToday) {
entry.sharedPlatforms.set(platform, today)
entry.pointBreakdown.shares += POINTS.SHARE
pushActivity(list, { type: 'share', who: maskEmail(entry.email), platform })
}
return send(res, 200, {
ok: true,
awarded: alreadyToday ? 0 : POINTS.SHARE,
alreadyClaimed: alreadyToday,
points: totalPoints(entry),
pointBreakdown: { ...entry.pointBreakdown },
})
}
// --- POST /v1/waitlist/invite ---
async function handleInvite(req, res) {
let body
try { body = await readJson(req) } catch { return err(res, 400, 'invalid json') }
const slug = (body.waitlist || '').toString().trim()
const refCode = (body.refCode || '').toString().trim()
const emails = Array.isArray(body.emails) ? body.emails : []
const message = (body.message || '').toString().slice(0, 1000)
if (!slug || !refCode || emails.length === 0) {
return err(res, 400, 'waitlist, refCode, emails required')
}
if (emails.length > INVITE_MAX_BATCH) {
return err(res, 400, `max ${INVITE_MAX_BATCH} emails per batch`)
}
const ip = clientIp(req)
if (!allow(`invite:${ip}`)) return err(res, 429, 'rate limit exceeded')
const list = lists.get(slug)
if (!list) return err(res, 404, 'waitlist not found')
const entry = findEntryByRefCode(list, refCode)
if (!entry) return err(res, 404, 'entry not found')
const seen = new Set()
const sent = []
const skipped = [] // already on the list
const invalid = []
const duplicates = []
for (const raw of emails) {
const e = (raw || '').toString().trim().toLowerCase()
if (!e) continue
if (seen.has(e)) { duplicates.push(e); continue }
seen.add(e)
if (!isValidEmail(e)) { invalid.push(e); continue }
if (DISPOSABLE.has(domain(e))) { invalid.push(e); continue }
if (e === entry.email) { invalid.push(e); continue } // don't invite yourself
if (list.entries.has(e)) { skipped.push(e); continue }
entry.invitedEmails.add(e)
sent.push(e)
entry.pointBreakdown.invitesSent += POINTS.INVITE_SENT
pushActivity(list, { type: 'invite', who: maskEmail(entry.email), invited: maskEmail(e) })
// In production this is where you'd enqueue a real email send. The
// mock just logs so you can see what would have been sent.
console.log(`[mock-api] invite from ${entry.email} -> ${e}` +
(message ? ` ("${message.slice(0, 40)}${message.length > 40 ? '…' : ''}")` : ''))
}
return send(res, 200, {
ok: true,
sent: sent.length,
skipped: skipped.length,
invalid: invalid.length,
duplicates: duplicates.length,
pointsAwarded: sent.length * POINTS.INVITE_SENT,
points: totalPoints(entry),
pointBreakdown: { ...entry.pointBreakdown },
})
}
function handleStatus(req, url, res) {
const slug = (url.searchParams.get('waitlist') || '').trim()
const email = (url.searchParams.get('email') || '').trim().toLowerCase()
if (!slug || !email) return err(res, 400, 'waitlist and email are required')
const list = lists.get(slug)
if (!list) return err(res, 404, 'waitlist not found')
const entry = list.entries.get(email)
if (!entry) return err(res, 404, 'entry not found')
return send(res, 200, entryToStatusResponse(list, entry))
}
function handleList(req, url, res) {
const slug = (url.searchParams.get('waitlist') || '').trim()
if (!slug) return err(res, 400, 'waitlist is required')
const list = lists.get(slug)
if (!list) return send(res, 200, { ok: true, entries: [], page: 1, pageSize: 0, total: 0 })
const page = Math.max(1, Number(url.searchParams.get('page') || 1))
const pageSize = Math.max(1, Math.min(500, Number(url.searchParams.get('pageSize') || 100)))
const isAdmin = !!ADMIN_SECRET && (req.headers['authorization'] || '') === `Bearer ${ADMIN_SECRET}`
const sorted = [...list.entries.values()].sort(
(a, b) => totalPoints(b) - totalPoints(a) || a.createdAt - b.createdAt
)
const total = sorted.length
const start = (page - 1) * pageSize
const slice = sorted.slice(start, start + pageSize).map((e, i) => ({
rank: start + i + 1,
email: isAdmin ? e.email : maskEmail(e.email),
refCode: isAdmin ? e.refCode : null,
points: totalPoints(e),
referralCount: e.referralCount,
createdAt: new Date(e.createdAt).toISOString(),
}))
send(res, 200, {
ok: true,
waitlist: slug,
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
entries: slice,
})
}
// --- GET /v1/waitlist/activity ---
function handleActivity(req, url, res) {
const slug = (url.searchParams.get('waitlist') || '').trim()
if (!slug) return err(res, 400, 'waitlist is required')
const list = lists.get(slug)
const limit = Math.max(1, Math.min(ACTIVITY_LIMIT_CAP, Number(url.searchParams.get('limit') || 20)))
const typesParam = (url.searchParams.get('types') || '').trim()
const typeFilter = typesParam
? new Set(typesParam.split(',').map((s) => s.trim().toLowerCase()).filter((s) => ACTIVITY_TYPES.has(s)))
: null
if (!list) {
return send(res, 200, { ok: true, waitlist: slug, now: Date.now(), entries: [] })
}
const filtered = (typeFilter ? list.activity.filter((e) => typeFilter.has(e.type)) : list.activity)
// Most-recent first.
const sliced = filtered.slice(Math.max(0, filtered.length - limit)).reverse()
return send(res, 200, {
ok: true,
waitlist: slug,
now: Date.now(),
entries: sliced,
})
}
// --- POST /v1/waitlist/award (server-to-server, Bearer AWARD_SECRET) ---
// The verified-event seam: a connector verifies a social follow/join or a
// hanzod run, then calls this to credit points. Idempotent per (source,dedupKey).
async function handleAward(req, res) {
if (!AWARD_SECRET) return err(res, 404, 'award disabled')
if ((req.headers['authorization'] || '') !== `Bearer ${AWARD_SECRET}`) {
return err(res, 401, 'award auth required')
}
let body
try { body = await readJson(req) } catch { return err(res, 400, 'invalid json') }
const slug = (body.waitlist || '').toString().trim()
const source = (body.source || '').toString().trim()
if (!slug || !source) return err(res, 400, 'waitlist and source are required')
if (!validAwardSource(source)) return err(res, 400, `unknown source: ${source}`)
const list = lists.get(slug)
if (!list) return err(res, 404, 'waitlist not found')
const email = (body.email || '').toString().trim().toLowerCase()
const refCode = (body.refCode || '').toString().trim()
const entry = email ? list.entries.get(email) : findEntryByRefCode(list, refCode)
if (!entry) return err(res, 404, 'entry not found')
let pts = sourcePoints(source)
if (source === 'grant' && typeof body.points === 'number') pts = body.points
let dedupKey = (body.dedupKey || '').toString().trim()
if (!dedupKey && source !== 'grant') dedupKey = source
const key = `${source}|${dedupKey}`
const already = dedupKey ? entry.awardedKeys.has(key) : false
if (!already) {
if (dedupKey) entry.awardedKeys.add(key)
const cat = categoryOf(source)
entry.pointBreakdown[cat] = (entry.pointBreakdown[cat] || 0) + pts
if (source.startsWith('social:')) {
pushActivity(list, { type: 'social', who: maskEmail(entry.email), platform: source.split(':')[1] || '' })
} else if (source.startsWith('hanzod')) {
pushActivity(list, { type: 'hanzod', who: maskEmail(entry.email) })
}
}
const { rank, total } = computeRank(list, entry)
return send(res, 200, {
ok: true, awarded: already ? 0 : pts, alreadyAwarded: already, source,
email: entry.email, points: totalPoints(entry),
pointBreakdown: { ...entry.pointBreakdown }, rank, total,
})
}
// --- GET /v1/waitlist/neighborhood?waitlist=&email=&window= ---
// The scalable "around me" view: the caller's rank plus the `window` entries
// just above and below — never the whole list.
function handleNeighborhood(req, url, res) {
const slug = (url.searchParams.get('waitlist') || '').trim()
const email = (url.searchParams.get('email') || '').trim().toLowerCase()
if (!slug || !email) return err(res, 400, 'waitlist and email are required')
const list = lists.get(slug)
if (!list) return err(res, 404, 'waitlist not found')
const entry = list.entries.get(email)
if (!entry) return err(res, 404, 'entry not found')
const window = Math.max(1, Math.min(100, Number(url.searchParams.get('window') || 25)))
const all = [...list.entries.values()].sort(
(a, b) => totalPoints(b) - totalPoints(a) || a.createdAt - b.createdAt)
const idx = all.findIndex((e) => e.email === entry.email)
const start = Math.max(0, idx - window)
const end = Math.min(all.length, idx + window + 1)
const rows = all.slice(start, end).map((e, i) => ({
rank: start + i + 1, email: maskEmail(e.email), points: totalPoints(e),
referralCount: e.referralCount, ...(e.email === entry.email ? { isMe: true } : {}),
}))
return send(res, 200, {
ok: true, waitlist: slug, email, rank: idx + 1, total: all.length,
points: totalPoints(entry), window, entries: rows,
})
}
function handleExport(req, url, res) {
const auth = req.headers['authorization'] || ''
if (!ADMIN_SECRET || auth !== `Bearer ${ADMIN_SECRET}`) {
return err(res, 401, 'admin auth required')
}
const slug = (url.searchParams.get('waitlist') || '').trim()
if (!slug) return err(res, 400, 'waitlist is required')
const list = lists.get(slug)
if (!list) return err(res, 404, 'waitlist not found')
const rows = [...list.entries.values()].sort(
(a, b) => b.referralCount - a.referralCount || a.createdAt - b.createdAt
)
const lines = ['rank,email,refCode,referredBy,referralCount,createdAt']
rows.forEach((e, i) => {
lines.push([
i + 1,
e.email,
e.refCode,
e.referredBy,
e.referralCount,
new Date(e.createdAt).toISOString(),
].join(','))
})
send(res, 200, lines.join('\n'), {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="waitlist-${slug}-${Date.now()}.csv"`,
})
}
// --- Server ---------------------------------------------------------------
const server = http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') return send(res, 204, '')
const url = new URL(req.url || '/', `http://${req.headers.host}`)
if (url.pathname === '/healthz') return send(res, 200, 'ok')
try {
if (req.method === 'POST' && url.pathname === '/v1/waitlist/join') return await handleJoin(req, res)
if (req.method === 'GET' && url.pathname === '/v1/waitlist/status') return handleStatus(req, url, res)
if (req.method === 'GET' && url.pathname === '/v1/waitlist/neighborhood') return handleNeighborhood(req, url, res)
if (req.method === 'POST' && url.pathname === '/v1/waitlist/award') return await handleAward(req, res)
if (req.method === 'GET' && url.pathname === '/v1/waitlist/list') return handleList(req, url, res)
if (req.method === 'GET' && url.pathname === '/v1/waitlist/activity') return handleActivity(req, url, res)
if (req.method === 'POST' && url.pathname === '/v1/waitlist/track-share') return await handleTrackShare(req, res)
if (req.method === 'POST' && url.pathname === '/v1/waitlist/invite') return await handleInvite(req, res)
if (req.method === 'GET' && url.pathname === '/v1/waitlist/export') return handleExport(req, url, res)
} catch (e) {
console.error('[mock-api]', e)
return err(res, 500, 'internal error')
}
err(res, 404, 'not found')
})
// Optional seed mode: SEED=1200 SEED_SLUG=demo creates a realistic
// distribution of entries. Referral counts follow a Zipf-ish curve so
// the top of the leaderboard has the usual handful of power-referrers
// while most entries sit at 0. Determined entirely by a fixed PRNG seed
// so reloads show identical data.
function seedList(slug, n) {
const list = listOf(slug)
// Tiny deterministic PRNG (xmur3 + mulberry32).
function xmur3(str) {
let h = 1779033703 ^ str.length
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353); h = (h << 13) | (h >>> 19)
}
return () => { h = Math.imul(h ^ (h >>> 16), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return (h ^= h >>> 16) >>> 0 }
}
function mulberry32(a) {
return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296 }
}
const seed = xmur3(`hanzo-waitlist:${slug}:${n}`)
const rand = mulberry32(seed())
const FIRST = ['alex','sam','jordan','taylor','riley','casey','morgan','jamie','avery','quinn','rowan','sage','blair','reese','drew','hayden','parker','skylar','emerson','finley','iris','kai','leo','mia','nora','owen','piper','ruby','theo','vera','wren','zoe','aria','ben','cleo','dax','eve','felix','gus','holly']
const LAST = ['ng','park','silva','chen','khan','okafor','ivanov','rossi','dubois','schmidt','garcia','tanaka','novak','vega','khanna','singh','muller','smith','jones','brown','davis','wilson','moore','taylor','andrade','kobayashi','larsen','vasquez','popescu','romero','holm','lindberg','laine','aalto','seppanen','dubois','beauchamp']
const TLD = ['com','io','dev','xyz','co','app','net','me']
function syntheticEmail(i) {
const f = FIRST[Math.floor(rand() * FIRST.length)]
const l = LAST[Math.floor(rand() * LAST.length)]
const t = TLD[Math.floor(rand() * TLD.length)]
return `${f}.${l}${i}@example.${t}`
}
const start = Date.now() - n * 60_000 // backdate so createdAt order is stable
for (let i = 0; i < n; i++) {
const email = syntheticEmail(i)
let refCode = ''
for (let attempt = 0; attempt < 8; attempt++) {
const cand = genRefCode()
if (![...list.entries.values()].some((e) => e.refCode === cand)) { refCode = cand; break }
}
// Zipf-ish: rank^-1.4 normalized to roughly produce top ~40 referrals.
const referralCount = Math.max(0, Math.floor(40 * Math.pow(1 / Math.max(1, i + 1), 1.4) + (rand() < 0.04 ? Math.floor(rand() * 6) : 0)))
// Synthesize a plausible pointBreakdown so seeded leaderboard has real
// gamification depth, not just referral counts.
const shareEvents = Math.min(8, Math.floor(referralCount * 0.6 + rand() * 3))
const invitesSent = Math.min(20, Math.floor(referralCount * 1.5 + rand() * 4))
const invitesConverted = Math.min(referralCount, Math.floor(referralCount * 0.4))
list.entries.set(email, {
waitlist: slug,
email,
refCode,
referredBy: '',
referralCount,
createdAt: start + i * 60_000,
pointBreakdown: {
referrals: referralCount * POINTS.REFERRAL,
shares: shareEvents * POINTS.SHARE,
invitesSent: invitesSent * POINTS.INVITE_SENT,
invitesConverted: invitesConverted * POINTS.INVITE_CONVERTED,
social: 0,
hanzod: 0,
},
sharedPlatforms: new Map(),
invitedEmails: new Set(),
awardedKeys: new Set(),
})
}
// Seed recent activity so the live ticker shows action on first page load.
const seedTypes = ['join','share','invite','referral']
const platforms = ['x','linkedin','copy','email','reddit','telegram']
const sample = [...list.entries.values()].sort(() => rand() - 0.5).slice(0, 30)
for (let i = 0; i < 30; i++) {
const e = sample[i] || sample[0]
if (!e) break
const type = seedTypes[Math.floor(rand() * seedTypes.length)]
const ts = Date.now() - Math.floor(rand() * 4 * 60 * 60 * 1000) // last 4h
if (type === 'join') {
list.activity.push({ ts, type, who: maskEmail(e.email), source: rand() < 0.5 ? 'direct' : 'referral' })
} else if (type === 'share') {
list.activity.push({ ts, type, who: maskEmail(e.email), platform: platforms[Math.floor(rand() * platforms.length)] })
} else if (type === 'invite') {
list.activity.push({ ts, type, who: maskEmail(e.email), invited: maskEmail(sample[(i + 1) % sample.length].email) })
} else {
list.activity.push({ ts, type, who: maskEmail(e.email), invited: maskEmail(sample[(i + 1) % sample.length].email) })
}
}
list.activity.sort((a, b) => a.ts - b.ts)
}
server.listen(PORT, () => {
console.log(`[mock-api] /v1/waitlist/* on http://localhost:${PORT}`)
if (!ADMIN_SECRET) console.log('[mock-api] WAITLIST_ADMIN_SECRET unset — /v1/waitlist/export disabled')
const seedN = Number(process.env.SEED || 0)
if (seedN > 0) {
const slug = process.env.SEED_SLUG || 'demo'
seedList(slug, seedN)
console.log(`[mock-api] seeded "${slug}" with ${seedN} entries`)
}
})