From 523d57624ecf0208276fe8354aab4a5e91b16139 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 19 Sep 2026 14:17:44 -0500 Subject: [PATCH] PolHunter v0.2: the hunt engine Hand-off sign-in from InstantAdPay (lib/sso.js: HMAC token, five minutes, single use; no sign-up, no mailer), missions with per-member per-visit proof codes (lib/missions.js: the embed is answered only from the mission's own origin, only after the dwell; hiding place rotates by day and member), weighted-low rewards with a daily cap that queues rather than refuses (lib/rewards.js), the faucet sender on ethers with a low-balance alert (lib/faucet.js), the one-line embed for the sites (public/embed.js), the hunter board, the public ledger, an admin page, and the site's own look. Telegram is behind the OUTBOUND gate like everything else. 13-check end-to-end test in test/run.js. Co-Authored-By: Claude Fable 5.1 --- lib/faucet.js | 58 +++++++++++++ lib/missions.js | 80 ++++++++++++++++++ lib/rewards.js | 75 +++++++++++++++++ lib/sso.js | 68 ++++++++++++++++ lib/store.js | 28 +++++++ package-lock.json | 124 ++++++++++++++++++++++++++++ package.json | 9 ++- public/admin.html | 62 ++++++++++++++ public/app.html | 34 ++++++++ public/app.js | 60 ++++++++++++++ public/embed.js | 43 ++++++++++ public/index.html | 78 +++++++++++++----- public/style.css | 75 +++++++++++++++++ server.js | 202 +++++++++++++++++++++++++++++++++------------- test/run.js | 80 ++++++++++++++++++ 15 files changed, 1001 insertions(+), 75 deletions(-) create mode 100644 lib/faucet.js create mode 100644 lib/missions.js create mode 100644 lib/rewards.js create mode 100644 lib/sso.js create mode 100644 lib/store.js create mode 100644 package-lock.json create mode 100644 public/admin.html create mode 100644 public/app.html create mode 100644 public/app.js create mode 100644 public/embed.js create mode 100644 public/style.css create mode 100644 test/run.js diff --git a/lib/faucet.js b/lib/faucet.js new file mode 100644 index 0000000..8fec9ae --- /dev/null +++ b/lib/faucet.js @@ -0,0 +1,58 @@ +// The faucet: pays due drips from the dedicated hot wallet. Amoy first, mainnet when Marty flips it. +// +// HUNT_WALLET_KEY the hot wallet's private key (env only; never on disk in the repo or volume) +// HUNT_RPC e.g. https://polygon-amoy-bor-rpc.publicnode.com (Amoy) or a mainnet RPC +// HUNT_CHAIN_ID 80002 (Amoy) or 137 (mainnet) +// +// Every send is one plain POL transfer, recorded on the ledger with its tx hash. A failure marks +// the entry 'failed' with the reason and never retries by itself (a human looks). The balance is +// read on every tick; below lowBalancePol the alert fires once per day. +'use strict'; +const { ethers } = require('ethers'); +const rewards = require('./rewards'); +const store = require('./store'); + +let provider = null, wallet = null, ticking = false; + +function enabled() { return !!(process.env.HUNT_WALLET_KEY && process.env.HUNT_RPC); } +function init() { + if (!enabled()) return false; + provider = new ethers.JsonRpcProvider(process.env.HUNT_RPC, Number(process.env.HUNT_CHAIN_ID) || undefined); + wallet = new ethers.Wallet(process.env.HUNT_WALLET_KEY.trim(), provider); + return true; +} +function address() { return wallet ? wallet.address : null; } +async function balance() { if (!provider || !wallet) return null; const b = await provider.getBalance(wallet.address); return Number(ethers.formatEther(b)); } + +async function tick(notify) { + if (!wallet || ticking) return { paid: 0 }; + ticking = true; + let paid = 0; + try { + const due = rewards.payable(); + for (const p of due) { + if (!p.wallet || !/^0x[a-f0-9]{40}$/i.test(p.wallet)) { rewards.mark(p.id, { status: 'failed', error: 'no wallet on the account' }); continue; } + try { + const tx = await wallet.sendTransaction({ to: p.wallet, value: ethers.parseEther(String(p.pol)) }); + rewards.mark(p.id, { status: 'sent', tx: tx.hash }); + const rc = await tx.wait(1); + rewards.mark(p.id, { status: rc && rc.status === 1 ? 'paid' : 'failed', paidAt: Date.now(), error: rc && rc.status === 1 ? null : 'reverted' }); + if (rc && rc.status === 1) { paid++; if (notify) await notify('paid', Object.assign({}, p, { tx: tx.hash })); } + } catch (e) { + rewards.mark(p.id, { status: 'failed', error: String(e.message || e).slice(0, 200) }); + if (notify) await notify('failed', Object.assign({}, p, { error: String(e.message || e).slice(0, 200) })); + } + } + // low-balance alert, once a day + const bal = await balance(); + if (bal != null) { + const s = rewards.settings(); const st = store.read('faucet-state', {}); + const day = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); + if (bal < Number(s.lowBalancePol) && st.lowAlertDay !== day) { st.lowAlertDay = day; store.write('faucet-state', st); if (notify) await notify('low', { balance: bal, threshold: s.lowBalancePol, address: wallet.address }); } + store.update('faucet-state', {}, x => Object.assign(x, { balance: bal, checkedAt: Date.now() })); + } + } finally { ticking = false; } + return { paid }; +} + +module.exports = { enabled, init, address, balance, tick }; diff --git a/lib/missions.js b/lib/missions.js new file mode 100644 index 0000000..f496cd5 --- /dev/null +++ b/lib/missions.js @@ -0,0 +1,80 @@ +// Missions, tokens and proof codes. +// +// A mission: { id, site, host, name, brief, url, dwell, reward: {min, max}, budget, active, created } +// host the origin the embed must call from (e.g. instantadpay.com); the proof only ever +// answers a request whose Origin header is that host +// brief what the hunter is told ("Open the ledger and find your code next to the newest payout") +// url where the mission link sends them; the token is appended as ?ph= +// dwell seconds on the site before the code will render +// budget completions this mission will still pay (0 = unlimited) +// +// A token is issued when a hunter starts a mission: { t, memberId, missionId, iat, exp }. The +// code they must type is derived from the token with the server secret, so it is unique to that +// member and that sitting, worthless to anyone else, and different tomorrow. The hiding place +// (which page / which slot the embed renders in) is also derived, from the day and the member. +'use strict'; +const crypto = require('crypto'); +const store = require('./store'); + +const SECRET = String(process.env.HUNT_CODE_SECRET || process.env.HUNT_SSO_SECRET || '').trim(); +const TOKEN_TTL = 20 * 60000; +const h = s => crypto.createHmac('sha256', SECRET).update(s).digest(); +const ctDay = t => new Date(t === undefined ? Date.now() : t).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); + +function list() { return store.read('missions', []); } +function get(id) { return list().find(m => m.id === id) || null; } +function save(m) { + return store.update('missions', [], all => { + const i = all.findIndex(x => x.id === m.id); + if (i >= 0) all[i] = Object.assign(all[i], m); else all.push(Object.assign({ created: Date.now(), active: true }, m)); + return all; + }); +} +function remove(id) { return store.update('missions', [], all => all.filter(m => m.id !== id)); } + +// each hunter sees the active missions in their own order, so two people comparing notes are +// not looking at the same list; the order is stable for a member within a day +function forMember(memberId) { + const day = ctDay(); + return list().filter(m => m.active).map(m => ({ m, k: h(day + ':' + memberId + ':' + m.id).readUInt32BE(0) })) + .sort((a, b) => a.k - b.k).map(x => x.m); +} + +// ---- tokens ------------------------------------------------------------------------------ +function issue(memberId, missionId) { + const t = crypto.randomBytes(16).toString('hex'); + const rec = { t, memberId: Number(memberId), missionId, iat: Date.now(), exp: Date.now() + TOKEN_TTL }; + store.update('tokens', {}, all => { for (const k of Object.keys(all)) if (all[k].exp < Date.now() - 3600000) delete all[k]; all[t] = rec; return all; }); + return rec; +} +function token(t) { const rec = store.read('tokens', {})[String(t || '')]; return rec && rec.exp > Date.now() ? rec : null; } + +// the proof: six characters from HMAC(secret, token); shown by the embed, typed by the hunter +function codeFor(rec) { return h('code:' + rec.t + ':' + rec.memberId + ':' + rec.missionId).toString('hex').slice(0, 6).toUpperCase(); } +// the hiding place: which slot on the page the embed renders in, by day and member +function slotFor(rec, slots) { const n = Math.max(1, Number(slots) || 1); return h('slot:' + ctDay() + ':' + rec.memberId + ':' + rec.missionId).readUInt32BE(0) % n; } + +// The embed asks for the code. Answered only when: the token is live, the request's Origin is the +// mission's host, and the dwell has passed since the token was issued. Anything else is silence. +function codeForEmbed(t, origin) { + const rec = token(t); if (!rec) return { error: 'expired' }; + const m = get(rec.missionId); if (!m || !m.active) return { error: 'gone' }; + const host = String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '').toLowerCase(); // Origin carries a port; the mission host never does + const allowed = [m.host, 'www.' + m.host].map(x => x.toLowerCase()); + if (!allowed.includes(host)) return { error: 'origin' }; + const waited = (Date.now() - rec.iat) / 1000; + if (waited < (m.dwell || 30)) return { wait: Math.ceil((m.dwell || 30) - waited) }; + return { code: codeFor(rec), slot: slotFor(rec, m.slots || 1) }; +} + +// The hunter submits what they found. +function check(t, memberId, typed) { + const rec = token(t); if (!rec) return { error: 'That mission timed out. Start it again from your board.' }; + if (rec.memberId !== Number(memberId)) return { error: 'That mission belongs to a different account.' }; + const want = codeFor(rec); + const got = String(typed || '').trim().toUpperCase(); + if (got !== want) return { error: 'That is not the code. It is on the site, and it is yours alone.' }; + return { ok: true, rec }; +} + +module.exports = { list, get, save, remove, forMember, issue, token, codeForEmbed, check, ctDay }; diff --git a/lib/rewards.js b/lib/rewards.js new file mode 100644 index 0000000..3a7d5a7 --- /dev/null +++ b/lib/rewards.js @@ -0,0 +1,75 @@ +// 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'); + +const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40 }; +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) { + const lo = Math.log(min), hi = Math.log(max); + const v = Math.exp(lo + Math.random() * (hi - lo)); + return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000; +} + +function paidToday(day) { + return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed').reduce((n, p) => n + p.pol, 0); +} + +// has this member completed this mission already? +function completed(memberId, missionId) { + return store.read('payouts', []).some(p => p.memberId === Number(memberId) && p.missionId === missionId && p.status !== 'failed'); +} + +// 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; +} +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; }); } + +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, payable, mark, ledger, mine, totals, paidToday }; diff --git a/lib/sso.js b/lib/sso.js new file mode 100644 index 0000000..2e7c25b --- /dev/null +++ b/lib/sso.js @@ -0,0 +1,68 @@ +// Sign-in by hand-off from InstantAdPay. There is no sign-up on PolHunter and never a mailer: +// a member clicks PolHunter inside their IAP dashboard, IAP signs a short-lived token with the +// shared secret, and this module turns it into a PolHunter session. +// +// Token: base64url(JSON payload) + '.' + base64url(HMAC-SHA256(secret, payload)). +// Payload: { memberId, email, wallet, username, iat, exp, nonce }. Single use, five minutes. +// Sessions: random id -> { memberId, email, wallet, username, since }, on the volume so a +// restart keeps people signed in. Cookie is HttpOnly, SameSite=Lax, Secure. +'use strict'; +const crypto = require('crypto'); +const store = require('./store'); + +const SECRET = String(process.env.HUNT_SSO_SECRET || '').trim(); +const SESSION_TTL = 30 * 86400000; +const used = new Map(); // nonce -> exp, single-use guard (in memory is fine: tokens live five minutes) + +const b64u = b => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +const unb64u = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +const sign = payload => b64u(crypto.createHmac('sha256', SECRET).update(payload).digest()); + +function enabled() { return SECRET.length >= 32; } + +// what IAP does to mint one (kept here so both sides read the same definition) +function mint(claims) { + const payload = JSON.stringify(Object.assign({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex') }, claims)); + return b64u(payload) + '.' + sign(payload); +} + +function verify(token) { + if (!enabled()) return { error: 'Sign-in from InstantAdPay is not configured on this server.' }; + const [p, sig] = String(token || '').split('.'); + if (!p || !sig) return { error: 'That sign-in link is not valid.' }; + let payload; try { payload = unb64u(p).toString('utf8'); } catch (e) { return { error: 'That sign-in link is not valid.' }; } + const want = sign(payload); + if (want.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))) return { error: 'That sign-in link is not valid.' }; + let c; try { c = JSON.parse(payload); } catch (e) { return { error: 'That sign-in link is not valid.' }; } + if (!c.exp || Date.now() > c.exp) return { error: 'That sign-in link has expired. Open PolHunter from your InstantAdPay dashboard again.' }; + if (!c.memberId || !c.email) return { error: 'That sign-in link is missing your account.' }; + for (const [n, exp] of used) if (exp < Date.now()) used.delete(n); + if (used.has(c.nonce)) return { error: 'That sign-in link was already used.' }; + used.set(c.nonce, c.exp); + return { ok: true, claims: c }; +} + +function startSession(claims) { + const id = crypto.randomBytes(24).toString('hex'); + store.update('sessions', {}, s => { + const now = Date.now(); + for (const k of Object.keys(s)) if ((s[k].since || 0) + SESSION_TTL < now) delete s[k]; + s[id] = { memberId: Number(claims.memberId), email: String(claims.email).toLowerCase(), wallet: claims.wallet ? String(claims.wallet).toLowerCase() : null, username: claims.username || null, since: now }; + return s; + }); + return id; +} +function cookie(id) { return 'ph.sid=' + id + '; Path=/; Max-Age=' + Math.floor(SESSION_TTL / 1000) + '; HttpOnly; SameSite=Lax; Secure'; } +function clearCookie() { return 'ph.sid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure'; } +function fromRequest(req) { + const m = /(?:^|;\s*)ph\.sid=([a-f0-9]{48})/.exec(req.headers.cookie || ''); + if (!m) return null; + const s = store.read('sessions', {})[m[1]]; + if (!s || (s.since || 0) + SESSION_TTL < Date.now()) return null; + return Object.assign({ sid: m[1] }, s); +} +function endSession(sid) { store.update('sessions', {}, s => { delete s[sid]; return s; }); } +// a member's wallet can change on IAP; the next hand-off refreshes it here +function refresh(sid, claims) { store.update('sessions', {}, s => { if (s[sid]) { s[sid].wallet = claims.wallet ? String(claims.wallet).toLowerCase() : null; s[sid].username = claims.username || s[sid].username; } return s; }); } + +module.exports = { enabled, mint, verify, startSession, cookie, clearCookie, fromRequest, endSession, refresh }; diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..044a2f0 --- /dev/null +++ b/lib/store.js @@ -0,0 +1,28 @@ +// Tiny JSON store on the volume. Every collection is one file; writes are atomic (tmp + rename). +// PolHunter's data is small (missions, completions, payouts) and a member's balance never lives +// here, so a database would be ceremony. If it ever grows, this is the one file to swap. +'use strict'; +const fs = require('fs'); +const path = require('path'); + +let DIR = null; +function init(dir) { DIR = dir; fs.mkdirSync(DIR, { recursive: true }); } +const file = name => path.join(DIR, name + '.json'); + +function read(name, fallback) { + try { return JSON.parse(fs.readFileSync(file(name), 'utf8')); } catch (e) { return fallback; } +} +function write(name, data) { + const f = file(name), tmp = f + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(data)); + fs.renameSync(tmp, f); +} +// read-modify-write in one place so callers never race themselves +function update(name, fallback, fn) { + const cur = read(name, fallback); + const out = fn(cur) || cur; + write(name, out); + return out; +} + +module.exports = { init, read, write, update }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e39d864 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,124 @@ +{ + "name": "polhunter", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "polhunter", + "version": "0.2.0", + "dependencies": { + "ethers": "^6.13.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 323a033..d7f9abd 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,18 @@ { "name": "polhunter", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "PolHunter: gamified visits across the network, paid in POL.", "main": "server.js", "scripts": { "start": "node server.js", - "dev": "node --watch server.js" + "dev": "node --watch server.js", + "test": "node test/run.js" }, "engines": { "node": ">=20" }, - "dependencies": {} + "dependencies": { + "ethers": "^6.13.0" + } } diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..84eac0c --- /dev/null +++ b/public/admin.html @@ -0,0 +1,62 @@ + + + + + +Admin · PolHunter + + + + + +
+
PolHunter admin
+ +
+ + + diff --git a/public/app.html b/public/app.html new file mode 100644 index 0000000..e84f594 --- /dev/null +++ b/public/app.html @@ -0,0 +1,34 @@ + + + + + +Your board · PolHunter + + + + +
+
+ PolHunter + +
+ +
+

Your missions

+
Each find pays a random drip. Your codes are yours alone.
+

Loading your board…

+
+ + + +
+

Your drips

+
+
+ +
Rewards are for completed missions, not income. The daily pool is limited; a find made after it is spent queues and pays next. Cryptocurrency involves risk of loss.
+
+ + + diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..42dccb9 --- /dev/null +++ b/public/app.js @@ -0,0 +1,60 @@ +// The hunter's board: missions in their own order, a live timer while they are on the site, a +// code box when they are back. One open mission at a time. +(() => { + const $ = id => document.getElementById(id); + const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const api = async (path, body) => { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); const j = await r.json().catch(() => ({})); if (r.status === 401) location.href = '/?signin=1'; return j; }; + let board = null, open = null; // open: { missionId, token, url, dwell, started } + + function card(m) { + const isOpen = open && open.missionId === m.id; + const r = m.reward || {}; + return '
' + + '' + (m.done ? 'Completed' : esc(m.site)) + '' + + '

' + esc(m.name) + '

' + esc(m.brief) + '

' + + '

' + r.minPol + ' to ' + r.maxPol + ' POL · ' + m.dwell + 's on the site

' + + (m.done ? '' : isOpen + ? '
Your code appears on the site after ' + m.dwell + 's. Keep that tab open.
' + + '
' + + '
' + + '

Open the site again ↗

' + : '
') + + '
'; + } + function render() { + $('who').innerHTML = ' ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + (board.me.wallet ? '' : ' · no wallet linked'); + const r = board.missions[0] && board.missions[0].reward; + if (r) $('rangeLine').textContent = 'Each find pays a random drip between ' + r.minPol + ' and ' + r.maxPol + ' POL. Your codes are yours alone.'; + $('missions').innerHTML = board.missions.length ? board.missions.map(card).join('') : '

No missions are open right now. Check back soon.

'; + $('mine').innerHTML = board.drips.length ? board.drips.map(d => '
' + esc(d.site) + '' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'failed: ' + esc(d.error || '') }[d.status] || d.status) + '+' + d.pol + ' POL' + (d.tx ? 'verify ↗' : '') + '
').join('') + : '
Nothing yet. Your first find goes here.
'; + document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start))); + const s = $('submit'); if (s) { s.addEventListener('click', submit); $('code').addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); tick(); } + } + let explorer = 'https://polygonscan.com'; + async function load() { board = await api('/api/my/board'); render(); } + async function start(id) { + const r = await api('/api/my/start', { missionId: id }); + if (r.error) { alert(r.error); return; } + open = { missionId: id, token: r.token, url: r.url, dwell: r.dwell, started: Date.now(), expires: r.expires }; + window.open(r.url, '_blank', 'noopener'); + render(); + } + function tick() { + const el = $('timer'); if (!el || !open) return; + const left = Math.max(0, open.dwell - Math.floor((Date.now() - open.started) / 1000)); + el.innerHTML = left > 0 ? 'Your code appears on the site in about ' + left + 's. Keep that tab open, then come back and type it here.' : 'Your code should be showing on the site now. Type it here.'; + if (open.expires && Date.now() > open.expires) { el.innerHTML = 'This attempt timed out. Start the mission again.'; return; } + setTimeout(tick, 1000); + } + async function submit() { + const code = $('code').value.trim(); if (!code) return; + $('submit').disabled = true; + const r = await api('/api/my/submit', { token: open.token, code }); + const m = $('msg'); m.className = 'msg ' + (r.error ? 'bad' : 'ok'); m.textContent = r.error || r.message; + $('submit').disabled = false; + if (!r.error) { open = null; setTimeout(load, 900); } + } + fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {}); + load(); +})(); diff --git a/public/embed.js b/public/embed.js new file mode 100644 index 0000000..fc2871f --- /dev/null +++ b/public/embed.js @@ -0,0 +1,43 @@ +/* PolHunter embed. One line on a mission site: + + plus, where a code may appear, elements with data-ph-slot="0" ... data-ph-slot="N-1". + + When a hunter arrives with ?ph= (their own, from their PolHunter board) the token is kept + in sessionStorage for this tab, and after the mission's dwell the code is fetched from + polhunter.com and rendered in ONE of the slots (which one is picked per hunter per day). The + request carries this page's Origin; polhunter.com answers only for the mission's own host and + only after the dwell. Anyone else loading this page sees nothing at all. */ +(function () { + var API = 'https://polhunter.com'; + try { + var q = new URLSearchParams(location.search), t = q.get('ph'); + if (t) { sessionStorage.setItem('ph.token', t); q.delete('ph'); history.replaceState(null, '', location.pathname + (q.toString() ? '?' + q : '') + location.hash); } + t = sessionStorage.getItem('ph.token'); if (!t) return; + } catch (e) { return; } + // slots: explicit [data-ph-slot="i"] elements, or the i-th match of data-selector (e.g. ".video-card" + // on a training page, ".card" on a programs page), else a fixed badge in the corner + var me = document.currentScript, slots = Number((me && me.getAttribute('data-slots')) || 1) || 1, sel = me && me.getAttribute('data-selector'); + function slotEl(i) { + var el = document.querySelector('[data-ph-slot="' + i + '"]'); if (el) return el; + if (sel) { var all = document.querySelectorAll(sel); if (all.length) { var host = all[i % all.length]; var d0 = document.createElement('div'); d0.style.cssText = 'margin:10px 0'; host.appendChild(d0); return d0; } } + var d = document.createElement('div'); d.setAttribute('data-ph-slot', String(i)); d.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:99999'; document.body.appendChild(d); return d; + } + function render(code, slot) { + var el = slotEl(slot); el.innerHTML = ''; + var box = document.createElement('div'); + box.setAttribute('role', 'note'); + box.style.cssText = 'display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:14px;background:linear-gradient(135deg,#1a0f3d,#2b1a63);color:#f1eefb;font:600 14px/1.2 system-ui,-apple-system,"Segoe UI",sans-serif;box-shadow:0 8px 30px rgba(130,71,229,.45),0 0 0 1px rgba(255,255,255,.08);letter-spacing:.02em'; + box.innerHTML = 'PolHunter code ' + code + ''; + el.appendChild(box); + } + var tries = 0; + function ask() { + tries++; if (tries > 40) return; + fetch(API + '/api/embed/code?t=' + encodeURIComponent(t), { mode: 'cors', credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (r) { + if (r && r.code) { render(r.code, Number(r.slot) % slots); try { sessionStorage.removeItem('ph.token'); } catch (e) {} return; } + if (r && r.wait) { setTimeout(ask, Math.min(r.wait, 10) * 1000); return; } + // expired / gone / wrong origin: say nothing + }).catch(function () { setTimeout(ask, 8000); }); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ask); else ask(); +})(); diff --git a/public/index.html b/public/index.html index f09cf96..2d5a15d 100644 --- a/public/index.html +++ b/public/index.html @@ -4,27 +4,69 @@ PolHunter - + +
-
PolHunter
-

Visit. Find it. Get paid in POL.

-

Missions across the network. Each one takes a few minutes on a site, a thing to find, and a drip of POL to your wallet when you find it.

- Building — opens soon -
Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.
+
+ PolHunter + +
+ +
+
+ Paying in POL on Polygon +

Visit. Find it.
Get paid in POL.

+

Every mission is a few minutes on one of our sites, one thing to find, and a drip of POL straight to your wallet when you find it. Your code is yours alone, and the hiding place moves.

+ Start hunting + See how + +
+ +
+ +
+

How a hunt works

+
Three steps, one code, real POL.
+
+
Pick a mission on your board. Each one names a site and what to find there.
+
Go find it. Spend the time on the site. Your personal code appears when you have, somewhere on the page.
+
Type the code. If it is yours, a random drip between the day's range lands in your wallet, on-chain, with a link to prove it.
+
+
+ +
+

Paid so far

+
Every drip is a Polygon transaction you can open.
+
+
Loading…
+
+ + + +
+ PolHunter rewards completed missions. Rewards are not income and are not guaranteed; the daily pool is limited and drips are random within the posted range. Cryptocurrency involves risk of loss. Hunters sign in with an InstantAdPay account; no account is created here. +
InstantAdPay · · +
+ diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..1694bd1 --- /dev/null +++ b/public/style.css @@ -0,0 +1,75 @@ +/* PolHunter: its own look. Night expedition: deep indigo, violet POL glow, gold finds, glass. */ +@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;600;800&family=JetBrains+Mono:wght@500&display=swap'); +:root{ + --bg:#080614;--bg2:#100b26;--ink:#f3f0ff;--muted:#a79fc4;--dim:#6e678a; + --pol:#8247e5;--pol-hi:#b48cff;--pol-deep:#3d2178;--gold:#f3be43;--gold-hi:#ffd97a;--ok:#4be3a5;--bad:#ff7a7a; + --glass:rgba(255,255,255,.05);--edge:rgba(255,255,255,.1);--edge-hi:rgba(180,140,255,.45); + --font:'Sora',system-ui,-apple-system,"Segoe UI",sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace; +} +*{margin:0;padding:0;box-sizing:border-box} +html{scroll-behavior:smooth} +body{min-height:100vh;background:var(--bg);color:var(--ink);font:16px/1.6 var(--font);overflow-x:hidden} +body::before{content:"";position:fixed;inset:0;z-index:-1;background: + radial-gradient(900px 500px at 15% -10%,rgba(130,71,229,.35),transparent 60%), + radial-gradient(700px 400px at 95% 20%,rgba(243,190,67,.14),transparent 60%), + radial-gradient(800px 600px at 50% 120%,rgba(130,71,229,.2),transparent 60%),var(--bg)} +a{color:var(--pol-hi);text-decoration:none} a:hover{color:var(--gold-hi)} +.wrap{max-width:1080px;margin:0 auto;padding:0 20px} +/* header */ +.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:22px 0} +.mark{display:inline-flex;align-items:center;gap:12px;font-weight:800;font-size:20px;letter-spacing:-.3px;color:var(--ink)} +.coin{width:34px;height:34px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#d7bdff,var(--pol) 55%,var(--pol-deep));box-shadow:0 0 28px rgba(130,71,229,.8),inset 0 0 0 1px rgba(255,255,255,.25);position:relative;flex:none} +.coin::after{content:"";position:absolute;inset:9px;border-radius:50%;border:2px solid rgba(255,255,255,.7);border-right-color:transparent;transform:rotate(-30deg)} +.nav{display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 20px;border-radius:999px;border:0;cursor:pointer;font:600 15px var(--font);color:#160a33;background:linear-gradient(135deg,var(--gold-hi),var(--gold));box-shadow:0 8px 24px rgba(243,190,67,.3);transition:transform .15s,box-shadow .15s} +.btn:hover{transform:translateY(-1px);box-shadow:0 12px 30px rgba(243,190,67,.4)} .btn:disabled{opacity:.5;cursor:default;transform:none} +.btn.ghost{background:var(--glass);color:var(--ink);box-shadow:none;border:1px solid var(--edge)} .btn.ghost:hover{border-color:var(--edge-hi)} +.btn.pol{background:linear-gradient(135deg,var(--pol-hi),var(--pol));color:#fff;box-shadow:0 8px 24px rgba(130,71,229,.4)} +.btn.sm{padding:8px 14px;font-size:13px} +/* hero */ +.hero{padding:56px 0 30px;display:grid;grid-template-columns:1.15fr .85fr;gap:40px;align-items:center} +.hero h1{font-size:clamp(38px,6vw,68px);line-height:1.02;font-weight:800;letter-spacing:-1.5px} +.hero h1 b{background:linear-gradient(90deg,var(--gold-hi),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent} +.hero p{font-size:19px;color:var(--muted);max-width:540px;margin:20px 0 26px} +.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:var(--glass);border:1px solid var(--edge);color:var(--muted);font-size:12px;letter-spacing:.1em;text-transform:uppercase} +.pill i{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 10px var(--ok)} +.stack{position:relative;height:340px} +.stack .c{position:absolute;border-radius:50%;background:radial-gradient(circle at 35% 30%,rgba(215,189,255,.95),var(--pol) 50%,var(--pol-deep) 85%);box-shadow:0 20px 60px rgba(130,71,229,.55),inset 0 0 0 2px rgba(255,255,255,.22),inset 0 -18px 30px rgba(0,0,0,.35);animation:float 7s ease-in-out infinite} +.stack .c::after{content:"";position:absolute;inset:26%;border-radius:50%;border:4px solid rgba(255,255,255,.75);border-right-color:transparent;transform:rotate(-30deg)} +.stack .c1{width:210px;height:210px;left:8%;top:8%} .stack .c2{width:120px;height:120px;right:10%;top:0;animation-delay:-2s} .stack .c3{width:90px;height:90px;right:22%;bottom:6%;animation-delay:-4s} +.stack .g{position:absolute;left:0;right:0;bottom:0;height:60px;background:radial-gradient(ellipse at center,rgba(243,190,67,.3),transparent 70%)} +@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-12px)}} +/* cards */ +.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px} +.card{background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.025));border:1px solid var(--edge);border-radius:22px;padding:22px;backdrop-filter:blur(8px);position:relative;overflow:hidden;transition:border-color .2s,transform .2s} +.card:hover{border-color:var(--edge-hi);transform:translateY(-2px)} +.card h3{font-size:19px;font-weight:800;letter-spacing:-.3px;margin:10px 0 6px} +.card p{color:var(--muted);font-size:14.5px} +.tag{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:600;background:rgba(130,71,229,.18);color:var(--pol-hi);border:1px solid rgba(130,71,229,.35)} +.tag.gold{background:rgba(243,190,67,.14);color:var(--gold-hi);border-color:rgba(243,190,67,.35)} +.tag.done{background:rgba(75,227,165,.12);color:var(--ok);border-color:rgba(75,227,165,.35)} +.range{font-family:var(--mono);font-size:13px;color:var(--gold-hi)} +.num{font-family:var(--mono);font-variant-numeric:tabular-nums} +.section{padding:34px 0} .section h2{font-size:28px;font-weight:800;letter-spacing:-.6px;margin-bottom:6px} .section .sub{color:var(--muted);margin-bottom:18px} +.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;counter-reset:s} +.step{padding:18px 18px 18px 58px;position:relative;background:var(--glass);border:1px solid var(--edge);border-radius:18px;color:var(--muted);font-size:15px} +.step::before{counter-increment:s;content:counter(s);position:absolute;left:16px;top:16px;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font:800 14px var(--font);color:#160a33;background:linear-gradient(135deg,var(--gold-hi),var(--gold))} +.step b{color:var(--ink)} +/* ledger */ +.ledger{display:flex;flex-direction:column;gap:8px} +.row{display:grid;grid-template-columns:1.2fr 1fr auto auto;gap:12px;align-items:center;padding:12px 16px;background:var(--glass);border:1px solid var(--edge);border-radius:14px;font-size:14px} +.row .pol{color:var(--gold-hi);font-family:var(--mono);font-weight:500} +.row .site{color:var(--muted)} .row .when{color:var(--dim);font-size:12px} +.stats{display:flex;gap:14px;flex-wrap:wrap;margin:14px 0 22px} +.stat{flex:1;min-width:140px;padding:16px 18px;background:var(--glass);border:1px solid var(--edge);border-radius:18px} +.stat .v{font:800 26px var(--font);letter-spacing:-.6px} .stat .l{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)} +/* mission flow */ +.mission-open{border-color:var(--edge-hi);box-shadow:0 0 0 1px rgba(180,140,255,.25),0 20px 60px rgba(130,71,229,.25)} +.timer{font:500 13px var(--mono);color:var(--muted)} .timer b{color:var(--gold-hi)} +.codebox{display:flex;gap:8px;margin-top:12px} +.codebox input{flex:1;padding:12px 14px;border-radius:12px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:600 18px var(--mono);letter-spacing:.2em;text-transform:uppercase;outline:none} +.codebox input:focus{border-color:var(--edge-hi)} +.msg{margin-top:10px;font-size:14px} .msg.ok{color:var(--ok)} .msg.bad{color:var(--bad)} +.foot{padding:40px 0 30px;color:var(--dim);font-size:12.5px;line-height:1.7;border-top:1px solid var(--edge);margin-top:30px} +.adslot{min-height:90px;display:flex;align-items:center;justify-content:center;margin:26px 0;color:var(--dim);font-size:12px} +@media (max-width:820px){.hero{grid-template-columns:1fr;padding-top:34px}.stack{height:240px}.row{grid-template-columns:1fr auto;row-gap:4px}.row .when{display:none}} diff --git a/server.js b/server.js index 4486bc6..befce94 100644 --- a/server.js +++ b/server.js @@ -1,82 +1,176 @@ // PolHunter: gamified visits across the network, paid in POL. // -// This is the shell the hunt engine will grow inside. What it does today is refuse to do anything -// dangerous by default, because the day before it was built a test area in this same network -// emailed 213 real people. Three gates, all default-deny, all read from the environment: +// 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=. // -// OUTBOUND=on nothing can send mail (or anything else outward) without it. There is no -// mailer in this app yet; when one arrives it must check outbound() first. -// SIGNUPS=open the sign-in door stays shut until this is set. Hunters will sign in with -// their InstantAdPay account; that bridge does not exist yet either. -// CURTAIN= while set, every request gets a contentless "Coming soon" page unless the -// browser has visited ?k= once. Lift it by unsetting the variable. -// -// A missing variable means silence, never delivery. +// 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 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'); const CURTAIN = String(process.env.CURTAIN || '').trim(); +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'; const signupsOpen = () => process.env.SIGNUPS === 'open'; -try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) {} +store.init(DATA_DIR); +const faucetOn = faucet.init(); -const CURTAIN_PAGE = ` - -Coming soon

