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:
+68
@@ -0,0 +1,68 @@
|
||||
// Sign-in by hand-off from InstantAdPay. There is no sign-up on PolHunter and never a mailer:
|
||||
// a member clicks PolHunter inside their IAP dashboard, IAP signs a short-lived token with the
|
||||
// shared secret, and this module turns it into a PolHunter session.
|
||||
//
|
||||
// Token: base64url(JSON payload) + '.' + base64url(HMAC-SHA256(secret, payload)).
|
||||
// Payload: { memberId, email, wallet, username, iat, exp, nonce }. Single use, five minutes.
|
||||
// Sessions: random id -> { memberId, email, wallet, username, since }, on the volume so a
|
||||
// restart keeps people signed in. Cookie is HttpOnly, SameSite=Lax, Secure.
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
const store = require('./store');
|
||||
|
||||
const SECRET = String(process.env.HUNT_SSO_SECRET || '').trim();
|
||||
const SESSION_TTL = 30 * 86400000;
|
||||
const used = new Map(); // nonce -> exp, single-use guard (in memory is fine: tokens live five minutes)
|
||||
|
||||
const b64u = b => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
|
||||
const unb64u = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
const sign = payload => b64u(crypto.createHmac('sha256', SECRET).update(payload).digest());
|
||||
|
||||
function enabled() { return SECRET.length >= 32; }
|
||||
|
||||
// what IAP does to mint one (kept here so both sides read the same definition)
|
||||
function mint(claims) {
|
||||
const payload = JSON.stringify(Object.assign({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex') }, claims));
|
||||
return b64u(payload) + '.' + sign(payload);
|
||||
}
|
||||
|
||||
function verify(token) {
|
||||
if (!enabled()) return { error: 'Sign-in from InstantAdPay is not configured on this server.' };
|
||||
const [p, sig] = String(token || '').split('.');
|
||||
if (!p || !sig) return { error: 'That sign-in link is not valid.' };
|
||||
let payload; try { payload = unb64u(p).toString('utf8'); } catch (e) { return { error: 'That sign-in link is not valid.' }; }
|
||||
const want = sign(payload);
|
||||
if (want.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig))) return { error: 'That sign-in link is not valid.' };
|
||||
let c; try { c = JSON.parse(payload); } catch (e) { return { error: 'That sign-in link is not valid.' }; }
|
||||
if (!c.exp || Date.now() > c.exp) return { error: 'That sign-in link has expired. Open PolHunter from your InstantAdPay dashboard again.' };
|
||||
if (!c.memberId || !c.email) return { error: 'That sign-in link is missing your account.' };
|
||||
for (const [n, exp] of used) if (exp < Date.now()) used.delete(n);
|
||||
if (used.has(c.nonce)) return { error: 'That sign-in link was already used.' };
|
||||
used.set(c.nonce, c.exp);
|
||||
return { ok: true, claims: c };
|
||||
}
|
||||
|
||||
function startSession(claims) {
|
||||
const id = crypto.randomBytes(24).toString('hex');
|
||||
store.update('sessions', {}, s => {
|
||||
const now = Date.now();
|
||||
for (const k of Object.keys(s)) if ((s[k].since || 0) + SESSION_TTL < now) delete s[k];
|
||||
s[id] = { memberId: Number(claims.memberId), email: String(claims.email).toLowerCase(), wallet: claims.wallet ? String(claims.wallet).toLowerCase() : null, username: claims.username || null, since: now };
|
||||
return s;
|
||||
});
|
||||
return id;
|
||||
}
|
||||
function cookie(id) { return 'ph.sid=' + id + '; Path=/; Max-Age=' + Math.floor(SESSION_TTL / 1000) + '; HttpOnly; SameSite=Lax; Secure'; }
|
||||
function clearCookie() { return 'ph.sid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure'; }
|
||||
function fromRequest(req) {
|
||||
const m = /(?:^|;\s*)ph\.sid=([a-f0-9]{48})/.exec(req.headers.cookie || '');
|
||||
if (!m) return null;
|
||||
const s = store.read('sessions', {})[m[1]];
|
||||
if (!s || (s.since || 0) + SESSION_TTL < Date.now()) return null;
|
||||
return Object.assign({ sid: m[1] }, s);
|
||||
}
|
||||
function endSession(sid) { store.update('sessions', {}, s => { delete s[sid]; return s; }); }
|
||||
// a member's wallet can change on IAP; the next hand-off refreshes it here
|
||||
function refresh(sid, claims) { store.update('sessions', {}, s => { if (s[sid]) { s[sid].wallet = claims.wallet ? String(claims.wallet).toLowerCase() : null; s[sid].username = claims.username || s[sid].username; } return s; }); }
|
||||
|
||||
module.exports = { enabled, mint, verify, startSession, cookie, clearCookie, fromRequest, endSession, refresh };
|
||||
Reference in New Issue
Block a user