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 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 14:17:44 -05:00
parent 1a6166abc3
commit 523d57624e
15 changed files with 1001 additions and 75 deletions
+58
View File
@@ -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 };
+80
View File
@@ -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=<token>
// 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 };
+75
View File
@@ -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 };
+68
View File
@@ -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 };
+28
View File
@@ -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 };
+124
View File
@@ -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
}
}
}
}
}
+6 -3
View File
@@ -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"
}
}
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin · PolHunter</title>
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/style.css?v=1">
<style>
label{display:block;font-size:12px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;margin:10px 0 4px}
input,textarea,select{width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:14px var(--font)}
textarea{min-height:70px} .two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
table{width:100%;border-collapse:collapse;font-size:13px} td,th{padding:8px 10px;border-bottom:1px solid var(--edge);text-align:left;vertical-align:top} th{color:var(--dim);font-weight:600;font-size:11px;letter-spacing:.1em;text-transform:uppercase}
.gate{display:inline-block;padding:3px 9px;border-radius:999px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--edge)} .gate.on{color:var(--ok);border-color:rgba(75,227,165,.4)} .gate.off{color:var(--bad);border-color:rgba(255,122,122,.4)}
</style>
</head>
<body>
<div class="wrap">
<header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter <span style="font-weight:400;color:var(--dim)">admin</span></a><nav class="nav"><input id="key" placeholder="admin key" style="width:220px"><button class="btn sm" id="go">Open</button></nav></header>
<div id="ui" hidden>
<section class="section" style="padding-top:6px"><h2>Posture</h2><div id="gates"></div></section>
<section class="section"><h2>Pool</h2><div class="stats" id="stats"></div>
<div class="card"><div class="two"><div><label>Min POL per drip</label><input id="minPol"></div><div><label>Max POL per drip</label><input id="maxPol"></div><div><label>Daily cap (POL)</label><input id="dailyCapPol"></div><div><label>Low-balance alert (POL)</label><input id="lowBalancePol"></div></div><div style="margin-top:12px"><button class="btn sm" id="saveSettings">Save pool settings</button> <button class="btn ghost sm" id="tickFaucet">Run the faucet now</button> <span id="faucetLine" style="font-size:13px;color:var(--muted)"></span></div></div>
</section>
<section class="section"><h2>Missions</h2>
<div class="card"><div class="two"><div><label>id (slug)</label><input id="m_id" placeholder="iap-ledger"></div><div><label>Site label</label><input id="m_site" placeholder="InstantAdPay"></div></div>
<label>Name</label><input id="m_name" placeholder="Find your code on the live ledger">
<label>Brief (what the hunter is told)</label><textarea id="m_brief" placeholder="Open the public ledger and find your code next to the newest payout."></textarea>
<label>URL the mission link opens (the token is appended)</label><input id="m_url" placeholder="https://instantadpay.com/ledger">
<div class="two"><div><label>Dwell (seconds)</label><input id="m_dwell" value="45"></div><div><label>Slots on that page (hiding places)</label><input id="m_slots" value="1"></div><div><label>Budget (completions, 0 = unlimited)</label><input id="m_budget" value="0"></div><div><label>Active</label><select id="m_active"><option value="true">yes</option><option value="false">no</option></select></div></div>
<div style="margin-top:12px"><button class="btn sm" id="saveMission">Save mission</button> <span id="missionMsg" style="font-size:13px;color:var(--muted)"></span></div></div>
<table id="missions" style="margin-top:14px"></table>
</section>
<section class="section"><h2>Recent drips</h2><table id="payouts"></table></section>
</div>
</div>
<script>
(() => {
const $ = id => document.getElementById(id); const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let KEY = ''; const api = async (p, opt) => (await fetch(p, Object.assign({ headers: { 'X-Admin-Key': KEY, 'Content-Type': 'application/json' } }, opt || {}))).json();
let st = null;
async function load() {
st = await api('/api/admin/state'); if (st.error) { alert(st.error); return; }
$('ui').hidden = false;
const g = st.gates; $('gates').innerHTML = ['outbound', 'signups', 'curtain', 'sso'].map(k => '<span class="gate ' + (g[k] ? 'on' : 'off') + '">' + k + ': ' + (g[k] ? 'on' : 'off') + '</span> ').join('') + ' <span class="gate ' + (st.faucet.on ? 'on' : 'off') + '">faucet: ' + (st.faucet.on ? esc(st.faucet.address) : 'off') + '</span>';
const t = st.totals; $('stats').innerHTML = [['paid today', (t.today || 0).toFixed(2) + ' POL'], ['queued', t.queued], ['drips paid', t.paid], ['POL paid', t.pol], ['hunters', t.hunters], ['faucet balance', st.faucet.state && st.faucet.state.balance != null ? Number(st.faucet.state.balance).toFixed(2) + ' POL' : '—']].map(([l, v]) => '<div class="stat"><div class="v num">' + v + '</div><div class="l">' + l + '</div></div>').join('');
for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol']) $(k).value = st.settings[k];
$('missions').innerHTML = '<tr><th>id</th><th>site</th><th>name</th><th>url</th><th>dwell</th><th>slots</th><th>budget</th><th>active</th><th></th></tr>' + st.missions.map(m => '<tr><td class="num">' + esc(m.id) + '</td><td>' + esc(m.site) + '</td><td>' + esc(m.name) + '</td><td style="max-width:220px;word-break:break-all">' + esc(m.url) + '</td><td>' + m.dwell + '</td><td>' + (m.slots || 1) + '</td><td>' + (m.budget || '∞') + '</td><td>' + (m.active ? 'yes' : 'no') + '</td><td><button class="btn ghost sm" data-edit="' + esc(m.id) + '">edit</button> <button class="btn ghost sm" data-test="' + esc(m.id) + '">test link</button> <button class="btn ghost sm" data-del="' + esc(m.id) + '">×</button></td></tr>').join('');
$('payouts').innerHTML = '<tr><th>when</th><th>who</th><th>mission</th><th>POL</th><th>status</th><th>tx / error</th></tr>' + st.payouts.map(p => '<tr><td>' + new Date(p.at).toLocaleString() + '</td><td>' + esc(p.username ? '@' + p.username : '#' + p.memberId) + '</td><td>' + esc(p.missionId) + '</td><td class="num">' + p.pol + '</td><td>' + esc(p.status) + '</td><td style="word-break:break-all">' + esc(p.tx || p.error || '') + '</td></tr>').join('');
document.querySelectorAll('[data-edit]').forEach(b => b.addEventListener('click', () => { const m = st.missions.find(x => x.id === b.dataset.edit); for (const k of ['id', 'site', 'name', 'brief', 'url', 'dwell', 'slots', 'budget']) $('m_' + k).value = m[k] == null ? '' : m[k]; $('m_active').value = String(m.active !== false); window.scrollTo({ top: $('m_id').getBoundingClientRect().top + window.scrollY - 80, behavior: 'smooth' }); }));
document.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => { if (!confirm('Delete mission ' + b.dataset.del + '?')) return; await api('/api/admin/mission', { method: 'DELETE', body: JSON.stringify({ id: b.dataset.del }) }); load(); }));
document.querySelectorAll('[data-test]').forEach(b => b.addEventListener('click', async () => { const r = await api('/api/admin/embed-test?id=' + encodeURIComponent(b.dataset.test)); if (r.url) { window.open(r.url, '_blank', 'noopener'); alert('Opened with a test token. The code renders after ' + r.dwell + 's.'); } else alert(r.error || 'failed'); }));
}
$('go').addEventListener('click', () => { KEY = $('key').value.trim(); load(); });
$('key').addEventListener('keydown', e => { if (e.key === 'Enter') $('go').click(); });
$('saveSettings').addEventListener('click', async () => { const b = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol']) b[k] = Number($(k).value); const r = await api('/api/admin/settings', { method: 'POST', body: JSON.stringify(b) }); if (r.error) alert(r.error); load(); });
$('tickFaucet').addEventListener('click', async () => { $('faucetLine').textContent = 'running…'; const r = await api('/api/admin/faucet/tick', { method: 'POST' }); $('faucetLine').textContent = r.error ? r.error : 'paid ' + r.paid + ' · balance ' + (r.balance == null ? '—' : Number(r.balance).toFixed(4) + ' POL'); load(); });
$('saveMission').addEventListener('click', async () => { const b = {}; for (const k of ['id', 'site', 'name', 'brief', 'url', 'dwell', 'slots', 'budget']) b[k] = $('m_' + k).value; b.active = $('m_active').value === 'true'; const r = await api('/api/admin/mission', { method: 'POST', body: JSON.stringify(b) }); $('missionMsg').textContent = r.error || 'saved'; load(); });
})();
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your board · PolHunter</title>
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/style.css?v=1">
</head>
<body>
<div class="wrap">
<header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav"><span class="pill" id="who"><i></i></span><a class="btn ghost sm" href="/logout">Sign out</a></nav>
</header>
<section class="section" style="padding-top:10px">
<h2>Your missions</h2>
<div class="sub" id="rangeLine">Each find pays a random drip. Your codes are yours alone.</div>
<div class="grid" id="missions"><div class="card"><p>Loading your board…</p></div></div>
</section>
<div class="adslot" data-ad="board"></div>
<section class="section">
<h2>Your drips</h2>
<div class="ledger" id="mine"></div>
</section>
<footer class="foot">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.</footer>
</div>
<script src="/app.js?v=1"></script>
</body>
</html>
+60
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 '<div class="card' + (isOpen ? ' mission-open' : '') + '" data-id="' + esc(m.id) + '">'
+ '<span class="tag' + (m.done ? ' done' : '') + '">' + (m.done ? 'Completed' : esc(m.site)) + '</span>'
+ '<h3>' + esc(m.name) + '</h3><p>' + esc(m.brief) + '</p>'
+ '<p style="margin-top:10px"><span class="range">' + r.minPol + ' to ' + r.maxPol + ' POL</span> <span style="color:var(--dim);font-size:12px">· ' + m.dwell + 's on the site</span></p>'
+ (m.done ? '' : isOpen
? '<div class="timer" id="timer">Your code appears on the site after <b>' + m.dwell + 's</b>. Keep that tab open.</div>'
+ '<div class="codebox"><input id="code" maxlength="6" placeholder="CODE" autocomplete="off" spellcheck="false"><button class="btn pol" id="submit">Claim</button></div>'
+ '<div class="msg" id="msg"></div>'
+ '<p style="margin-top:8px"><a href="' + esc(open.url) + '" target="_blank" rel="noopener">Open the site again ↗</a></p>'
: '<div style="margin-top:14px"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>')
+ '</div>';
}
function render() {
$('who').innerHTML = '<i></i> ' + 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('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>';
$('mine').innerHTML = board.drips.length ? board.drips.map(d => '<div class="row"><span>' + esc(d.site) + '</span><span class="site">' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'failed: ' + esc(d.error || '') }[d.status] || d.status) + '</span><span class="pol">+' + d.pol + ' POL</span>' + (d.tx ? '<a class="when" target="_blank" rel="noopener" href="' + explorer + '/tx/' + d.tx + '">verify ↗</a>' : '<span class="when"></span>') + '</div>').join('')
: '<div class="row"><span class="site">Nothing yet. Your first find goes here.</span></div>';
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 <b>' + left + 's</b>. 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();
})();
+43
View File
@@ -0,0 +1,43 @@
/* PolHunter embed. One line on a mission site:
<script src="https://polhunter.com/embed.js" data-slots="3" async></script>
plus, where a code may appear, elements with data-ph-slot="0" ... data-ph-slot="N-1".
When a hunter arrives with ?ph=<token> (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 = '<span style="width:22px;height:22px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#c9a4ff,#8247e5 60%,#4b2a8c);box-shadow:0 0 14px rgba(130,71,229,.9);flex:none"></span><span>PolHunter code <b style="font-family:ui-monospace,Menlo,monospace;font-size:16px;letter-spacing:.14em;color:#f3be43">' + code + '</b></span>';
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();
})();
+60 -18
View File
@@ -4,27 +4,69 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PolHunter</title>
<style>
:root{--bg:#0b0a14;--ink:#f1eefb;--muted:#a79fc4;--pol:#8247e5;--pol-hi:#a97cf5;--gold:#f3be43}
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:32px 20px;background:radial-gradient(circle at 20% 10%,rgba(130,71,229,.28),transparent 45%),radial-gradient(circle at 85% 90%,rgba(243,190,67,.16),transparent 40%),var(--bg);color:var(--ink);font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
.wrap{max-width:640px;text-align:center}
.mark{display:inline-flex;align-items:center;gap:12px;font-weight:800;font-size:22px;letter-spacing:-.3px;margin-bottom:28px}
.mark i{width:34px;height:34px;border-radius:50%;background:var(--pol);box-shadow:0 0 34px rgba(130,71,229,.7)}
h1{font-size:clamp(32px,7vw,54px);line-height:1.05;font-weight:800;letter-spacing:-1px;margin-bottom:16px}
h1 b{color:var(--gold)}
p{color:var(--muted);font-size:18px;max-width:520px;margin:0 auto 22px}
.pill{display:inline-block;padding:8px 16px;border:1px solid rgba(167,159,196,.35);border-radius:999px;color:var(--muted);font-size:13px;letter-spacing:.08em;text-transform:uppercase}
.fine{margin-top:40px;font-size:12px;color:#6e678a}
</style>
<meta name="description" content="Missions across the network. Visit a site, find the thing, get a drip of POL to your wallet.">
<link rel="stylesheet" href="/style.css?v=1">
</head>
<body>
<div class="wrap">
<div class="mark"><i></i>PolHunter</div>
<h1>Visit. Find it. <b>Get paid in POL.</b></h1>
<p>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.</p>
<span class="pill">Building — opens soon</span>
<div class="fine">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</div>
<header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav">
<a class="btn ghost sm" href="#how">How it works</a>
<a class="btn ghost sm" href="#ledger">Paid so far</a>
<a class="btn sm" id="signin" href="https://instantadpay.com/my#polhunter">Open my board</a>
</nav>
</header>
<section class="hero">
<div>
<span class="pill"><i></i> Paying in POL on Polygon</span>
<h1>Visit. Find it.<br><b>Get paid in POL.</b></h1>
<p>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.</p>
<a class="btn" href="https://instantadpay.com/my#polhunter">Start hunting</a>
<a class="btn ghost" href="#how" style="margin-left:8px">See how</a>
<p id="signinNote" style="font-size:13px;color:var(--dim);margin-top:14px;display:none">Open PolHunter from your InstantAdPay dashboard to sign in. No password, no new account.</p>
</div>
<div class="stack" aria-hidden="true"><div class="c c1"></div><div class="c c2"></div><div class="c c3"></div><div class="g"></div></div>
</section>
<section class="section" id="how">
<h2>How a hunt works</h2>
<div class="sub">Three steps, one code, real POL.</div>
<div class="steps">
<div class="step"><b>Pick a mission</b> on your board. Each one names a site and what to find there.</div>
<div class="step"><b>Go find it.</b> Spend the time on the site. Your personal code appears when you have, somewhere on the page.</div>
<div class="step"><b>Type the code.</b> If it is yours, a random drip between the day's range lands in your wallet, on-chain, with a link to prove it.</div>
</div>
</section>
<section class="section" id="ledger">
<h2>Paid so far</h2>
<div class="sub">Every drip is a Polygon transaction you can open.</div>
<div class="stats" id="stats"></div>
<div class="ledger" id="recent"><div class="row"><span class="site">Loading…</span></div></div>
</section>
<div class="adslot" data-ad="home-footer"></div>
<footer class="foot">
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.
<br><a href="https://instantadpay.com">InstantAdPay</a> · <a href="/admin" rel="nofollow" style="color:var(--dim)">·</a>
</footer>
</div>
<script>
(async () => {
if (new URLSearchParams(location.search).get('signin')) document.getElementById('signinNote').style.display = 'block';
try {
const r = await (await fetch('/api/ledger')).json();
const t = r.totals || {};
document.getElementById('stats').innerHTML = [['POL paid', (t.pol || 0).toLocaleString('en-US', { maximumFractionDigits: 2 })], ['drips', t.paid || 0], ['hunters', t.hunters || 0], ['today', (t.today || 0).toLocaleString('en-US', { maximumFractionDigits: 2 }) + ' POL']]
.map(([l, v]) => '<div class="stat"><div class="v num">' + v + '</div><div class="l">' + l + '</div></div>').join('');
const cfg = await (await fetch('/api/config')).json();
document.getElementById('recent').innerHTML = (r.recent || []).length ? r.recent.map(x => '<div class="row"><span>' + x.who + '</span><span class="site">' + x.site + '</span><span class="pol">+' + x.pol + ' POL</span><a class="when" href="' + cfg.explorer + '/tx/' + x.tx + '" target="_blank" rel="noopener">verify ↗</a></div>').join('')
: '<div class="row"><span class="site">No drips yet. The first hunter gets the first line.</span></div>';
} catch (e) {}
})();
</script>
</body>
</html>
+75
View File
@@ -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}}
+148 -54
View File
@@ -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=<secret> a contentless "Coming soon" page for everyone who has not opened ?k=<secret>.
//
// 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=<secret> while set, every request gets a contentless "Coming soon" page unless the
// browser has visited ?k=<secret> 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 = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow">
<title>Coming soon</title><style>
*{margin:0;padding:0;box-sizing:border-box} html,body{height:100%}
body{display:flex;align-items:center;justify-content:center;padding:24px;background:#0d1117;color:#e6edf3;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
.card{max-width:420px;text-align:center} h1{font-size:clamp(28px,7vw,44px);font-weight:700;letter-spacing:-.5px;margin-bottom:14px} p{color:#8b949e}
</style></head><body><div class="card"><h1>Coming soon</h1><p>This site is still being built.</p></div></body></html>`;
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} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>');
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · 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 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Coming soon</title><style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{display:flex;align-items:center;justify-content:center;padding:24px;background:#0d1117;color:#e6edf3;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}.card{max-width:420px;text-align:center}h1{font-size:clamp(28px,7vw,44px);font-weight:700;letter-spacing:-.5px;margin-bottom:14px}p{color:#8b949e}</style></head><body><div class="card"><h1>Coming soon</h1><p>This site is still being built.</p></div></body></html>`;
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('<p style="font:16px system-ui;padding:40px">' + v.error + '</p>'); }
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. Todays 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'}`));
+80
View File
@@ -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); });