Advertiser-funded missions: the accounting that keeps their money separate
First increment of paid missions. No advertiser can buy one yet; this is the
layer that has to be right before anyone can, because the faucet is a single
wallet holding house float and every advertiser's unspent reserve at once.
THE SOLVENCY RULE, in paid.canSell: faucet balance must cover the house float
plus every outstanding reserve before a sale is accepted. Break it and you have
sold delivery you cannot pay for, and a hunter finds a code only to watch the
drip fail. Checked before the sale, never after.
Three consequences of Marty's decision that paid missions sit ON TOP of the
daily cap rather than inside it:
- a paid completion is excluded from paidToday, so it cannot eat house budget
- each live paid mission the hunter has not done raises their allowance by one
- a paid mission pays exactly what the advertiser set, with no random draw, so
the reserve taken at purchase is exact and can never come up short
Advertisers set the hunter payout themselves, floored at 0.30, and may pay more
to be completed sooner. Company margin rides on top as a percentage.
Existing missions are untouched: nothing carries the paid flag, so house
behaviour is byte for byte what it was. 18 new tests, 79 across the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+96
@@ -0,0 +1,96 @@
|
||||
// Advertiser-funded missions (Marty, 2026-09-24).
|
||||
//
|
||||
// A member buys a mission: they pay POL into the faucet wallet, and hunters who complete it are
|
||||
// dripped out of what they paid. Two rules follow from that, and everything here exists to hold
|
||||
// them:
|
||||
//
|
||||
// 1. A paid completion is NOT house spend. It does not count against dailyCapPol, and it does
|
||||
// not use up one of the hunter's missionsPerDay. Each live paid mission raises that hunter's
|
||||
// allowance by one, because the company is not funding it. ("Each additional mission paid by
|
||||
// an advertiser should allow +1 above the cap.")
|
||||
//
|
||||
// 2. THE SOLVENCY RULE. The faucet is one wallet holding house float AND every advertiser's
|
||||
// unspent reserve. If reserves ever exceed the balance we have sold delivery we cannot pay
|
||||
// for, and a hunter completes a mission only to watch the drip fail. So:
|
||||
//
|
||||
// faucet balance >= house float + sum(outstanding reserves)
|
||||
//
|
||||
// checked before a sale is accepted, never after.
|
||||
//
|
||||
// Money in is split at purchase: the hunter payout is reserved, the rest is company margin the
|
||||
// P&L can recognise. The advertiser sets the hunter payout themselves (base 0.30, they may pay
|
||||
// more to get completed sooner), so the reserve is exact rather than estimated. No draw, no
|
||||
// variance, no guessing.
|
||||
'use strict';
|
||||
const store = require('./store');
|
||||
|
||||
const DEFAULTS = {
|
||||
paidEnabled: 1,
|
||||
paidBaseHunterPol: 0.30, // the floor an advertiser may set as the hunter's payout
|
||||
paidMarginPct: 100, // company margin on top of the hunter payout, as a percentage
|
||||
paidMinBlock: 100, // completions per block
|
||||
paidMaxConcurrent: 0, // 0 = no limit; supply is elastic now that paid missions add capacity
|
||||
paidHouseFloatPol: 25, // POL kept back for house missions, never counted as sellable
|
||||
};
|
||||
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||
const r4 = n => Math.round(Number(n) * 10000) / 10000;
|
||||
|
||||
// ---- what a block costs -----------------------------------------------------------------
|
||||
// hunterPol is what each completing hunter receives; the advertiser pays that plus the margin.
|
||||
function quote(hunterPol, completions) {
|
||||
const c = cfg();
|
||||
const hp = r4(Math.max(Number(c.paidBaseHunterPol), Number(hunterPol) || 0));
|
||||
const n = Math.max(Number(c.paidMinBlock), Math.round(Number(completions) || 0));
|
||||
const reserve = r4(hp * n); // exact: no draw, so no buffer needed
|
||||
const margin = r4(reserve * (Number(c.paidMarginPct) / 100));
|
||||
return { hunterPol: hp, completions: n, reserve, margin, total: r4(reserve + margin) };
|
||||
}
|
||||
|
||||
// ---- reserves ---------------------------------------------------------------------------
|
||||
const paidMissions = () => store.read('missions', []).filter(m => m.paid);
|
||||
|
||||
// completions already granted against a mission (failed ones do not count, they never paid)
|
||||
function usedOf(missionId) {
|
||||
return store.read('payouts', []).filter(p => p.missionId === missionId && p.status !== 'failed' && !p.prize && !p.ref).length;
|
||||
}
|
||||
|
||||
// what every live paid mission still owes its hunters
|
||||
function outstanding() {
|
||||
let pol = 0, left = 0;
|
||||
for (const m of paidMissions()) {
|
||||
const remaining = Math.max(0, Number(m.budget || 0) - usedOf(m.id));
|
||||
left += remaining;
|
||||
pol += remaining * Number(m.hunterPol || 0);
|
||||
}
|
||||
return { reservePol: r4(pol), completionsLeft: left, missions: paidMissions().length };
|
||||
}
|
||||
|
||||
// THE guard. Given the faucet's real balance, can we take this sale?
|
||||
function canSell(balancePol, addReservePol) {
|
||||
const c = cfg();
|
||||
const o = outstanding();
|
||||
const float = Number(c.paidHouseFloatPol) || 0;
|
||||
const committed = r4(o.reservePol + float + Number(addReservePol || 0));
|
||||
const bal = r4(Number(balancePol) || 0);
|
||||
return {
|
||||
ok: bal >= committed,
|
||||
balance: bal, committed, houseFloat: float,
|
||||
alreadyReserved: o.reservePol, adding: r4(Number(addReservePol) || 0),
|
||||
shortBy: bal >= committed ? 0 : r4(committed - bal),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- the hunter's allowance ---------------------------------------------------------------
|
||||
// Base allowance is the company-funded missionsPerDay. Every live paid mission the hunter has
|
||||
// not yet done adds one, because that completion costs the company nothing.
|
||||
function extraAllowance(doneIds) {
|
||||
const done = doneIds instanceof Set ? doneIds : new Set(doneIds || []);
|
||||
return paidMissions().filter(m => m.active && !done.has(m.id) && Math.max(0, Number(m.budget || 0) - usedOf(m.id)) > 0).length;
|
||||
}
|
||||
|
||||
function isPaid(missionId) {
|
||||
const m = store.read('missions', []).find(x => x.id === missionId);
|
||||
return !!(m && m.paid);
|
||||
}
|
||||
|
||||
module.exports = { cfg, quote, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions };
|
||||
+125
-112
@@ -1,112 +1,125 @@
|
||||
// Rewards: the weighted draw, the daily cap, the queue, and the ledger.
|
||||
//
|
||||
// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol }
|
||||
// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant,
|
||||
// because POL's dollar price moves.
|
||||
//
|
||||
// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near
|
||||
// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever
|
||||
// the cap.
|
||||
//
|
||||
// A completion is recorded the moment the proof checks out. If today's paid total plus this
|
||||
// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in
|
||||
// order; the hunter did the work and is never told no. The faucet pays 'due' entries.
|
||||
'use strict';
|
||||
const store = require('./store');
|
||||
const { ctDay } = require('./missions');
|
||||
|
||||
// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor
|
||||
// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per
|
||||
// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool)
|
||||
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 };
|
||||
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
|
||||
|
||||
function draw(min, max, skew) {
|
||||
const lo = Math.log(min), hi = Math.log(max);
|
||||
const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1);
|
||||
const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor
|
||||
const v = Math.exp(hi - u * (hi - lo));
|
||||
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
|
||||
}
|
||||
|
||||
// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool
|
||||
// a hunter's finds today (Central), prizes excluded
|
||||
// day defaults to today: called without it the comparison was against undefined and silently
|
||||
// returned 0 for everyone, which would read as "no finds yet" to any new caller
|
||||
function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; }
|
||||
function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
|
||||
function paidToday(day) {
|
||||
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref).reduce((n, p) => n + p.pol, 0);
|
||||
}
|
||||
|
||||
// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and
|
||||
// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet:
|
||||
// two accounts sharing a wallet get one drip per mission per day between them.
|
||||
function completed(memberId, missionId, wallet) {
|
||||
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
||||
return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
|
||||
}
|
||||
// the mission ids this member (or wallet) has done today
|
||||
function doneToday(memberId, wallet) {
|
||||
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
||||
return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId));
|
||||
}
|
||||
|
||||
// record a completion; decide paid-today vs queued
|
||||
function grant(member, mission) {
|
||||
const s = settings();
|
||||
const pol = draw(Number(s.minPol), Number(s.maxPol));
|
||||
const day = ctDay();
|
||||
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
|
||||
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
|
||||
const overCap = paidToday(day) + pol > Number(s.dailyCapPol);
|
||||
const rec = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
|
||||
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
|
||||
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
|
||||
};
|
||||
store.update('payouts', [], all => { all.push(rec); return all; });
|
||||
return { ok: true, rec, queued: overCap };
|
||||
}
|
||||
|
||||
// what the faucet should pay now: due entries, then queued ones as long as today's cap allows
|
||||
function payable() {
|
||||
const s = settings(); const day = ctDay();
|
||||
let room = Number(s.dailyCapPol) - paidToday(day);
|
||||
const all = store.read('payouts', []);
|
||||
const out = [];
|
||||
for (const p of all.filter(p => p.status === 'due')) { out.push(p); }
|
||||
for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; }
|
||||
return out;
|
||||
}
|
||||
// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first
|
||||
function unposted(since, limit) {
|
||||
return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0))
|
||||
.sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15));
|
||||
}
|
||||
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
|
||||
|
||||
// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it
|
||||
// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight)
|
||||
function nextResetAt(now) {
|
||||
const t0 = now || Date.now(); const today = ctDay(t0);
|
||||
let lo = t0, hi = t0 + 26 * 3600000;
|
||||
while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; }
|
||||
return hi;
|
||||
}
|
||||
function pool() {
|
||||
const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol);
|
||||
return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() };
|
||||
}
|
||||
|
||||
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
|
||||
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
|
||||
function totals() {
|
||||
const all = store.read('payouts', []);
|
||||
const paid = all.filter(p => p.status === 'paid');
|
||||
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
|
||||
}
|
||||
|
||||
module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted };
|
||||
// Rewards: the weighted draw, the daily cap, the queue, and the ledger.
|
||||
//
|
||||
// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol }
|
||||
// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant,
|
||||
// because POL's dollar price moves.
|
||||
//
|
||||
// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near
|
||||
// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever
|
||||
// the cap.
|
||||
//
|
||||
// A completion is recorded the moment the proof checks out. If today's paid total plus this
|
||||
// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in
|
||||
// order; the hunter did the work and is never told no. The faucet pays 'due' entries.
|
||||
'use strict';
|
||||
const store = require('./store');
|
||||
const { ctDay } = require('./missions');
|
||||
|
||||
// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor
|
||||
// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per
|
||||
// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool)
|
||||
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 };
|
||||
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
|
||||
|
||||
function draw(min, max, skew) {
|
||||
const lo = Math.log(min), hi = Math.log(max);
|
||||
const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1);
|
||||
const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor
|
||||
const v = Math.exp(hi - u * (hi - lo));
|
||||
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
|
||||
}
|
||||
|
||||
// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool
|
||||
// a hunter's finds today (Central), prizes excluded
|
||||
// day defaults to today: called without it the comparison was against undefined and silently
|
||||
// returned 0 for everyone, which would read as "no finds yet" to any new caller
|
||||
function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; }
|
||||
function daily(memberId, doneIds) { const s = settings(); const base = Math.max(1, Number(s.missionsPerDay) || 3);
|
||||
// every live paid mission this hunter has not done yet adds one, because the company is not
|
||||
// funding that completion
|
||||
let extra = 0; try { extra = require('./paid').extraAllowance(doneIds); } catch (e) {}
|
||||
const limit = base + extra; const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
|
||||
// What the COMPANY has spent today. Advertiser-funded completions are excluded on purpose: the
|
||||
// hunter is paid out of what the advertiser already put in the faucet, so counting it here would
|
||||
// let a paid mission use up the house budget (Marty, 2026-09-24).
|
||||
function paidToday(day) {
|
||||
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && !p.paid).reduce((n, p) => n + p.pol, 0);
|
||||
}
|
||||
|
||||
// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and
|
||||
// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet:
|
||||
// two accounts sharing a wallet get one drip per mission per day between them.
|
||||
function completed(memberId, missionId, wallet) {
|
||||
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
||||
return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
|
||||
}
|
||||
// the mission ids this member (or wallet) has done today
|
||||
function doneToday(memberId, wallet) {
|
||||
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
||||
return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId));
|
||||
}
|
||||
|
||||
// record a completion; decide paid-today vs queued
|
||||
function grant(member, mission) {
|
||||
const s = settings();
|
||||
// An advertiser-funded mission pays exactly what the advertiser set, every time. No draw, so
|
||||
// the reserve taken at purchase is exact and can never come up short.
|
||||
const isPaid = !!mission.paid;
|
||||
const pol = isPaid ? Math.round(Number(mission.hunterPol || 0) * 10000) / 10000
|
||||
: draw(Number(s.minPol), Number(s.maxPol));
|
||||
const day = ctDay();
|
||||
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
|
||||
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
|
||||
// the house cap governs house spend only; a paid completion is already funded
|
||||
const overCap = !isPaid && (paidToday(day) + pol > Number(s.dailyCapPol));
|
||||
const rec = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
|
||||
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
|
||||
paid: isPaid || undefined, // marks advertiser-funded spend so the house accounting skips it
|
||||
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
|
||||
};
|
||||
store.update('payouts', [], all => { all.push(rec); return all; });
|
||||
return { ok: true, rec, queued: overCap };
|
||||
}
|
||||
|
||||
// what the faucet should pay now: due entries, then queued ones as long as today's cap allows
|
||||
function payable() {
|
||||
const s = settings(); const day = ctDay();
|
||||
let room = Number(s.dailyCapPol) - paidToday(day);
|
||||
const all = store.read('payouts', []);
|
||||
const out = [];
|
||||
for (const p of all.filter(p => p.status === 'due')) { out.push(p); }
|
||||
for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; }
|
||||
return out;
|
||||
}
|
||||
// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first
|
||||
function unposted(since, limit) {
|
||||
return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0))
|
||||
.sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15));
|
||||
}
|
||||
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
|
||||
|
||||
// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it
|
||||
// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight)
|
||||
function nextResetAt(now) {
|
||||
const t0 = now || Date.now(); const today = ctDay(t0);
|
||||
let lo = t0, hi = t0 + 26 * 3600000;
|
||||
while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; }
|
||||
return hi;
|
||||
}
|
||||
function pool() {
|
||||
const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol);
|
||||
return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() };
|
||||
}
|
||||
|
||||
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
|
||||
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
|
||||
function totals() {
|
||||
const all = store.read('payouts', []);
|
||||
const paid = all.filter(p => p.status === 'paid');
|
||||
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
|
||||
}
|
||||
|
||||
module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted };
|
||||
|
||||
@@ -1,335 +1,335 @@
|
||||
// PolHunter: gamified visits across the network, paid in POL.
|
||||
//
|
||||
// Three gates, all default-deny, all from the environment. A missing variable means silence:
|
||||
// OUTBOUND=on required before anything leaves this server: Telegram posts included.
|
||||
// There is no mailer in this app and there is not going to be one.
|
||||
// SIGNUPS=open there is no sign-up form at all; hunters arrive signed in from their
|
||||
// InstantAdPay dashboard (lib/sso.js). This gate controls whether that
|
||||
// hand-off is accepted, so the whole thing can be shut with one variable.
|
||||
// CURTAIN=<secret> a contentless "Coming soon" page for everyone who has not opened ?k=<secret>.
|
||||
//
|
||||
// Faucet: HUNT_WALLET_KEY + HUNT_RPC (+ HUNT_CHAIN_ID). Admin: ADMIN_KEY. Hand-off: HUNT_SSO_SECRET.
|
||||
'use strict';
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const store = require('./lib/store');
|
||||
const sso = require('./lib/sso');
|
||||
const missions = require('./lib/missions');
|
||||
const rewards = require('./lib/rewards');
|
||||
const social = require('./lib/social');
|
||||
const badge = require('./lib/badge');
|
||||
const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
|
||||
const faucet = require('./lib/faucet');
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
|
||||
const CURTAIN = String(process.env.CURTAIN || '').trim();
|
||||
// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND
|
||||
// says; from then on the curtain lifts by itself and Telegram opens. Launch: 2026-09-21T09:00-05:00.
|
||||
const LIVE_AT = process.env.HUNT_LIVE_AT ? Date.parse(process.env.HUNT_LIVE_AT) : 0;
|
||||
function live() { return !LIVE_AT || Date.now() >= LIVE_AT; }
|
||||
const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim();
|
||||
const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, '');
|
||||
const outbound = () => process.env.OUTBOUND === 'on' && live(); // nothing leaves before the live moment
|
||||
const signupsOpen = () => process.env.SIGNUPS === 'open';
|
||||
|
||||
store.init(DATA_DIR);
|
||||
const faucetOn = faucet.init();
|
||||
|
||||
// ---- Telegram (outward: gated) ------------------------------------------------------------
|
||||
async function telegram(text) {
|
||||
if (!outbound()) return false; // the gate
|
||||
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
|
||||
if (!tok || !chat) return false;
|
||||
const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {}));
|
||||
try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; }
|
||||
}
|
||||
async function telegramPhoto(file, caption, general) {
|
||||
if (!outbound()) return false; // the gate
|
||||
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
|
||||
if (!tok || !chat || !file) return false;
|
||||
try {
|
||||
const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic && !general) fd.append('message_thread_id', String(topic));
|
||||
fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg');
|
||||
const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 });
|
||||
// keys being posted right now, so the claim path and the sweep never post the same thing twice
|
||||
const postingNow = new Set();
|
||||
async function notify(kind, p) {
|
||||
if (kind === 'paid' && p && p.id) { const k = 'find:' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyPaid(p); } finally { postingNow.delete(k); } }
|
||||
if (kind === 'badge' && p && p.me) { const k = 'badge:' + p.me.memberId + ':' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyBadge(p); } finally { postingNow.delete(k); } }
|
||||
return notifyOther(kind, p);
|
||||
}
|
||||
async function notifyPaid(p) {
|
||||
{ const ok = await telegram('\u{1F3AF} <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 (ok && p.id) rewards.mark(p.id, { posted: Date.now() }); return ok; }
|
||||
}
|
||||
async function notifyOther(kind, p) {
|
||||
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);
|
||||
// a bounty is the one payout that advertises the mission itself, so it goes to the room
|
||||
if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} <b>PolHunter</b> · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · <b>' + fmt(d.pol) + ' POL</b> bounty on the way\n<a href="' + SITE + '">Bring yours</a>'); }
|
||||
// Marty's launch bonus, paid once to whoever gets a referral all the way through first
|
||||
if (kind === 'firstbonus') { const d = p.drip; return telegram('\u{1F947} <b>First referral home!</b> ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' is the first to bring a hunter all the way through, so Marty’s <b>' + fmt(d.pol) + ' POL</b> bonus is theirs on top of the bounty.\nThat one is claimed. The mission is not — <a href="' + SITE + '">bring yours</a>.'); }
|
||||
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
|
||||
return false;
|
||||
}
|
||||
async function notifyBadge(p) { const ok = await postBadge(p); if (ok) store.update('badges-posted', {}, all => { const k = String(p.me.memberId); all[k] = Array.from(new Set([...(all[k] || []), p.id])); return all; }); return ok; }
|
||||
async function postBadge(p) {
|
||||
{ const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} <b>PolHunter</b> \u00b7 <b>' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + '</b> unlocked <b>' + b.name + '</b>: ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; if (!file) return telegram(cap); const ok = await telegramPhoto(file, cap); if (String(process.env.HUNT_TG_BADGE_GENERAL || 'on') === 'on') await telegramPhoto(file, cap, true); return ok; }
|
||||
return false;
|
||||
}
|
||||
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
|
||||
const dailyMsg = d => 'That is your ' + ['', 'one', 'two', 'three', 'four', 'five'][d.limit] + ' for today. Fresh missions at midnight Central.';
|
||||
const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.';
|
||||
function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; }
|
||||
function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; }
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------------------
|
||||
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.mp4': 'video/mp4' };
|
||||
const SEC = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY' };
|
||||
function json(res, code, body, extra) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(JSON.stringify(body)); }
|
||||
function sendFile(res, file, extra) {
|
||||
// video streams with byte ranges (iOS Safari refuses to play without 206 support)
|
||||
if (path.extname(file) === '.mp4') return sendRange(res, file, extra);
|
||||
fs.readFile(file, (err, buf) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
||||
res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {}));
|
||||
res.end(buf);
|
||||
});
|
||||
}
|
||||
function sendRange(res, file, extra) {
|
||||
fs.stat(file, (err, st) => {
|
||||
if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
||||
const size = st.size; const range = res.req && res.req.headers.range;
|
||||
const head = Object.assign({ 'Content-Type': TYPES['.mp4'], 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' }, SEC, extra || {});
|
||||
let start = 0, end = size - 1, code = 200;
|
||||
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||
if (m) {
|
||||
start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2] || 0)); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : (m[1] ? size - 1 : size - 1);
|
||||
if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); }
|
||||
code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size;
|
||||
}
|
||||
head['Content-Length'] = end - start + 1;
|
||||
res.writeHead(code, head);
|
||||
if (res.req && res.req.method === 'HEAD') return res.end();
|
||||
fs.createReadStream(file, { start, end }).pipe(res);
|
||||
});
|
||||
}
|
||||
function readBody(req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { resolve({}); } }); }); }
|
||||
const hits = new Map();
|
||||
function limited(key, max, windowMs) { const now = Date.now(); const r = hits.get(key); if (!r || now > r.reset) { hits.set(key, { n: 1, reset: now + windowMs }); return false; } r.n++; return r.n > max; }
|
||||
const ip = req => String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
||||
|
||||
const CURTAIN_PAGE = `<!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 || (LIVE_AT && live())) return false; // the launch moment lifts the curtain
|
||||
if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; }
|
||||
const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || '');
|
||||
if (m && decodeURIComponent(m[1]) === CURTAIN) return false;
|
||||
res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' }); res.end(req.method === 'HEAD' ? '' : CURTAIN_PAGE); return true;
|
||||
}
|
||||
function admin(req) { const k = req.headers['x-admin-key'] || new URL(req.url, 'http://x').searchParams.get('key'); return !!(ADMIN_KEY && k && k.length === ADMIN_KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(ADMIN_KEY))); }
|
||||
const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, dwell: m.dwell || 30, reward: rewards.settings() });
|
||||
|
||||
// ---- the server ------------------------------------------------------------------------------
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const u = new URL(req.url, 'http://x'); const p = u.pathname;
|
||||
if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null });
|
||||
|
||||
// the embed talks to us from the mission sites: it must work through the curtain, and it must
|
||||
// answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first)
|
||||
if (p === '/api/embed/code') {
|
||||
const origin = String(req.headers.origin || '');
|
||||
const r = missions.codeForEmbed(u.searchParams.get('t'), origin);
|
||||
const cors = r.error === 'origin' ? {} : { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin' };
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204, Object.assign({ 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' }, cors)); return res.end(); }
|
||||
if (limited('embed:' + ip(req), 120, 60000)) return json(res, 429, { error: 'slow down' }, cors);
|
||||
return json(res, r.error ? 403 : 200, r, cors);
|
||||
}
|
||||
if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' });
|
||||
// badge cards and their share pages are posted around the web: they pass the curtain
|
||||
if (p === '/style.css') return sendFile(res, path.join(PUBLIC_DIR, 'style.css'), { 'Cache-Control': 'public, max-age=3600' }); // the share pages are styled for outsiders
|
||||
if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
|
||||
{ const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p);
|
||||
if (bm) {
|
||||
const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id);
|
||||
if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
||||
if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); }
|
||||
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId);
|
||||
const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.';
|
||||
const share = encodeURIComponent(title + ' ' + url);
|
||||
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">'
|
||||
+ '<meta property="og:type" content="website"><meta property="og:site_name" content="PolHunter"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1080"><meta property="og:image:height" content="1080">'
|
||||
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '"><link rel="stylesheet" href="/style.css?v=12"></head>'
|
||||
+ '<body><div class="wrap"><header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter</a><nav class="nav"><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn sm" href="' + ref + '">Hunt yours</a></nav></header>'
|
||||
+ '<div class="bp"><img src="' + img + '" alt="' + esc(b.name) + ' badge for ' + esc(m.who) + '"><h1>' + esc(m.who) + ' unlocked <em>' + esc(b.name) + '</em></h1><p>' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.</p>'
|
||||
+ '<a class="btn" href="' + ref + '">Hunt yours</a><div class="sharebtns" style="justify-content:center;margin-top:16px"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + share + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + share + '">WhatsApp</a></div>'
|
||||
+ '<p class="small" style="margin-top:26px">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</p></div></div></body></html>';
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html);
|
||||
}
|
||||
}
|
||||
// the coin on the code pill, fetched by mission-site visitors who hold no curtain pass
|
||||
if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
|
||||
|
||||
if (curtained(req, res, u)) return;
|
||||
// a member's share link: /?r=<their IAP username or id> becomes a 30-day cookie; the landing then
|
||||
// sends sign-ups to instantadpay.com/join/<ref>, so IAP's own last-touch sponsor rule applies
|
||||
// Served in place, not redirected (Marty, 2026-09-21): Facebook's crawler follows redirects and canonicalizes a
|
||||
// share to og:url, so a redirect to the bare home page lost the member's name on every share. og:url carries
|
||||
// the ref; the cookie rides on the same response.
|
||||
if (p === '/' && u.searchParams.get('r')) {
|
||||
const r = String(u.searchParams.get('r')).trim().slice(0, 40);
|
||||
const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r);
|
||||
let html = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8');
|
||||
if (ok) html = html.replace('<meta property="og:url" content="https://polhunter.com/">', '<meta property="og:url" content="https://polhunter.com/?r=' + encodeURIComponent(r) + '">');
|
||||
const h = Object.assign({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, SEC);
|
||||
if (ok) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure';
|
||||
res.writeHead(200, h); return res.end(html);
|
||||
}
|
||||
|
||||
// ---- sign-in by hand-off from InstantAdPay
|
||||
if (p === '/auth') {
|
||||
if (!signupsOpen()) { res.writeHead(503, { 'Content-Type': 'text/plain' }); return res.end('PolHunter is not accepting hunters yet.'); }
|
||||
const v = sso.verify(u.searchParams.get('t'));
|
||||
if (v.error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('<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);
|
||||
referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in
|
||||
res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end();
|
||||
}
|
||||
if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); }
|
||||
|
||||
// ---- public
|
||||
if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); }
|
||||
if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); }
|
||||
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) });
|
||||
if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' });
|
||||
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
|
||||
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
|
||||
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
|
||||
|
||||
// ---- hunter (session required)
|
||||
if (p.startsWith('/api/my/')) {
|
||||
const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' });
|
||||
if (p === '/api/my/board') {
|
||||
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
|
||||
const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet
|
||||
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId),
|
||||
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
|
||||
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' },
|
||||
referral: referrals.state(me) });
|
||||
}
|
||||
if (p === '/api/my/start' && req.method === 'POST') {
|
||||
const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' });
|
||||
if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' });
|
||||
const t = missions.issue(me.memberId, m.id);
|
||||
// a mission URL may place the token itself with {token} (a Telegram Mini App takes it in
|
||||
// ?startapp=, not as our own query string); otherwise it is appended as ?ph=
|
||||
// the token rides in the hash: a server never sees it, so no redirect or canonical rewrite can lose it
|
||||
const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t;
|
||||
return json(res, 200, { ok: true, token: t.t, url, dwell: m.dwell || 30, expires: t.exp });
|
||||
}
|
||||
if (p === '/api/my/submit' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' });
|
||||
const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error });
|
||||
const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
|
||||
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
|
||||
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
|
||||
let refDrips = [];
|
||||
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
|
||||
for (const d of refDrips) if (d.ref.kind === 'bounty' || d.ref.kind === 'first-bonus') notify(d.ref.kind === 'bounty' ? 'referral' : 'firstbonus', { drip: d }).catch(() => {});
|
||||
// achievements unlocked by this find: told to the board, posted to Telegram (gated)
|
||||
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
|
||||
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
|
||||
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' });
|
||||
}
|
||||
return json(res, 404, { error: 'No such call.' });
|
||||
}
|
||||
|
||||
// ---- admin (key)
|
||||
if (p.startsWith('/api/admin/')) {
|
||||
if (!admin(req)) return json(res, 401, { error: 'Admin key required.' });
|
||||
if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } });
|
||||
if (p === '/api/admin/mission' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' });
|
||||
let host = ''; try { host = new URL(String(b.url)).hostname.replace(/^www\./, ''); } catch (e) { return json(res, 400, { error: 'url must be a full https URL' }); }
|
||||
// the origin the embed calls from can differ from the link (a t.me launch link opens a Mini App on its own host)
|
||||
if (b.host) host = String(b.host).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '');
|
||||
missions.save({ id, site: String(b.site || host).slice(0, 60), host, name: String(b.name || '').slice(0, 80), brief: String(b.brief || '').slice(0, 400), url: String(b.url), dwell: Math.max(5, Number(b.dwell) || 45), slots: Math.max(1, Number(b.slots) || 1), budget: Math.max(0, Number(b.budget) || 0), active: b.active !== false });
|
||||
return json(res, 200, { ok: true, missions: missions.list() });
|
||||
}
|
||||
if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); }
|
||||
if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
|
||||
// award a week by hand (its Monday key); already-awarded weeks are skipped
|
||||
if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); }
|
||||
if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); }
|
||||
if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter
|
||||
const m = missions.get(String(u.searchParams.get('id') || '')); if (!m) return json(res, 404, { error: 'no such mission' });
|
||||
const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t, token: t.t, dwell: m.dwell });
|
||||
}
|
||||
return json(res, 404, { error: 'No such admin call.' });
|
||||
}
|
||||
if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html'));
|
||||
if (p === '/app') { if (!sso.fromRequest(req)) { res.writeHead(302, { Location: '/?signin=1' }); return res.end(); } return sendFile(res, path.join(PUBLIC_DIR, 'app.html')); }
|
||||
if (p.startsWith('/api/')) return json(res, 404, { error: 'No such call.' });
|
||||
|
||||
const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, '');
|
||||
const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe);
|
||||
if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); }
|
||||
return sendFile(res, file);
|
||||
} catch (e) { console.error(req.method, req.url, e.message); try { json(res, 500, { error: 'Internal server error' }); } catch (x) {} }
|
||||
});
|
||||
|
||||
// the faucet pays every two minutes; nothing outward leaves unless OUTBOUND=on (telegram checks)
|
||||
if (faucetOn) setInterval(() => faucet.tick(notify).catch(e => console.error('faucet', e.message)), 2 * 60000);
|
||||
// Every completed mission reaches Telegram (Marty, 2026-09-21): a find is posted when its drip is paid, and
|
||||
// this sweep posts any paid find that is not marked posted yet (the pre-live gate, Telegram down, a restart).
|
||||
// Only finds made after HUNT_POST_SINCE count, so rehearsal drips from before the doors opened stay quiet.
|
||||
const POST_SINCE = process.env.HUNT_POST_SINCE ? Date.parse(process.env.HUNT_POST_SINCE) : 0;
|
||||
let sweeping = false;
|
||||
async function postMissedFinds() {
|
||||
if (sweeping || !outbound()) return 0; sweeping = true; let n = 0;
|
||||
try { for (const p of rewards.unposted(POST_SINCE, 15)) { if (postingNow.has('find:' + p.id)) continue; if (await notify('paid', p)) n++; else break; } }
|
||||
catch (e) { console.error('find sweep', e.message); } finally { sweeping = false; }
|
||||
if (n) console.log('posted', n, 'missed find(s) to Telegram');
|
||||
await postMissedBadges().catch(e => console.error('badge sweep', e.message));
|
||||
return n;
|
||||
}
|
||||
// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart)
|
||||
async function postMissedBadges() {
|
||||
if (!outbound()) return 0;
|
||||
const since = POST_SINCE; const recs = store.read('payouts', []).filter(x => (x.at || 0) >= since && x.status !== 'failed');
|
||||
const seen = new Map(); for (const r of recs) seen.set(r.memberId, { memberId: r.memberId, username: r.username || null });
|
||||
const posted = store.read('badges-posted', {}); let n = 0;
|
||||
for (const me of seen.values()) {
|
||||
const have = new Set(posted[String(me.memberId)] || []);
|
||||
for (const b of social.badgesFor(me.memberId)) { if (have.has(b.id) || postingNow.has('badge:' + me.memberId + ':' + b.id)) continue; if (n >= 10) return n; if (await notify('badge', { me, id: b.id })) n++; else return n; }
|
||||
}
|
||||
if (n) console.log('posted', n, 'missed badge(s) to Telegram');
|
||||
return n;
|
||||
}
|
||||
setInterval(() => postMissedFinds().catch(() => {}), 2 * 60000); setTimeout(() => postMissedFinds().catch(() => {}), 20000);
|
||||
// weekly prizes: the week that just ended is awarded on the first tick after Sunday, Central
|
||||
setInterval(() => { try { social.awardDue(notify); } catch (e) { console.error('prizes', e.message); } }, 2 * 60000);
|
||||
|
||||
server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'} — sso: ${sso.enabled() ? 'on' : 'off'} — faucet: ${faucetOn ? faucet.address() + ' chain ' + (process.env.HUNT_CHAIN_ID || '?') : 'off'}`));
|
||||
// PolHunter: gamified visits across the network, paid in POL.
|
||||
//
|
||||
// Three gates, all default-deny, all from the environment. A missing variable means silence:
|
||||
// OUTBOUND=on required before anything leaves this server: Telegram posts included.
|
||||
// There is no mailer in this app and there is not going to be one.
|
||||
// SIGNUPS=open there is no sign-up form at all; hunters arrive signed in from their
|
||||
// InstantAdPay dashboard (lib/sso.js). This gate controls whether that
|
||||
// hand-off is accepted, so the whole thing can be shut with one variable.
|
||||
// CURTAIN=<secret> a contentless "Coming soon" page for everyone who has not opened ?k=<secret>.
|
||||
//
|
||||
// Faucet: HUNT_WALLET_KEY + HUNT_RPC (+ HUNT_CHAIN_ID). Admin: ADMIN_KEY. Hand-off: HUNT_SSO_SECRET.
|
||||
'use strict';
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const store = require('./lib/store');
|
||||
const sso = require('./lib/sso');
|
||||
const missions = require('./lib/missions');
|
||||
const rewards = require('./lib/rewards');
|
||||
const social = require('./lib/social');
|
||||
const badge = require('./lib/badge');
|
||||
const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
|
||||
const faucet = require('./lib/faucet');
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
|
||||
const CURTAIN = String(process.env.CURTAIN || '').trim();
|
||||
// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND
|
||||
// says; from then on the curtain lifts by itself and Telegram opens. Launch: 2026-09-21T09:00-05:00.
|
||||
const LIVE_AT = process.env.HUNT_LIVE_AT ? Date.parse(process.env.HUNT_LIVE_AT) : 0;
|
||||
function live() { return !LIVE_AT || Date.now() >= LIVE_AT; }
|
||||
const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim();
|
||||
const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, '');
|
||||
const outbound = () => process.env.OUTBOUND === 'on' && live(); // nothing leaves before the live moment
|
||||
const signupsOpen = () => process.env.SIGNUPS === 'open';
|
||||
|
||||
store.init(DATA_DIR);
|
||||
const faucetOn = faucet.init();
|
||||
|
||||
// ---- Telegram (outward: gated) ------------------------------------------------------------
|
||||
async function telegram(text) {
|
||||
if (!outbound()) return false; // the gate
|
||||
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
|
||||
if (!tok || !chat) return false;
|
||||
const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {}));
|
||||
try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; }
|
||||
}
|
||||
async function telegramPhoto(file, caption, general) {
|
||||
if (!outbound()) return false; // the gate
|
||||
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
|
||||
if (!tok || !chat || !file) return false;
|
||||
try {
|
||||
const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic && !general) fd.append('message_thread_id', String(topic));
|
||||
fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg');
|
||||
const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 });
|
||||
// keys being posted right now, so the claim path and the sweep never post the same thing twice
|
||||
const postingNow = new Set();
|
||||
async function notify(kind, p) {
|
||||
if (kind === 'paid' && p && p.id) { const k = 'find:' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyPaid(p); } finally { postingNow.delete(k); } }
|
||||
if (kind === 'badge' && p && p.me) { const k = 'badge:' + p.me.memberId + ':' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyBadge(p); } finally { postingNow.delete(k); } }
|
||||
return notifyOther(kind, p);
|
||||
}
|
||||
async function notifyPaid(p) {
|
||||
{ const ok = await telegram('\u{1F3AF} <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 (ok && p.id) rewards.mark(p.id, { posted: Date.now() }); return ok; }
|
||||
}
|
||||
async function notifyOther(kind, p) {
|
||||
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);
|
||||
// a bounty is the one payout that advertises the mission itself, so it goes to the room
|
||||
if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} <b>PolHunter</b> · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · <b>' + fmt(d.pol) + ' POL</b> bounty on the way\n<a href="' + SITE + '">Bring yours</a>'); }
|
||||
// Marty's launch bonus, paid once to whoever gets a referral all the way through first
|
||||
if (kind === 'firstbonus') { const d = p.drip; return telegram('\u{1F947} <b>First referral home!</b> ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' is the first to bring a hunter all the way through, so Marty’s <b>' + fmt(d.pol) + ' POL</b> bonus is theirs on top of the bounty.\nThat one is claimed. The mission is not — <a href="' + SITE + '">bring yours</a>.'); }
|
||||
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
|
||||
return false;
|
||||
}
|
||||
async function notifyBadge(p) { const ok = await postBadge(p); if (ok) store.update('badges-posted', {}, all => { const k = String(p.me.memberId); all[k] = Array.from(new Set([...(all[k] || []), p.id])); return all; }); return ok; }
|
||||
async function postBadge(p) {
|
||||
{ const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} <b>PolHunter</b> \u00b7 <b>' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + '</b> unlocked <b>' + b.name + '</b>: ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; if (!file) return telegram(cap); const ok = await telegramPhoto(file, cap); if (String(process.env.HUNT_TG_BADGE_GENERAL || 'on') === 'on') await telegramPhoto(file, cap, true); return ok; }
|
||||
return false;
|
||||
}
|
||||
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
|
||||
const dailyMsg = d => 'That is your ' + ['', 'one', 'two', 'three', 'four', 'five'][d.limit] + ' for today. Fresh missions at midnight Central.';
|
||||
const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.';
|
||||
function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; }
|
||||
function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; }
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------------------
|
||||
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webp': 'image/webp', '.mp4': 'video/mp4' };
|
||||
const SEC = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY' };
|
||||
function json(res, code, body, extra) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(JSON.stringify(body)); }
|
||||
function sendFile(res, file, extra) {
|
||||
// video streams with byte ranges (iOS Safari refuses to play without 206 support)
|
||||
if (path.extname(file) === '.mp4') return sendRange(res, file, extra);
|
||||
fs.readFile(file, (err, buf) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
||||
res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {}));
|
||||
res.end(buf);
|
||||
});
|
||||
}
|
||||
function sendRange(res, file, extra) {
|
||||
fs.stat(file, (err, st) => {
|
||||
if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
||||
const size = st.size; const range = res.req && res.req.headers.range;
|
||||
const head = Object.assign({ 'Content-Type': TYPES['.mp4'], 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' }, SEC, extra || {});
|
||||
let start = 0, end = size - 1, code = 200;
|
||||
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||
if (m) {
|
||||
start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2] || 0)); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : (m[1] ? size - 1 : size - 1);
|
||||
if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); }
|
||||
code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size;
|
||||
}
|
||||
head['Content-Length'] = end - start + 1;
|
||||
res.writeHead(code, head);
|
||||
if (res.req && res.req.method === 'HEAD') return res.end();
|
||||
fs.createReadStream(file, { start, end }).pipe(res);
|
||||
});
|
||||
}
|
||||
function readBody(req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { resolve({}); } }); }); }
|
||||
const hits = new Map();
|
||||
function limited(key, max, windowMs) { const now = Date.now(); const r = hits.get(key); if (!r || now > r.reset) { hits.set(key, { n: 1, reset: now + windowMs }); return false; } r.n++; return r.n > max; }
|
||||
const ip = req => String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
||||
|
||||
const CURTAIN_PAGE = `<!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 || (LIVE_AT && live())) return false; // the launch moment lifts the curtain
|
||||
if (u.searchParams.get('k') === CURTAIN) { u.searchParams.delete('k'); res.writeHead(302, { 'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure', Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' }); res.end(); return true; }
|
||||
const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || '');
|
||||
if (m && decodeURIComponent(m[1]) === CURTAIN) return false;
|
||||
res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' }); res.end(req.method === 'HEAD' ? '' : CURTAIN_PAGE); return true;
|
||||
}
|
||||
function admin(req) { const k = req.headers['x-admin-key'] || new URL(req.url, 'http://x').searchParams.get('key'); return !!(ADMIN_KEY && k && k.length === ADMIN_KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(ADMIN_KEY))); }
|
||||
const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, dwell: m.dwell || 30, reward: rewards.settings() });
|
||||
|
||||
// ---- the server ------------------------------------------------------------------------------
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const u = new URL(req.url, 'http://x'); const p = u.pathname;
|
||||
if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null });
|
||||
|
||||
// the embed talks to us from the mission sites: it must work through the curtain, and it must
|
||||
// answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first)
|
||||
if (p === '/api/embed/code') {
|
||||
const origin = String(req.headers.origin || '');
|
||||
const r = missions.codeForEmbed(u.searchParams.get('t'), origin);
|
||||
const cors = r.error === 'origin' ? {} : { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin' };
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204, Object.assign({ 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' }, cors)); return res.end(); }
|
||||
if (limited('embed:' + ip(req), 120, 60000)) return json(res, 429, { error: 'slow down' }, cors);
|
||||
return json(res, r.error ? 403 : 200, r, cors);
|
||||
}
|
||||
if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' });
|
||||
// badge cards and their share pages are posted around the web: they pass the curtain
|
||||
if (p === '/style.css') return sendFile(res, path.join(PUBLIC_DIR, 'style.css'), { 'Cache-Control': 'public, max-age=3600' }); // the share pages are styled for outsiders
|
||||
if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
|
||||
{ const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p);
|
||||
if (bm) {
|
||||
const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id);
|
||||
if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
||||
if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); }
|
||||
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId);
|
||||
const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.';
|
||||
const share = encodeURIComponent(title + ' ' + url);
|
||||
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">'
|
||||
+ '<meta property="og:type" content="website"><meta property="og:site_name" content="PolHunter"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1080"><meta property="og:image:height" content="1080">'
|
||||
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '"><link rel="stylesheet" href="/style.css?v=12"></head>'
|
||||
+ '<body><div class="wrap"><header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter</a><nav class="nav"><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn sm" href="' + ref + '">Hunt yours</a></nav></header>'
|
||||
+ '<div class="bp"><img src="' + img + '" alt="' + esc(b.name) + ' badge for ' + esc(m.who) + '"><h1>' + esc(m.who) + ' unlocked <em>' + esc(b.name) + '</em></h1><p>' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.</p>'
|
||||
+ '<a class="btn" href="' + ref + '">Hunt yours</a><div class="sharebtns" style="justify-content:center;margin-top:16px"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + share + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + share + '">WhatsApp</a></div>'
|
||||
+ '<p class="small" style="margin-top:26px">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</p></div></div></body></html>';
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html);
|
||||
}
|
||||
}
|
||||
// the coin on the code pill, fetched by mission-site visitors who hold no curtain pass
|
||||
if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
|
||||
|
||||
if (curtained(req, res, u)) return;
|
||||
// a member's share link: /?r=<their IAP username or id> becomes a 30-day cookie; the landing then
|
||||
// sends sign-ups to instantadpay.com/join/<ref>, so IAP's own last-touch sponsor rule applies
|
||||
// Served in place, not redirected (Marty, 2026-09-21): Facebook's crawler follows redirects and canonicalizes a
|
||||
// share to og:url, so a redirect to the bare home page lost the member's name on every share. og:url carries
|
||||
// the ref; the cookie rides on the same response.
|
||||
if (p === '/' && u.searchParams.get('r')) {
|
||||
const r = String(u.searchParams.get('r')).trim().slice(0, 40);
|
||||
const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r);
|
||||
let html = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8');
|
||||
if (ok) html = html.replace('<meta property="og:url" content="https://polhunter.com/">', '<meta property="og:url" content="https://polhunter.com/?r=' + encodeURIComponent(r) + '">');
|
||||
const h = Object.assign({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, SEC);
|
||||
if (ok) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure';
|
||||
res.writeHead(200, h); return res.end(html);
|
||||
}
|
||||
|
||||
// ---- sign-in by hand-off from InstantAdPay
|
||||
if (p === '/auth') {
|
||||
if (!signupsOpen()) { res.writeHead(503, { 'Content-Type': 'text/plain' }); return res.end('PolHunter is not accepting hunters yet.'); }
|
||||
const v = sso.verify(u.searchParams.get('t'));
|
||||
if (v.error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('<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);
|
||||
referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in
|
||||
res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end();
|
||||
}
|
||||
if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); }
|
||||
|
||||
// ---- public
|
||||
if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); }
|
||||
if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); }
|
||||
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) });
|
||||
if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' });
|
||||
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
|
||||
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
|
||||
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
|
||||
|
||||
// ---- hunter (session required)
|
||||
if (p.startsWith('/api/my/')) {
|
||||
const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' });
|
||||
if (p === '/api/my/board') {
|
||||
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
|
||||
const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet
|
||||
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(), daily: rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)),
|
||||
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
|
||||
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' },
|
||||
referral: referrals.state(me) });
|
||||
}
|
||||
if (p === '/api/my/start' && req.method === 'POST') {
|
||||
const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' });
|
||||
if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' });
|
||||
const t = missions.issue(me.memberId, m.id);
|
||||
// a mission URL may place the token itself with {token} (a Telegram Mini App takes it in
|
||||
// ?startapp=, not as our own query string); otherwise it is appended as ?ph=
|
||||
// the token rides in the hash: a server never sees it, so no redirect or canonical rewrite can lose it
|
||||
const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t;
|
||||
return json(res, 200, { ok: true, token: t.t, url, dwell: m.dwell || 30, expires: t.exp });
|
||||
}
|
||||
if (p === '/api/my/submit' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' });
|
||||
const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error });
|
||||
const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
{ const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); }
|
||||
{ const d = rewards.daily(me.memberId, rewards.doneToday(me.memberId, me.wallet)); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
|
||||
const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
|
||||
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
|
||||
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
|
||||
let refDrips = [];
|
||||
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
|
||||
for (const d of refDrips) if (d.ref.kind === 'bounty' || d.ref.kind === 'first-bonus') notify(d.ref.kind === 'bounty' ? 'referral' : 'firstbonus', { drip: d }).catch(() => {});
|
||||
// achievements unlocked by this find: told to the board, posted to Telegram (gated)
|
||||
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
|
||||
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
|
||||
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' });
|
||||
}
|
||||
return json(res, 404, { error: 'No such call.' });
|
||||
}
|
||||
|
||||
// ---- admin (key)
|
||||
if (p.startsWith('/api/admin/')) {
|
||||
if (!admin(req)) return json(res, 401, { error: 'Admin key required.' });
|
||||
if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } });
|
||||
if (p === '/api/admin/mission' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' });
|
||||
let host = ''; try { host = new URL(String(b.url)).hostname.replace(/^www\./, ''); } catch (e) { return json(res, 400, { error: 'url must be a full https URL' }); }
|
||||
// the origin the embed calls from can differ from the link (a t.me launch link opens a Mini App on its own host)
|
||||
if (b.host) host = String(b.host).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '');
|
||||
missions.save({ id, site: String(b.site || host).slice(0, 60), host, name: String(b.name || '').slice(0, 80), brief: String(b.brief || '').slice(0, 400), url: String(b.url), dwell: Math.max(5, Number(b.dwell) || 45), slots: Math.max(1, Number(b.slots) || 1), budget: Math.max(0, Number(b.budget) || 0), active: b.active !== false });
|
||||
return json(res, 200, { ok: true, missions: missions.list() });
|
||||
}
|
||||
if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); }
|
||||
if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
|
||||
// award a week by hand (its Monday key); already-awarded weeks are skipped
|
||||
if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); }
|
||||
if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); }
|
||||
if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter
|
||||
const m = missions.get(String(u.searchParams.get('id') || '')); if (!m) return json(res, 404, { error: 'no such mission' });
|
||||
const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t, token: t.t, dwell: m.dwell });
|
||||
}
|
||||
return json(res, 404, { error: 'No such admin call.' });
|
||||
}
|
||||
if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html'));
|
||||
if (p === '/app') { if (!sso.fromRequest(req)) { res.writeHead(302, { Location: '/?signin=1' }); return res.end(); } return sendFile(res, path.join(PUBLIC_DIR, 'app.html')); }
|
||||
if (p.startsWith('/api/')) return json(res, 404, { error: 'No such call.' });
|
||||
|
||||
const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, '');
|
||||
const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe);
|
||||
if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); }
|
||||
return sendFile(res, file);
|
||||
} catch (e) { console.error(req.method, req.url, e.message); try { json(res, 500, { error: 'Internal server error' }); } catch (x) {} }
|
||||
});
|
||||
|
||||
// the faucet pays every two minutes; nothing outward leaves unless OUTBOUND=on (telegram checks)
|
||||
if (faucetOn) setInterval(() => faucet.tick(notify).catch(e => console.error('faucet', e.message)), 2 * 60000);
|
||||
// Every completed mission reaches Telegram (Marty, 2026-09-21): a find is posted when its drip is paid, and
|
||||
// this sweep posts any paid find that is not marked posted yet (the pre-live gate, Telegram down, a restart).
|
||||
// Only finds made after HUNT_POST_SINCE count, so rehearsal drips from before the doors opened stay quiet.
|
||||
const POST_SINCE = process.env.HUNT_POST_SINCE ? Date.parse(process.env.HUNT_POST_SINCE) : 0;
|
||||
let sweeping = false;
|
||||
async function postMissedFinds() {
|
||||
if (sweeping || !outbound()) return 0; sweeping = true; let n = 0;
|
||||
try { for (const p of rewards.unposted(POST_SINCE, 15)) { if (postingNow.has('find:' + p.id)) continue; if (await notify('paid', p)) n++; else break; } }
|
||||
catch (e) { console.error('find sweep', e.message); } finally { sweeping = false; }
|
||||
if (n) console.log('posted', n, 'missed find(s) to Telegram');
|
||||
await postMissedBadges().catch(e => console.error('badge sweep', e.message));
|
||||
return n;
|
||||
}
|
||||
// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart)
|
||||
async function postMissedBadges() {
|
||||
if (!outbound()) return 0;
|
||||
const since = POST_SINCE; const recs = store.read('payouts', []).filter(x => (x.at || 0) >= since && x.status !== 'failed');
|
||||
const seen = new Map(); for (const r of recs) seen.set(r.memberId, { memberId: r.memberId, username: r.username || null });
|
||||
const posted = store.read('badges-posted', {}); let n = 0;
|
||||
for (const me of seen.values()) {
|
||||
const have = new Set(posted[String(me.memberId)] || []);
|
||||
for (const b of social.badgesFor(me.memberId)) { if (have.has(b.id) || postingNow.has('badge:' + me.memberId + ':' + b.id)) continue; if (n >= 10) return n; if (await notify('badge', { me, id: b.id })) n++; else return n; }
|
||||
}
|
||||
if (n) console.log('posted', n, 'missed badge(s) to Telegram');
|
||||
return n;
|
||||
}
|
||||
setInterval(() => postMissedFinds().catch(() => {}), 2 * 60000); setTimeout(() => postMissedFinds().catch(() => {}), 20000);
|
||||
// weekly prizes: the week that just ended is awarded on the first tick after Sunday, Central
|
||||
setInterval(() => { try { social.awardDue(notify); } catch (e) { console.error('prizes', e.message); } }, 2 * 60000);
|
||||
|
||||
server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'} — sso: ${sso.enabled() ? 'on' : 'off'} — faucet: ${faucetOn ? faucet.address() + ' chain ' + (process.env.HUNT_CHAIN_ID || '?') : 'off'}`));
|
||||
|
||||
Reference in New Issue
Block a user