PolHunter hand-off, nav entry, and the ledger embed

/api/my/polhunter signs a member across to polhunter.com with a five-minute single-use HMAC
token (memberId, email, wallet, username); PolHunter has no sign-up and no mailer, this is the
only way in. A PolHunter entry in the dashboard nav. The PolHunter embed on the public ledger
with one slot beside the payout feed; CSP allows polhunter.com.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 14:20:16 -05:00
parent 0d2621defd
commit af710f68b8
4 changed files with 22 additions and 2 deletions
+3
View File
@@ -269,6 +269,9 @@ textarea{resize:vertical;font:inherit}
.bo-menu button{display:flex;align-items:center;gap:11px;background:transparent;border:0;color:var(--muted); .bo-menu button{display:flex;align-items:center;gap:11px;background:transparent;border:0;color:var(--muted);
font:600 14px var(--disp);padding:11px 12px;border-radius:11px;cursor:pointer;text-align:left; font:600 14px var(--disp);padding:11px 12px;border-radius:11px;cursor:pointer;text-align:left;
border-left:3px solid transparent} border-left:3px solid transparent}
.bo-menu a{display:flex;align-items:center;gap:11px;color:var(--muted);font:600 14px var(--disp);padding:11px 12px;border-radius:11px;text-align:left;border-left:3px solid transparent;text-decoration:none}
.bo-menu a:hover{color:var(--ink);background:rgba(67,232,195,.05)}
.bo-menu a svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
.bo-menu button svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto} .bo-menu button svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
.bo-menu button:hover{color:var(--ink);background:rgba(67,232,195,.05)} .bo-menu button:hover{color:var(--ink);background:rgba(67,232,195,.05)}
.bo-menu button.on{color:var(--mint);background:rgba(67,232,195,.09);border-left-color:var(--mint)} .bo-menu button.on{color:var(--mint);background:rgba(67,232,195,.09);border-left-color:var(--mint)}
+2
View File
@@ -30,6 +30,7 @@
</section> </section>
<div class="card" id="adSlotBanner" hidden></div> <div class="card" id="adSlotBanner" hidden></div>
<div class="card" style="padding:0"> <div class="card" style="padding:0">
<div data-ph-slot="0" style="margin:0 0 10px"></div>
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div> <div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
</div> </div>
<div class="card small" id="adSlotText" hidden></div> <div class="card small" id="adSlotText" hidden></div>
@@ -39,6 +40,7 @@
</div> </div>
<script src="/assets/common.js?v=20260916a"></script> <script src="/assets/common.js?v=20260916a"></script>
<script src="/assets/ledger.js?v=20260906m"></script> <script src="/assets/ledger.js?v=20260906m"></script>
<script src="https://polhunter.com/embed.js" data-slots="1" async></script>
<script src="/assets/chat.js?v=20260906m"></script> <script src="/assets/chat.js?v=20260906m"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -5,7 +5,7 @@
<title>Member area | InstantAdPay</title> <title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="icon" type="image/png" href="/logo-icon.png"> <link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260919a"> <link rel="stylesheet" href="/assets/site.css?v=20260919b">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -141,6 +141,7 @@
<button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button> <button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button>
<button data-pane="promo" type="button"><svg viewBox="0 0 24 24"><path d="M7 10s5-1 9-5v14c-4-4-9-5-9-5H5a2 2 0 0 1-2-2v0a2 2 0 0 1 2-2h2z"/><path d="M8 15l1 5h2l-1-5"/></svg>Promo tools</button> <button data-pane="promo" type="button"><svg viewBox="0 0 24 24"><path d="M7 10s5-1 9-5v14c-4-4-9-5-9-5H5a2 2 0 0 1-2-2v0a2 2 0 0 1 2-2h2z"/><path d="M8 15l1 5h2l-1-5"/></svg>Promo tools</button>
<button data-pane="training" type="button"><svg viewBox="0 0 24 24"><path d="M3 5l9-2 9 2v11l-9 2-9-2z"/><path d="M12 3v13"/><path d="M9 11l3 2 3-2"/></svg>Training</button> <button data-pane="training" type="button"><svg viewBox="0 0 24 24"><path d="M3 5l9-2 9 2v11l-9 2-9-2z"/><path d="M12 3v13"/><path d="M9 11l3 2 3-2"/></svg>Training</button>
<a href="/api/my/polhunter" id="polhunterNav" title="Missions that pay POL"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/><circle cx="12" cy="12" r="3"/></svg>PolHunter</a>
<button data-pane="wallet" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><circle cx="16.5" cy="14.5" r="1.4"/></svg>Wallet</button> <button data-pane="wallet" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><circle cx="16.5" cy="14.5" r="1.4"/></svg>Wallet</button>
<button data-pane="profile" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.6"/><path d="M4.5 20c1.6-3.6 4.2-5.2 7.5-5.2s5.9 1.6 7.5 5.2"/></svg>Profile</button> <button data-pane="profile" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.6"/><path d="M4.5 20c1.6-3.6 4.2-5.2 7.5-5.2s5.9 1.6 7.5 5.2"/></svg>Profile</button>
</nav> </nav>
+15 -1
View File
@@ -483,7 +483,7 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': '
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2', '.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2',
'.gif': 'image/gif', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8' }; '.gif': 'image/gif', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8' };
const CSP = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://www.networkadspace.com https://networkadspace.com 'sha256-NzvNrqk5jB9YZATwo5BF4JoRlJ02HsnFikbKXgEPdaQ='; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; media-src 'self' https: blob:; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.org https://*.reown.com wss://*.reown.com https://*.reown.org wss://*.reown.org https://*.web3modal.org https://*.drpc.org https://*.publicnode.com https://*.coinbase.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com; form-action 'self'; frame-src https: http:"; const CSP = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://www.networkadspace.com https://networkadspace.com https://polhunter.com 'sha256-NzvNrqk5jB9YZATwo5BF4JoRlJ02HsnFikbKXgEPdaQ='; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; media-src 'self' https: blob:; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.org https://*.reown.com wss://*.reown.com https://*.reown.org wss://*.reown.org https://*.web3modal.org https://*.drpc.org https://*.publicnode.com https://*.coinbase.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.com; form-action 'self'; frame-src https: http:";
function baseHeaders(extra) { function baseHeaders(extra) {
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {}); 'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
@@ -2100,6 +2100,20 @@ const server = http.createServer(async (req, res) => {
earnTokens.delete(s.email); // single use earnTokens.delete(s.email); // single use
return json(res, 200, await ads.recordView(s.email)); return json(res, 200, await ads.recordView(s.email));
} }
// -- PolHunter hand-off (2026-09-19): sign the member across; polhunter.com verifies with the shared secret
if (p === '/api/my/polhunter' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) { res.writeHead(302, { Location: '/my' }); return res.end(); }
const secret = String(process.env.HUNT_SSO_SECRET || '').trim(); const huntUrl = String(process.env.HUNT_URL || 'https://polhunter.com').replace(/\/+$/, '');
if (secret.length < 32) return json(res, 503, { error: 'PolHunter is not connected yet.' });
const acct = await accounts.byEmail(s.email);
const memberId = (acct && acct.memberId) || s.memberId || 0;
if (!memberId) { res.writeHead(302, { Location: '/my#wallet' }); return res.end(); }
const b64u = b => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
const payload = JSON.stringify({ iat: Date.now(), exp: Date.now() + 5 * 60000, nonce: crypto.randomBytes(12).toString('hex'), memberId: Number(memberId), email: s.email, wallet: (acct && acct.address) || s.address || null, username: (acct && acct.username) || null });
const tok = b64u(payload) + '.' + b64u(crypto.createHmac('sha256', secret).update(payload).digest());
res.writeHead(302, { Location: huntUrl + '/auth?t=' + tok, 'Cache-Control': 'no-store' }); return res.end();
}
if (p === '/api/my/claim' && req.method === 'POST') { if (p === '/api/my/claim' && req.method === 'POST') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });