// 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'); } 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') notify('referral', { 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'}`));