diff --git a/lib/paid.js b/lib/paid.js new file mode 100644 index 0000000..c09df43 --- /dev/null +++ b/lib/paid.js @@ -0,0 +1,96 @@ +// Advertiser-funded missions (Marty, 2026-09-24). +// +// A member buys a mission: they pay POL into the faucet wallet, and hunters who complete it are +// dripped out of what they paid. Two rules follow from that, and everything here exists to hold +// them: +// +// 1. A paid completion is NOT house spend. It does not count against dailyCapPol, and it does +// not use up one of the hunter's missionsPerDay. Each live paid mission raises that hunter's +// allowance by one, because the company is not funding it. ("Each additional mission paid by +// an advertiser should allow +1 above the cap.") +// +// 2. THE SOLVENCY RULE. The faucet is one wallet holding house float AND every advertiser's +// unspent reserve. If reserves ever exceed the balance we have sold delivery we cannot pay +// for, and a hunter completes a mission only to watch the drip fail. So: +// +// faucet balance >= house float + sum(outstanding reserves) +// +// checked before a sale is accepted, never after. +// +// Money in is split at purchase: the hunter payout is reserved, the rest is company margin the +// P&L can recognise. The advertiser sets the hunter payout themselves (base 0.30, they may pay +// more to get completed sooner), so the reserve is exact rather than estimated. No draw, no +// variance, no guessing. +'use strict'; +const store = require('./store'); + +const DEFAULTS = { + paidEnabled: 1, + paidBaseHunterPol: 0.30, // the floor an advertiser may set as the hunter's payout + paidMarginPct: 100, // company margin on top of the hunter payout, as a percentage + paidMinBlock: 100, // completions per block + paidMaxConcurrent: 0, // 0 = no limit; supply is elastic now that paid missions add capacity + paidHouseFloatPol: 25, // POL kept back for house missions, never counted as sellable +}; +function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } +const r4 = n => Math.round(Number(n) * 10000) / 10000; + +// ---- what a block costs ----------------------------------------------------------------- +// hunterPol is what each completing hunter receives; the advertiser pays that plus the margin. +function quote(hunterPol, completions) { + const c = cfg(); + const hp = r4(Math.max(Number(c.paidBaseHunterPol), Number(hunterPol) || 0)); + const n = Math.max(Number(c.paidMinBlock), Math.round(Number(completions) || 0)); + const reserve = r4(hp * n); // exact: no draw, so no buffer needed + const margin = r4(reserve * (Number(c.paidMarginPct) / 100)); + return { hunterPol: hp, completions: n, reserve, margin, total: r4(reserve + margin) }; +} + +// ---- reserves --------------------------------------------------------------------------- +const paidMissions = () => store.read('missions', []).filter(m => m.paid); + +// completions already granted against a mission (failed ones do not count, they never paid) +function usedOf(missionId) { + return store.read('payouts', []).filter(p => p.missionId === missionId && p.status !== 'failed' && !p.prize && !p.ref).length; +} + +// what every live paid mission still owes its hunters +function outstanding() { + let pol = 0, left = 0; + for (const m of paidMissions()) { + const remaining = Math.max(0, Number(m.budget || 0) - usedOf(m.id)); + left += remaining; + pol += remaining * Number(m.hunterPol || 0); + } + return { reservePol: r4(pol), completionsLeft: left, missions: paidMissions().length }; +} + +// THE guard. Given the faucet's real balance, can we take this sale? +function canSell(balancePol, addReservePol) { + const c = cfg(); + const o = outstanding(); + const float = Number(c.paidHouseFloatPol) || 0; + const committed = r4(o.reservePol + float + Number(addReservePol || 0)); + const bal = r4(Number(balancePol) || 0); + return { + ok: bal >= committed, + balance: bal, committed, houseFloat: float, + alreadyReserved: o.reservePol, adding: r4(Number(addReservePol) || 0), + shortBy: bal >= committed ? 0 : r4(committed - bal), + }; +} + +// ---- the hunter's allowance --------------------------------------------------------------- +// Base allowance is the company-funded missionsPerDay. Every live paid mission the hunter has +// not yet done adds one, because that completion costs the company nothing. +function extraAllowance(doneIds) { + const done = doneIds instanceof Set ? doneIds : new Set(doneIds || []); + return paidMissions().filter(m => m.active && !done.has(m.id) && Math.max(0, Number(m.budget || 0) - usedOf(m.id)) > 0).length; +} + +function isPaid(missionId) { + const m = store.read('missions', []).find(x => x.id === missionId); + return !!(m && m.paid); +} + +module.exports = { cfg, quote, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions }; diff --git a/lib/rewards.js b/lib/rewards.js index 7d402dd..b83104e 100644 --- a/lib/rewards.js +++ b/lib/rewards.js @@ -1,112 +1,125 @@ -// Rewards: the weighted draw, the daily cap, the queue, and the ledger. -// -// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol } -// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant, -// because POL's dollar price moves. -// -// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near -// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever -// the cap. -// -// A completion is recorded the moment the proof checks out. If today's paid total plus this -// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in -// order; the hunter did the work and is never told no. The faucet pays 'due' entries. -'use strict'; -const store = require('./store'); -const { ctDay } = require('./missions'); - -// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor -// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per -// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool) -const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 }; -function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } -function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); } - -function draw(min, max, skew) { - const lo = Math.log(min), hi = Math.log(max); - const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1); - const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor - const v = Math.exp(hi - u * (hi - lo)); - return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000; -} - -// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool -// a hunter's finds today (Central), prizes excluded -// day defaults to today: called without it the comparison was against undefined and silently -// returned 0 for everyone, which would read as "no finds yet" to any new caller -function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; } -function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; } -function paidToday(day) { - return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref).reduce((n, p) => n + p.pol, 0); -} - -// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and -// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet: -// two accounts sharing a wallet get one drip per mission per day between them. -function completed(memberId, missionId, wallet) { - const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); - return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))); -} -// the mission ids this member (or wallet) has done today -function doneToday(memberId, wallet) { - const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); - return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId)); -} - -// record a completion; decide paid-today vs queued -function grant(member, mission) { - const s = settings(); - const pol = draw(Number(s.minPol), Number(s.maxPol)); - const day = ctDay(); - const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity; - if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' }; - const overCap = paidToday(day) + pol > Number(s.dailyCapPol); - const rec = { - id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), - memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null, - missionId: mission.id, site: mission.site, pol, day, at: Date.now(), - status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null, - }; - store.update('payouts', [], all => { all.push(rec); return all; }); - return { ok: true, rec, queued: overCap }; -} - -// what the faucet should pay now: due entries, then queued ones as long as today's cap allows -function payable() { - const s = settings(); const day = ctDay(); - let room = Number(s.dailyCapPol) - paidToday(day); - const all = store.read('payouts', []); - const out = []; - for (const p of all.filter(p => p.status === 'due')) { out.push(p); } - for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; } - return out; -} -// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first -function unposted(since, limit) { - return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0)) - .sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15)); -} -function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); } - -// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it -// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight) -function nextResetAt(now) { - const t0 = now || Date.now(); const today = ctDay(t0); - let lo = t0, hi = t0 + 26 * 3600000; - while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; } - return hi; -} -function pool() { - const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol); - return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() }; -} - -function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); } -function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); } -function totals() { - const all = store.read('payouts', []); - const paid = all.filter(p => p.status === 'paid'); - return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size }; -} - -module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted }; +// Rewards: the weighted draw, the daily cap, the queue, and the ledger. +// +// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol } +// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant, +// because POL's dollar price moves. +// +// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near +// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever +// the cap. +// +// A completion is recorded the moment the proof checks out. If today's paid total plus this +// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in +// order; the hunter did the work and is never told no. The faucet pays 'due' entries. +'use strict'; +const store = require('./store'); +const { ctDay } = require('./missions'); + +// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor +// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per +// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool) +const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 }; +function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } +function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); } + +function draw(min, max, skew) { + const lo = Math.log(min), hi = Math.log(max); + const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1); + const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor + const v = Math.exp(hi - u * (hi - lo)); + return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000; +} + +// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool +// a hunter's finds today (Central), prizes excluded +// day defaults to today: called without it the comparison was against undefined and silently +// returned 0 for everyone, which would read as "no finds yet" to any new caller +function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; } +function daily(memberId, doneIds) { const s = settings(); const base = Math.max(1, Number(s.missionsPerDay) || 3); + // every live paid mission this hunter has not done yet adds one, because the company is not + // funding that completion + let extra = 0; try { extra = require('./paid').extraAllowance(doneIds); } catch (e) {} + const limit = base + extra; const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; } +// What the COMPANY has spent today. Advertiser-funded completions are excluded on purpose: the +// hunter is paid out of what the advertiser already put in the faucet, so counting it here would +// let a paid mission use up the house budget (Marty, 2026-09-24). +function paidToday(day) { + return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && !p.paid).reduce((n, p) => n + p.pol, 0); +} + +// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and +// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet: +// two accounts sharing a wallet get one drip per mission per day between them. +function completed(memberId, missionId, wallet) { + const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); + return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))); +} +// the mission ids this member (or wallet) has done today +function doneToday(memberId, wallet) { + const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); + return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId)); +} + +// record a completion; decide paid-today vs queued +function grant(member, mission) { + const s = settings(); + // An advertiser-funded mission pays exactly what the advertiser set, every time. No draw, so + // the reserve taken at purchase is exact and can never come up short. + const isPaid = !!mission.paid; + const pol = isPaid ? Math.round(Number(mission.hunterPol || 0) * 10000) / 10000 + : draw(Number(s.minPol), Number(s.maxPol)); + const day = ctDay(); + const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity; + if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' }; + // the house cap governs house spend only; a paid completion is already funded + const overCap = !isPaid && (paidToday(day) + pol > Number(s.dailyCapPol)); + const rec = { + id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), + memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null, + missionId: mission.id, site: mission.site, pol, day, at: Date.now(), + paid: isPaid || undefined, // marks advertiser-funded spend so the house accounting skips it + status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null, + }; + store.update('payouts', [], all => { all.push(rec); return all; }); + return { ok: true, rec, queued: overCap }; +} + +// what the faucet should pay now: due entries, then queued ones as long as today's cap allows +function payable() { + const s = settings(); const day = ctDay(); + let room = Number(s.dailyCapPol) - paidToday(day); + const all = store.read('payouts', []); + const out = []; + for (const p of all.filter(p => p.status === 'due')) { out.push(p); } + for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; } + return out; +} +// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first +function unposted(since, limit) { + return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0)) + .sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15)); +} +function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); } + +// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it +// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight) +function nextResetAt(now) { + const t0 = now || Date.now(); const today = ctDay(t0); + let lo = t0, hi = t0 + 26 * 3600000; + while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; } + return hi; +} +function pool() { + const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol); + return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() }; +} + +function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); } +function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); } +function totals() { + const all = store.read('payouts', []); + const paid = all.filter(p => p.status === 'paid'); + return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size }; +} + +module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted }; diff --git a/server.js b/server.js index 6614b91..fed77ab 100644 --- a/server.js +++ b/server.js @@ -1,335 +1,335 @@ -// PolHunter: gamified visits across the network, paid in POL. -// -// Three gates, all default-deny, all from the environment. A missing variable means silence: -// OUTBOUND=on required before anything leaves this server: Telegram posts included. -// There is no mailer in this app and there is not going to be one. -// SIGNUPS=open there is no sign-up form at all; hunters arrive signed in from their -// InstantAdPay dashboard (lib/sso.js). This gate controls whether that -// hand-off is accepted, so the whole thing can be shut with one variable. -// CURTAIN= a contentless "Coming soon" page for everyone who has not opened ?k=. -// -// Faucet: HUNT_WALLET_KEY + HUNT_RPC (+ HUNT_CHAIN_ID). Admin: ADMIN_KEY. Hand-off: HUNT_SSO_SECRET. -'use strict'; -const http = require('http'); -const fs = require('fs'); -const path = require('path'); -const crypto = require('crypto'); -const store = require('./lib/store'); -const sso = require('./lib/sso'); -const missions = require('./lib/missions'); -const rewards = require('./lib/rewards'); -const social = require('./lib/social'); -const badge = require('./lib/badge'); -const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts -const faucet = require('./lib/faucet'); - -const PORT = Number(process.env.PORT || 3000); -const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); -const PUBLIC_DIR = path.join(__dirname, 'public'); -badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') }); -const CURTAIN = String(process.env.CURTAIN || '').trim(); -// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND -// says; from then on the curtain lifts by itself and Telegram opens. Launch: 2026-09-21T09:00-05:00. -const LIVE_AT = process.env.HUNT_LIVE_AT ? Date.parse(process.env.HUNT_LIVE_AT) : 0; -function live() { return !LIVE_AT || Date.now() >= LIVE_AT; } -const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim(); -const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, ''); -const outbound = () => process.env.OUTBOUND === 'on' && live(); // nothing leaves before the live moment -const signupsOpen = () => process.env.SIGNUPS === 'open'; - -store.init(DATA_DIR); -const faucetOn = faucet.init(); - -// ---- Telegram (outward: gated) ------------------------------------------------------------ -async function telegram(text) { - if (!outbound()) return false; // the gate - const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC; - if (!tok || !chat) return false; - const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {})); - try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; } -} -async function telegramPhoto(file, caption, general) { - if (!outbound()) return false; // the gate - const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC; - if (!tok || !chat || !file) return false; - try { - const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic && !general) fd.append('message_thread_id', String(topic)); - fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg'); - const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok; - } catch (e) { return false; } -} -const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 }); -// keys being posted right now, so the claim path and the sweep never post the same thing twice -const postingNow = new Set(); -async function notify(kind, p) { - if (kind === 'paid' && p && p.id) { const k = 'find:' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyPaid(p); } finally { postingNow.delete(k); } } - if (kind === 'badge' && p && p.me) { const k = 'badge:' + p.me.memberId + ':' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyBadge(p); } finally { postingNow.delete(k); } } - return notifyOther(kind, p); -} -async function notifyPaid(p) { - { const ok = await telegram('\u{1F3AF} PolHunter · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got ' + fmt(p.pol) + ' POL · verify\nHunt yours'); if (ok && p.id) rewards.mark(p.id, { posted: Date.now() }); return ok; } -} -async function notifyOther(kind, p) { - if (kind === 'low') return telegram('⚠️ PolHunter faucet is low: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.'); - if (kind === 'failed') return telegram('❌ PolHunter · drip to #' + p.memberId + ' failed: ' + p.error); - // a bounty is the one payout that advertises the mission itself, so it goes to the room - if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} PolHunter · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · ' + fmt(d.pol) + ' POL bounty on the way\nBring yours'); } - // Marty's launch bonus, paid once to whoever gets a referral all the way through first - if (kind === 'firstbonus') { const d = p.drip; return telegram('\u{1F947} First referral home! ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' is the first to bring a hunter all the way through, so Marty’s ' + fmt(d.pol) + ' POL bonus is theirs on top of the bounty.\nThat one is claimed. The mission is not — bring yours.'); } - if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} PolHunter weekly prizes for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 ' + fmt(w.prizePol) + ' POL').join('\n') + '\nLeaderboard'); } - return false; -} -async function notifyBadge(p) { const ok = await postBadge(p); if (ok) store.update('badges-posted', {}, all => { const k = String(p.me.memberId); all[k] = Array.from(new Set([...(all[k] || []), p.id])); return all; }); return ok; } -async function postBadge(p) { - { const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} PolHunter \u00b7 ' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + ' unlocked ' + b.name + ': ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; if (!file) return telegram(cap); const ok = await telegramPhoto(file, cap); if (String(process.env.HUNT_TG_BADGE_GENERAL || 'on') === 'on') await telegramPhoto(file, cap, true); return ok; } - return false; -} -// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central -const dailyMsg = d => 'That is your ' + ['', 'one', 'two', 'three', 'four', 'five'][d.limit] + ' for today. Fresh missions at midnight Central.'; -const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.'; -function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; } -function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; } - -// ---- helpers ------------------------------------------------------------------------------- -const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.mp4': 'video/mp4' }; -const SEC = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY' }; -function json(res, code, body, extra) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(JSON.stringify(body)); } -function sendFile(res, file, extra) { - // video streams with byte ranges (iOS Safari refuses to play without 206 support) - if (path.extname(file) === '.mp4') return sendRange(res, file, extra); - fs.readFile(file, (err, buf) => { - if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } - res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {})); - res.end(buf); - }); -} -function sendRange(res, file, extra) { - fs.stat(file, (err, st) => { - if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } - const size = st.size; const range = res.req && res.req.headers.range; - const head = Object.assign({ 'Content-Type': TYPES['.mp4'], 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' }, SEC, extra || {}); - let start = 0, end = size - 1, code = 200; - const m = range && /^bytes=(\d*)-(\d*)$/.exec(range); - if (m) { - start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2] || 0)); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : (m[1] ? size - 1 : size - 1); - if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); } - code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size; - } - head['Content-Length'] = end - start + 1; - res.writeHead(code, head); - if (res.req && res.req.method === 'HEAD') return res.end(); - fs.createReadStream(file, { start, end }).pipe(res); - }); -} -function readBody(req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { resolve({}); } }); }); } -const hits = new Map(); -function limited(key, max, windowMs) { const now = Date.now(); const r = hits.get(key); if (!r || now > r.reset) { hits.set(key, { n: 1, reset: now + windowMs }); return false; } r.n++; return r.n > max; } -const ip = req => String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(); - -const CURTAIN_PAGE = `Coming soon

Coming soon

This site is still being built.

`; -function curtained(req, res, u) { - if (!CURTAIN || (LIVE_AT && live())) return false; // the launch moment lifts the curtain - if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; } - const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || ''); - if (m && decodeURIComponent(m[1]) === CURTAIN) return false; - res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' }); res.end(req.method === 'HEAD' ? '' : CURTAIN_PAGE); return true; -} -function admin(req) { const k = req.headers['x-admin-key'] || new URL(req.url, 'http://x').searchParams.get('key'); return !!(ADMIN_KEY && k && k.length === ADMIN_KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(ADMIN_KEY))); } -const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, dwell: m.dwell || 30, reward: rewards.settings() }); - -// ---- the server ------------------------------------------------------------------------------ -const server = http.createServer(async (req, res) => { - try { - const u = new URL(req.url, 'http://x'); const p = u.pathname; - if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); - - // the embed talks to us from the mission sites: it must work through the curtain, and it must - // answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first) - if (p === '/api/embed/code') { - const origin = String(req.headers.origin || ''); - const r = missions.codeForEmbed(u.searchParams.get('t'), origin); - const cors = r.error === 'origin' ? {} : { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin' }; - if (req.method === 'OPTIONS') { res.writeHead(204, Object.assign({ 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' }, cors)); return res.end(); } - if (limited('embed:' + ip(req), 120, 60000)) return json(res, 429, { error: 'slow down' }, cors); - return json(res, r.error ? 403 : 200, r, cors); - } - if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' }); - // badge cards and their share pages are posted around the web: they pass the curtain - if (p === '/style.css') return sendFile(res, path.join(PUBLIC_DIR, 'style.css'), { 'Cache-Control': 'public, max-age=3600' }); // the share pages are styled for outsiders - if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' }); - { const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p); - if (bm) { - const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id); - if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } - if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); } - const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); - const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId); - const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.'; - const share = encodeURIComponent(title + ' ' + url); - const html = '' + esc(title) + '' - + '' - + '' - + '
PolHunter
' - + '
' + esc(b.name) + ' badge for ' + esc(m.who) + '

' + esc(m.who) + ' unlocked ' + esc(b.name) + '

' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.

' - + 'Hunt yours' - + '

Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.

'; - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html); - } - } - // the coin on the code pill, fetched by mission-site visitors who hold no curtain pass - if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' }); - - if (curtained(req, res, u)) return; - // a member's share link: /?r= becomes a 30-day cookie; the landing then - // sends sign-ups to instantadpay.com/join/, so IAP's own last-touch sponsor rule applies - // Served in place, not redirected (Marty, 2026-09-21): Facebook's crawler follows redirects and canonicalizes a - // share to og:url, so a redirect to the bare home page lost the member's name on every share. og:url carries - // the ref; the cookie rides on the same response. - if (p === '/' && u.searchParams.get('r')) { - const r = String(u.searchParams.get('r')).trim().slice(0, 40); - const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r); - let html = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); - if (ok) html = html.replace('', ''); - const h = Object.assign({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, SEC); - if (ok) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure'; - res.writeHead(200, h); return res.end(html); - } - - // ---- sign-in by hand-off from InstantAdPay - if (p === '/auth') { - if (!signupsOpen()) { res.writeHead(503, { 'Content-Type': 'text/plain' }); return res.end('PolHunter is not accepting hunters yet.'); } - const v = sso.verify(u.searchParams.get('t')); - if (v.error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('

' + v.error + '

'); } - const cur = sso.fromRequest(req); - let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null; - if (sid) sso.refresh(sid, v.claims); else sid = sso.startSession(v.claims); - referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in - res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end(); - } - if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); } - - // ---- public - if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); } - if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); } - if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) }); - if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' }); - if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html')); - if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html')); - if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); } - - // ---- hunter (session required) - if (p.startsWith('/api/my/')) { - const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' }); - if (p === '/api/my/board') { - referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too - const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet - return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId), - badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') }, - share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' }, - referral: referrals.state(me) }); - } - if (p === '/api/my/start' && req.method === 'POST') { - const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' }); - if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' }); - if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); - { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } - { const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } - if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' }); - const t = missions.issue(me.memberId, m.id); - // a mission URL may place the token itself with {token} (a Telegram Mini App takes it in - // ?startapp=, not as our own query string); otherwise it is appended as ?ph= - // the token rides in the hash: a server never sees it, so no redirect or canonical rewrite can lose it - const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t; - return json(res, 200, { ok: true, token: t.t, url, dwell: m.dwell || 30, expires: t.exp }); - } - if (p === '/api/my/submit' && req.method === 'POST') { - const b = await readBody(req); - if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' }); - const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error }); - const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' }); - if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); - { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } - { const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } - const before = new Set(social.badgesFor(me.memberId).map(b => b.id)); - const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); - // whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new - let refDrips = []; - try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); } - for (const d of refDrips) if (d.ref.kind === 'bounty' || d.ref.kind === 'first-bonus') notify(d.ref.kind === 'bounty' ? 'referral' : 'firstbonus', { drip: d }).catch(() => {}); - // achievements unlocked by this find: told to the board, posted to Telegram (gated) - const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id)); - for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {}); - return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' }); - } - return json(res, 404, { error: 'No such call.' }); - } - - // ---- admin (key) - if (p.startsWith('/api/admin/')) { - if (!admin(req)) return json(res, 401, { error: 'Admin key required.' }); - if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } }); - if (p === '/api/admin/mission' && req.method === 'POST') { - const b = await readBody(req); - const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' }); - let host = ''; try { host = new URL(String(b.url)).hostname.replace(/^www\./, ''); } catch (e) { return json(res, 400, { error: 'url must be a full https URL' }); } - // the origin the embed calls from can differ from the link (a t.me launch link opens a Mini App on its own host) - if (b.host) host = String(b.host).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, ''); - missions.save({ id, site: String(b.site || host).slice(0, 60), host, name: String(b.name || '').slice(0, 80), brief: String(b.brief || '').slice(0, 400), url: String(b.url), dwell: Math.max(5, Number(b.dwell) || 45), slots: Math.max(1, Number(b.slots) || 1), budget: Math.max(0, Number(b.budget) || 0), active: b.active !== false }); - return json(res, 200, { ok: true, missions: missions.list() }); - } - if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); } - if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } - // award a week by hand (its Monday key); already-awarded weeks are skipped - if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); } - if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); } - if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); } - if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter - const m = missions.get(String(u.searchParams.get('id') || '')); if (!m) return json(res, 404, { error: 'no such mission' }); - const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t, token: t.t, dwell: m.dwell }); - } - return json(res, 404, { error: 'No such admin call.' }); - } - if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html')); - if (p === '/app') { if (!sso.fromRequest(req)) { res.writeHead(302, { Location: '/?signin=1' }); return res.end(); } return sendFile(res, path.join(PUBLIC_DIR, 'app.html')); } - if (p.startsWith('/api/')) return json(res, 404, { error: 'No such call.' }); - - const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, ''); - const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe); - if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); } - return sendFile(res, file); - } catch (e) { console.error(req.method, req.url, e.message); try { json(res, 500, { error: 'Internal server error' }); } catch (x) {} } -}); - -// the faucet pays every two minutes; nothing outward leaves unless OUTBOUND=on (telegram checks) -if (faucetOn) setInterval(() => faucet.tick(notify).catch(e => console.error('faucet', e.message)), 2 * 60000); -// Every completed mission reaches Telegram (Marty, 2026-09-21): a find is posted when its drip is paid, and -// this sweep posts any paid find that is not marked posted yet (the pre-live gate, Telegram down, a restart). -// Only finds made after HUNT_POST_SINCE count, so rehearsal drips from before the doors opened stay quiet. -const POST_SINCE = process.env.HUNT_POST_SINCE ? Date.parse(process.env.HUNT_POST_SINCE) : 0; -let sweeping = false; -async function postMissedFinds() { - if (sweeping || !outbound()) return 0; sweeping = true; let n = 0; - try { for (const p of rewards.unposted(POST_SINCE, 15)) { if (postingNow.has('find:' + p.id)) continue; if (await notify('paid', p)) n++; else break; } } - catch (e) { console.error('find sweep', e.message); } finally { sweeping = false; } - if (n) console.log('posted', n, 'missed find(s) to Telegram'); - await postMissedBadges().catch(e => console.error('badge sweep', e.message)); - return n; -} -// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart) -async function postMissedBadges() { - if (!outbound()) return 0; - const since = POST_SINCE; const recs = store.read('payouts', []).filter(x => (x.at || 0) >= since && x.status !== 'failed'); - const seen = new Map(); for (const r of recs) seen.set(r.memberId, { memberId: r.memberId, username: r.username || null }); - const posted = store.read('badges-posted', {}); let n = 0; - for (const me of seen.values()) { - const have = new Set(posted[String(me.memberId)] || []); - for (const b of social.badgesFor(me.memberId)) { if (have.has(b.id) || postingNow.has('badge:' + me.memberId + ':' + b.id)) continue; if (n >= 10) return n; if (await notify('badge', { me, id: b.id })) n++; else return n; } - } - if (n) console.log('posted', n, 'missed badge(s) to Telegram'); - return n; -} -setInterval(() => postMissedFinds().catch(() => {}), 2 * 60000); setTimeout(() => postMissedFinds().catch(() => {}), 20000); -// weekly prizes: the week that just ended is awarded on the first tick after Sunday, Central -setInterval(() => { try { social.awardDue(notify); } catch (e) { console.error('prizes', e.message); } }, 2 * 60000); - -server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'} — sso: ${sso.enabled() ? 'on' : 'off'} — faucet: ${faucetOn ? faucet.address() + ' chain ' + (process.env.HUNT_CHAIN_ID || '?') : 'off'}`)); +// PolHunter: gamified visits across the network, paid in POL. +// +// Three gates, all default-deny, all from the environment. A missing variable means silence: +// OUTBOUND=on required before anything leaves this server: Telegram posts included. +// There is no mailer in this app and there is not going to be one. +// SIGNUPS=open there is no sign-up form at all; hunters arrive signed in from their +// InstantAdPay dashboard (lib/sso.js). This gate controls whether that +// hand-off is accepted, so the whole thing can be shut with one variable. +// CURTAIN= a contentless "Coming soon" page for everyone who has not opened ?k=. +// +// Faucet: HUNT_WALLET_KEY + HUNT_RPC (+ HUNT_CHAIN_ID). Admin: ADMIN_KEY. Hand-off: HUNT_SSO_SECRET. +'use strict'; +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const store = require('./lib/store'); +const sso = require('./lib/sso'); +const missions = require('./lib/missions'); +const rewards = require('./lib/rewards'); +const social = require('./lib/social'); +const badge = require('./lib/badge'); +const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts +const faucet = require('./lib/faucet'); + +const PORT = Number(process.env.PORT || 3000); +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const PUBLIC_DIR = path.join(__dirname, 'public'); +badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') }); +const CURTAIN = String(process.env.CURTAIN || '').trim(); +// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND +// says; from then on the curtain lifts by itself and Telegram opens. Launch: 2026-09-21T09:00-05:00. +const LIVE_AT = process.env.HUNT_LIVE_AT ? Date.parse(process.env.HUNT_LIVE_AT) : 0; +function live() { return !LIVE_AT || Date.now() >= LIVE_AT; } +const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim(); +const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, ''); +const outbound = () => process.env.OUTBOUND === 'on' && live(); // nothing leaves before the live moment +const signupsOpen = () => process.env.SIGNUPS === 'open'; + +store.init(DATA_DIR); +const faucetOn = faucet.init(); + +// ---- Telegram (outward: gated) ------------------------------------------------------------ +async function telegram(text) { + if (!outbound()) return false; // the gate + const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC; + if (!tok || !chat) return false; + const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {})); + try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; } +} +async function telegramPhoto(file, caption, general) { + if (!outbound()) return false; // the gate + const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC; + if (!tok || !chat || !file) return false; + try { + const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic && !general) fd.append('message_thread_id', String(topic)); + fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg'); + const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok; + } catch (e) { return false; } +} +const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 }); +// keys being posted right now, so the claim path and the sweep never post the same thing twice +const postingNow = new Set(); +async function notify(kind, p) { + if (kind === 'paid' && p && p.id) { const k = 'find:' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyPaid(p); } finally { postingNow.delete(k); } } + if (kind === 'badge' && p && p.me) { const k = 'badge:' + p.me.memberId + ':' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyBadge(p); } finally { postingNow.delete(k); } } + return notifyOther(kind, p); +} +async function notifyPaid(p) { + { const ok = await telegram('\u{1F3AF} PolHunter · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got ' + fmt(p.pol) + ' POL · verify\nHunt yours'); if (ok && p.id) rewards.mark(p.id, { posted: Date.now() }); return ok; } +} +async function notifyOther(kind, p) { + if (kind === 'low') return telegram('⚠️ PolHunter faucet is low: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.'); + if (kind === 'failed') return telegram('❌ PolHunter · drip to #' + p.memberId + ' failed: ' + p.error); + // a bounty is the one payout that advertises the mission itself, so it goes to the room + if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} PolHunter · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · ' + fmt(d.pol) + ' POL bounty on the way\nBring yours'); } + // Marty's launch bonus, paid once to whoever gets a referral all the way through first + if (kind === 'firstbonus') { const d = p.drip; return telegram('\u{1F947} First referral home! ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' is the first to bring a hunter all the way through, so Marty’s ' + fmt(d.pol) + ' POL bonus is theirs on top of the bounty.\nThat one is claimed. The mission is not — bring yours.'); } + if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} PolHunter weekly prizes for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 ' + fmt(w.prizePol) + ' POL').join('\n') + '\nLeaderboard'); } + return false; +} +async function notifyBadge(p) { const ok = await postBadge(p); if (ok) store.update('badges-posted', {}, all => { const k = String(p.me.memberId); all[k] = Array.from(new Set([...(all[k] || []), p.id])); return all; }); return ok; } +async function postBadge(p) { + { const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} PolHunter \u00b7 ' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + ' unlocked ' + b.name + ': ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; if (!file) return telegram(cap); const ok = await telegramPhoto(file, cap); if (String(process.env.HUNT_TG_BADGE_GENERAL || 'on') === 'on') await telegramPhoto(file, cap, true); return ok; } + return false; +} +// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central +const dailyMsg = d => 'That is your ' + ['', 'one', 'two', 'three', 'four', 'five'][d.limit] + ' for today. Fresh missions at midnight Central.'; +const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.'; +function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; } +function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; } + +// ---- helpers ------------------------------------------------------------------------------- +const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.mp4': 'video/mp4' }; +const SEC = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY' }; +function json(res, code, body, extra) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(JSON.stringify(body)); } +function sendFile(res, file, extra) { + // video streams with byte ranges (iOS Safari refuses to play without 206 support) + if (path.extname(file) === '.mp4') return sendRange(res, file, extra); + fs.readFile(file, (err, buf) => { + if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } + res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {})); + res.end(buf); + }); +} +function sendRange(res, file, extra) { + fs.stat(file, (err, st) => { + if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } + const size = st.size; const range = res.req && res.req.headers.range; + const head = Object.assign({ 'Content-Type': TYPES['.mp4'], 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' }, SEC, extra || {}); + let start = 0, end = size - 1, code = 200; + const m = range && /^bytes=(\d*)-(\d*)$/.exec(range); + if (m) { + start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2] || 0)); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : (m[1] ? size - 1 : size - 1); + if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); } + code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size; + } + head['Content-Length'] = end - start + 1; + res.writeHead(code, head); + if (res.req && res.req.method === 'HEAD') return res.end(); + fs.createReadStream(file, { start, end }).pipe(res); + }); +} +function readBody(req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { resolve({}); } }); }); } +const hits = new Map(); +function limited(key, max, windowMs) { const now = Date.now(); const r = hits.get(key); if (!r || now > r.reset) { hits.set(key, { n: 1, reset: now + windowMs }); return false; } r.n++; return r.n > max; } +const ip = req => String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(); + +const CURTAIN_PAGE = `Coming soon