Coming soon

This site is still being built.

`; - -function curtained(req, res, u) { - if (!CURTAIN) return false; - 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; +// ---- 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; } } +const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 }); +async function notify(kind, p) { + if (kind === 'paid') return 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 (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); +} +function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; } -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' }; -function sendFile(res, file) { +// ---- 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' }; +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) { fs.readFile(file, (err, buf) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } - res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); + res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(buf); }); } -const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }; +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 server = http.createServer((req, res) => { - 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 }); - if (curtained(req, res, u)) return; // nothing below runs for an uninvited visitor +const CURTAIN_PAGE = `Coming soon

Coming soon

This site is still being built.

`; +function curtained(req, res, u) { + if (!CURTAIN) return false; + 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() }); - if (p === '/api/config') return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), outbound: outbound() }); - if (p.startsWith('/api/')) return json(res, 404, { error: 'Not built yet.' }); +// ---- 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, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); - // static: the public dir, index for / - 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(); } - sendFile(res, file); + // 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': '*' }); + + if (curtained(req, res, u)) return; + + // ---- 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); + 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') return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer() }); + if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, 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') { + const done = new Set(rewards.mine(me.memberId).map(x => x.missionId)); + 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) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn } }); + } + 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)) return json(res, 400, { error: 'You already completed this one.' }); + 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); + const url = m.url + (m.url.includes('?') ? '&' : '?') + '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)) return json(res, 400, { error: 'You already completed this one.' }); + const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); + return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, 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(), 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' }); } + 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']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } + 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 + (m.url.includes('?') ? '&' : '?') + '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) {} } }); -server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'}`)); +// 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); + +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'}`)); diff --git a/test/run.js b/test/run.js new file mode 100644 index 0000000..6f198fc --- /dev/null +++ b/test/run.js @@ -0,0 +1,80 @@ +// End-to-end on a local boot: hand-off sign-in, a mission, the embed's origin and dwell locks, +// the code, the grant, the daily cap queue. No faucet, no outbound. Expected values stated. +'use strict'; +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const PORT = 8899, DIR = path.join(__dirname, '..', 'data-test'); +fs.rmSync(DIR, { recursive: true, force: true }); +const env = Object.assign({}, process.env, { PORT: String(PORT), DATA_DIR: DIR, HUNT_SSO_SECRET: 'x'.repeat(48), ADMIN_KEY: 'adminkey123', SIGNUPS: 'open', SITE_URL: 'http://127.0.0.1:' + PORT }); +delete env.CURTAIN; delete env.OUTBOUND; delete env.HUNT_WALLET_KEY; delete env.HUNT_RPC; +const child = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] }); +let fails = 0; +const eq = (a, b, m) => { const ok = JSON.stringify(a) === JSON.stringify(b); console.log((ok ? ' ok ' : ' FAIL ') + m + (ok ? '' : ' -> got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b))); if (!ok) fails++; }; +const B = 'http://127.0.0.1:' + PORT; +let jar = ''; +const call = async (p, opt = {}) => { + const res = await fetch(B + p, Object.assign({ redirect: 'manual' }, opt, { headers: Object.assign({ Cookie: jar, 'Content-Type': 'application/json' }, opt.headers || {}) })); + const sc = res.headers.get('set-cookie'); if (sc && /ph\.sid=/.test(sc)) jar = sc.split(';')[0]; + let body = null; try { body = await res.json(); } catch (e) {} + return { status: res.status, body, headers: res.headers }; +}; +const admin = (p, opt = {}) => call(p, Object.assign(opt, { headers: { 'X-Admin-Key': 'adminkey123' } })); +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + await sleep(1200); + process.env.HUNT_SSO_SECRET = env.HUNT_SSO_SECRET; const sso = require('../lib/sso'); + const h = await call('/health'); eq([h.body.outbound, h.body.signups, h.body.faucet, h.body.sso], [false, true, false, true], 'posture: outbound off, signups open, faucet off, sso on'); + + // a mission whose host is this test server + const mk = await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-1', site: 'Test site', name: 'Find it', brief: 'Open the page and find your code.', url: B + '/index.html', dwell: 5, slots: 3, budget: 0 }) }); + eq([mk.status, mk.body.missions.length], [200, 1], 'admin creates a mission'); + + // sign-in by hand-off + const bad = await call('/auth?t=nonsense'); eq(bad.status, 400, 'a bad hand-off token is refused'); + const tok = sso.mint({ memberId: 42, email: 'hunter@example.com', wallet: '0x' + 'ab'.repeat(20), username: 'hunter42' }); + const a = await call('/auth?t=' + tok); eq([a.status, a.headers.get('location')], [302, '/app'], 'a good hand-off signs in and lands on the board'); + const again = await call('/auth?t=' + tok); eq(again.status, 400, 'the same hand-off token cannot be replayed'); + const board = await call('/api/my/board'); eq([board.body.me.memberId, board.body.missions.length, board.body.missions[0].done], [42, 1, false], 'board shows the mission, not done'); + + // start: token + url + const st = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) }); + eq([st.status, /[?&]ph=[a-f0-9]{32}/.test(st.body.url)], [200, true], 'start issues a token on the mission link'); + const t = st.body.token; + + // the embed's locks + const wrongOrigin = await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'https://evil.example' } }); + eq(wrongOrigin.status, 403, 'embed: wrong origin is refused'); + const early = await (await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json(); + eq(typeof early.wait, 'number', 'embed: right origin before the dwell is told to wait'); + await sleep(5500); + const code = await (await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json(); + eq([/^[A-F0-9]{6}$/.test(code.code), code.slot >= 0 && code.slot < 3], [true, true], 'embed: after the dwell, a 6-char code and a slot 0..2'); + + // claim + const wrong = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: 'ZZZZZZ' }) }); eq(wrong.status, 400, 'a wrong code is refused'); + const ok = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); + eq([ok.status, ok.body.pol >= 0.05 && ok.body.pol <= 1, ok.body.queued], [200, true, false], 'the right code pays a drip in range, not queued'); + const twice = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); eq(twice.status, 400, 'the same mission cannot be claimed twice'); + const b2 = await call('/api/my/board'); eq([b2.body.missions[0].done, b2.body.drips.length, b2.body.drips[0].status], [true, 1, 'due'], 'board: done, one drip due (faucet off, so it waits)'); + + // the daily cap: with the cap set below one drip, the next find queues + await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 0.01 }) }); + await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-2', site: 'Test site', name: 'Second', brief: 'x', url: B + '/index.html', dwell: 5 }) }); + const s2 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); await sleep(5500); + const c2 = await (await fetch(B + '/api/embed/code?t=' + s2.body.token, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json(); + const q = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: s2.body.token, code: c2.code }) }); + eq([q.status, q.body.queued], [200, true], 'over the daily cap, the find is queued rather than refused'); + + // the draw is weighted low + const rewards = require('../lib/rewards'); const draws = Array.from({ length: 4000 }, () => rewards.draw(0.05, 1)); + const median = draws.sort((x, y) => x - y)[2000]; eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.35], [true, true], 'the draw stays in range and its median sits low (' + median + ')'); + + // nothing outward: no telegram env, no faucet env + const led = await call('/api/ledger'); eq(led.body.totals.paid, 0, 'nothing has been paid, because no faucet is configured'); + + console.log(fails ? '\nFAILURES: ' + fails : '\nALL POLHUNTER CHECKS PASS'); + child.kill(); fs.rmSync(DIR, { recursive: true, force: true }); process.exit(fails ? 1 : 0); +})().catch(e => { console.error('CRASH', e); child.kill(); process.exit(1); });