523d57624e
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>
81 lines
4.7 KiB
JavaScript
81 lines
4.7 KiB
JavaScript
// 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 };
|