Coming soon

This site is still being built.

`; +function curtained(req, res, u) { + if (!CURTAIN || (LIVE_AT && live())) return false; // the launch moment lifts the curtain + if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; } + const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || ''); + if (m && decodeURIComponent(m[1]) === CURTAIN) return false; + res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' }); res.end(req.method === 'HEAD' ? '' : CURTAIN_PAGE); return true; +} +function admin(req) { const k = req.headers['x-admin-key'] || new URL(req.url, 'http://x').searchParams.get('key'); return !!(ADMIN_KEY && k && k.length === ADMIN_KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(ADMIN_KEY))); } +const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, dwell: m.dwell || 30, reward: rewards.settings() }); + +// ---- the server ------------------------------------------------------------------------------ +const server = http.createServer(async (req, res) => { + try { + const u = new URL(req.url, 'http://x'); const p = u.pathname; + if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); + + // the embed talks to us from the mission sites: it must work through the curtain, and it must + // answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first) + if (p === '/api/embed/code') { + const origin = String(req.headers.origin || ''); + const r = missions.codeForEmbed(u.searchParams.get('t'), origin); + const cors = r.error === 'origin' ? {} : { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin' }; + if (req.method === 'OPTIONS') { res.writeHead(204, Object.assign({ 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' }, cors)); return res.end(); } + if (limited('embed:' + ip(req), 120, 60000)) return json(res, 429, { error: 'slow down' }, cors); + return json(res, r.error ? 403 : 200, r, cors); + } + if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' }); + // badge cards and their share pages are posted around the web: they pass the curtain + if (p === '/style.css') return sendFile(res, path.join(PUBLIC_DIR, 'style.css'), { 'Cache-Control': 'public, max-age=3600' }); // the share pages are styled for outsiders + if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' }); + { const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p); + if (bm) { + const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id); + if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } + if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); } + const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId); + const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.'; + const share = encodeURIComponent(title + ' ' + url); + const html = '' + esc(title) + '' + + '' + + '' + + '
PolHunter
' + + '
' + esc(b.name) + ' badge for ' + esc(m.who) + '

' + esc(m.who) + ' unlocked ' + esc(b.name) + '

' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.

' + + 'Hunt yours' + + '

Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.

'; + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html); + } + } + // the coin on the code pill, fetched by mission-site visitors who hold no curtain pass + if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' }); + + if (curtained(req, res, u)) return; + // a member's share link: /?r= becomes a 30-day cookie; the landing then + // sends sign-ups to instantadpay.com/join/, so IAP's own last-touch sponsor rule applies + // Served in place, not redirected (Marty, 2026-09-21): Facebook's crawler follows redirects and canonicalizes a + // share to og:url, so a redirect to the bare home page lost the member's name on every share. og:url carries + // the ref; the cookie rides on the same response. + if (p === '/' && u.searchParams.get('r')) { + const r = String(u.searchParams.get('r')).trim().slice(0, 40); + const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r); + let html = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); + if (ok) html = html.replace('', ''); + const h = Object.assign({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, SEC); + if (ok) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure'; + res.writeHead(200, h); return res.end(html); + } + + // ---- sign-in by hand-off from InstantAdPay + if (p === '/auth') { + if (!signupsOpen()) { res.writeHead(503, { 'Content-Type': 'text/plain' }); return res.end('PolHunter is not accepting hunters yet.'); } + const v = sso.verify(u.searchParams.get('t')); + if (v.error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('

' + v.error + '

'); } + const cur = sso.fromRequest(req); + let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null; + if (sid) sso.refresh(sid, v.claims); else sid = sso.startSession(v.claims); + referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in + res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end(); + } + if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); } + + // ---- public + if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); } + if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); } + if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) }); + if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' }); + if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html')); + if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html')); + if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); } + + // ---- hunter (session required) + if (p.startsWith('/api/my/')) { + const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' }); + if (p === '/api/my/board') { + referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too + const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet + return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)), + badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') }, + share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' }, + referral: referrals.state(me) }); + } + if (p === '/api/my/start' && req.method === 'POST') { + const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' }); + if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' }); + if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); + { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } + { const d = rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } + if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' }); + const t = missions.issue(me.memberId, m.id); + // a mission URL may place the token itself with {token} (a Telegram Mini App takes it in + // ?startapp=, not as our own query string); otherwise it is appended as ?ph= + // the token rides in the hash: a server never sees it, so no redirect or canonical rewrite can lose it + const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t; + return json(res, 200, { ok: true, token: t.t, url, dwell: m.dwell || 30, expires: t.exp }); + } + if (p === '/api/my/submit' && req.method === 'POST') { + const b = await readBody(req); + if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' }); + const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error }); + const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' }); + if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); + { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } + { const d = rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } + const before = new Set(social.badgesFor(me.memberId).map(b => b.id)); + const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); + // whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new + let refDrips = []; + try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); } + for (const d of refDrips) if (d.ref.kind === 'bounty' || d.ref.kind === 'first-bonus') notify(d.ref.kind === 'bounty' ? 'referral' : 'firstbonus', { drip: d }).catch(() => {}); + // achievements unlocked by this find: told to the board, posted to Telegram (gated) + const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id)); + for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {}); + return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' }); + } + return json(res, 404, { error: 'No such call.' }); + } + + // ---- admin (key) + if (p.startsWith('/api/admin/')) { + if (!admin(req)) return json(res, 401, { error: 'Admin key required.' }); + if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } }); + if (p === '/api/admin/mission' && req.method === 'POST') { + const b = await readBody(req); + const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' }); + let host = ''; try { host = new URL(String(b.url)).hostname.replace(/^www\./, ''); } catch (e) { return json(res, 400, { error: 'url must be a full https URL' }); } + // the origin the embed calls from can differ from the link (a t.me launch link opens a Mini App on its own host) + if (b.host) host = String(b.host).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, ''); + missions.save({ id, site: String(b.site || host).slice(0, 60), host, name: String(b.name || '').slice(0, 80), brief: String(b.brief || '').slice(0, 400), url: String(b.url), dwell: Math.max(5, Number(b.dwell) || 45), slots: Math.max(1, Number(b.slots) || 1), budget: Math.max(0, Number(b.budget) || 0), active: b.active !== false }); + return json(res, 200, { ok: true, missions: missions.list() }); + } + if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); } + if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } + // award a week by hand (its Monday key); already-awarded weeks are skipped + if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); } + if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); } + if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); } + if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter + const m = missions.get(String(u.searchParams.get('id') || '')); if (!m) return json(res, 404, { error: 'no such mission' }); + const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t, token: t.t, dwell: m.dwell }); + } + return json(res, 404, { error: 'No such admin call.' }); + } + if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html')); + if (p === '/app') { if (!sso.fromRequest(req)) { res.writeHead(302, { Location: '/?signin=1' }); return res.end(); } return sendFile(res, path.join(PUBLIC_DIR, 'app.html')); } + if (p.startsWith('/api/')) return json(res, 404, { error: 'No such call.' }); + + const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, ''); + const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe); + if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); } + return sendFile(res, file); + } catch (e) { console.error(req.method, req.url, e.message); try { json(res, 500, { error: 'Internal server error' }); } catch (x) {} } +}); + +// the faucet pays every two minutes; nothing outward leaves unless OUTBOUND=on (telegram checks) +if (faucetOn) setInterval(() => faucet.tick(notify).catch(e => console.error('faucet', e.message)), 2 * 60000); +// Every completed mission reaches Telegram (Marty, 2026-09-21): a find is posted when its drip is paid, and +// this sweep posts any paid find that is not marked posted yet (the pre-live gate, Telegram down, a restart). +// Only finds made after HUNT_POST_SINCE count, so rehearsal drips from before the doors opened stay quiet. +const POST_SINCE = process.env.HUNT_POST_SINCE ? Date.parse(process.env.HUNT_POST_SINCE) : 0; +let sweeping = false; +async function postMissedFinds() { + if (sweeping || !outbound()) return 0; sweeping = true; let n = 0; + try { for (const p of rewards.unposted(POST_SINCE, 15)) { if (postingNow.has('find:' + p.id)) continue; if (await notify('paid', p)) n++; else break; } } + catch (e) { console.error('find sweep', e.message); } finally { sweeping = false; } + if (n) console.log('posted', n, 'missed find(s) to Telegram'); + await postMissedBadges().catch(e => console.error('badge sweep', e.message)); + return n; +} +// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart) +async function postMissedBadges() { + if (!outbound()) return 0; + const since = POST_SINCE; const recs = store.read('payouts', []).filter(x => (x.at || 0) >= since && x.status !== 'failed'); + const seen = new Map(); for (const r of recs) seen.set(r.memberId, { memberId: r.memberId, username: r.username || null }); + const posted = store.read('badges-posted', {}); let n = 0; + for (const me of seen.values()) { + const have = new Set(posted[String(me.memberId)] || []); + for (const b of social.badgesFor(me.memberId)) { if (have.has(b.id) || postingNow.has('badge:' + me.memberId + ':' + b.id)) continue; if (n >= 10) return n; if (await notify('badge', { me, id: b.id })) n++; else return n; } + } + if (n) console.log('posted', n, 'missed badge(s) to Telegram'); + return n; +} +setInterval(() => postMissedFinds().catch(() => {}), 2 * 60000); setTimeout(() => postMissedFinds().catch(() => {}), 20000); +// weekly prizes: the week that just ended is awarded on the first tick after Sunday, Central +setInterval(() => { try { social.awardDue(notify); } catch (e) { console.error('prizes', e.message); } }, 2 * 60000); + +server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'} — sso: ${sso.enabled() ? 'on' : 'off'} — faucet: ${faucetOn ? faucet.address() + ' chain ' + (process.env.HUNT_CHAIN_ID || '?') : 'off'}`));