PolHunter v0.2: the hunt engine
Hand-off sign-in from InstantAdPay (lib/sso.js: HMAC token, five minutes, single use; no sign-up, no mailer), missions with per-member per-visit proof codes (lib/missions.js: the embed is answered only from the mission's own origin, only after the dwell; hiding place rotates by day and member), weighted-low rewards with a daily cap that queues rather than refuses (lib/rewards.js), the faucet sender on ethers with a low-balance alert (lib/faucet.js), the one-line embed for the sites (public/embed.js), the hunter board, the public ledger, an admin page, and the site's own look. Telegram is behind the OUTBOUND gate like everything else. 13-check end-to-end test in test/run.js. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// Missions, tokens and proof codes.
|
||||
//
|
||||
// A mission: { id, site, host, name, brief, url, dwell, reward: {min, max}, budget, active, created }
|
||||
// host the origin the embed must call from (e.g. instantadpay.com); the proof only ever
|
||||
// answers a request whose Origin header is that host
|
||||
// brief what the hunter is told ("Open the ledger and find your code next to the newest payout")
|
||||
// url where the mission link sends them; the token is appended as ?ph=<token>
|
||||
// dwell seconds on the site before the code will render
|
||||
// budget completions this mission will still pay (0 = unlimited)
|
||||
//
|
||||
// A token is issued when a hunter starts a mission: { t, memberId, missionId, iat, exp }. The
|
||||
// code they must type is derived from the token with the server secret, so it is unique to that
|
||||
// member and that sitting, worthless to anyone else, and different tomorrow. The hiding place
|
||||
// (which page / which slot the embed renders in) is also derived, from the day and the member.
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
const store = require('./store');
|
||||
|
||||
const SECRET = String(process.env.HUNT_CODE_SECRET || process.env.HUNT_SSO_SECRET || '').trim();
|
||||
const TOKEN_TTL = 20 * 60000;
|
||||
const h = s => crypto.createHmac('sha256', SECRET).update(s).digest();
|
||||
const ctDay = t => new Date(t === undefined ? Date.now() : t).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
|
||||
|
||||
function list() { return store.read('missions', []); }
|
||||
function get(id) { return list().find(m => m.id === id) || null; }
|
||||
function save(m) {
|
||||
return store.update('missions', [], all => {
|
||||
const i = all.findIndex(x => x.id === m.id);
|
||||
if (i >= 0) all[i] = Object.assign(all[i], m); else all.push(Object.assign({ created: Date.now(), active: true }, m));
|
||||
return all;
|
||||
});
|
||||
}
|
||||
function remove(id) { return store.update('missions', [], all => all.filter(m => m.id !== id)); }
|
||||
|
||||
// each hunter sees the active missions in their own order, so two people comparing notes are
|
||||
// not looking at the same list; the order is stable for a member within a day
|
||||
function forMember(memberId) {
|
||||
const day = ctDay();
|
||||
return list().filter(m => m.active).map(m => ({ m, k: h(day + ':' + memberId + ':' + m.id).readUInt32BE(0) }))
|
||||
.sort((a, b) => a.k - b.k).map(x => x.m);
|
||||
}
|
||||
|
||||
// ---- tokens ------------------------------------------------------------------------------
|
||||
function issue(memberId, missionId) {
|
||||
const t = crypto.randomBytes(16).toString('hex');
|
||||
const rec = { t, memberId: Number(memberId), missionId, iat: Date.now(), exp: Date.now() + TOKEN_TTL };
|
||||
store.update('tokens', {}, all => { for (const k of Object.keys(all)) if (all[k].exp < Date.now() - 3600000) delete all[k]; all[t] = rec; return all; });
|
||||
return rec;
|
||||
}
|
||||
function token(t) { const rec = store.read('tokens', {})[String(t || '')]; return rec && rec.exp > Date.now() ? rec : null; }
|
||||
|
||||
// the proof: six characters from HMAC(secret, token); shown by the embed, typed by the hunter
|
||||
function codeFor(rec) { return h('code:' + rec.t + ':' + rec.memberId + ':' + rec.missionId).toString('hex').slice(0, 6).toUpperCase(); }
|
||||
// the hiding place: which slot on the page the embed renders in, by day and member
|
||||
function slotFor(rec, slots) { const n = Math.max(1, Number(slots) || 1); return h('slot:' + ctDay() + ':' + rec.memberId + ':' + rec.missionId).readUInt32BE(0) % n; }
|
||||
|
||||
// The embed asks for the code. Answered only when: the token is live, the request's Origin is the
|
||||
// mission's host, and the dwell has passed since the token was issued. Anything else is silence.
|
||||
function codeForEmbed(t, origin) {
|
||||
const rec = token(t); if (!rec) return { error: 'expired' };
|
||||
const m = get(rec.missionId); if (!m || !m.active) return { error: 'gone' };
|
||||
const host = String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '').toLowerCase(); // Origin carries a port; the mission host never does
|
||||
const allowed = [m.host, 'www.' + m.host].map(x => x.toLowerCase());
|
||||
if (!allowed.includes(host)) return { error: 'origin' };
|
||||
const waited = (Date.now() - rec.iat) / 1000;
|
||||
if (waited < (m.dwell || 30)) return { wait: Math.ceil((m.dwell || 30) - waited) };
|
||||
return { code: codeFor(rec), slot: slotFor(rec, m.slots || 1) };
|
||||
}
|
||||
|
||||
// The hunter submits what they found.
|
||||
function check(t, memberId, typed) {
|
||||
const rec = token(t); if (!rec) return { error: 'That mission timed out. Start it again from your board.' };
|
||||
if (rec.memberId !== Number(memberId)) return { error: 'That mission belongs to a different account.' };
|
||||
const want = codeFor(rec);
|
||||
const got = String(typed || '').trim().toUpperCase();
|
||||
if (got !== want) return { error: 'That is not the code. It is on the site, and it is yours alone.' };
|
||||
return { ok: true, rec };
|
||||
}
|
||||
|
||||
module.exports = { list, get, save, remove, forMember, issue, token, codeForEmbed, check, ctDay };
|
||||
Reference in New Issue
Block a user