View-fraud protection + real ad placement surfaces

Earning views now run on single-use server-issued tokens: the view only
counts when the full dwell elapses on the server clock; instant, forged,
replayed, and stale posts are all rejected, and the client countdown
pauses whenever the tab loses visibility or focus. New placements: login
ads on the sign-in screen (the per-day format's real home), a banner slot
in the back-office Overview, a text slot in the member sidebar, and a
member-ads banner on the homepage. Assets v=20260905g.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 06:15:27 -05:00
parent 7331eac244
commit 28ed925443
7 changed files with 79 additions and 27 deletions
+1
View File
@@ -133,6 +133,7 @@
recalc();
}
IAP.adSlot('banner', 'adSlotHome');
loadLadder();
loadStats();
loadTicker();
+24 -9
View File
@@ -286,33 +286,42 @@
} catch (e) { return null; }
}
async function earnShowAd() {
const st = await earnRefresh();
if (!st || st.views >= st.target) return;
const type = earnState.types[earnState.i++ % earnState.types.length];
let ad = null;
try { ad = (await (await fetch('/api/ads/slot?type=' + type)).json()).ad; } catch (e) {}
let r = null;
try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {}
const box = $('earnAdBox');
if (!ad) {
box.innerHTML = '<span class="muted small">No member ads are live in rotation right now. '
+ 'Views resume the moment a campaign is running.</span>';
if (!r || !r.ad) {
await earnRefresh();
box.innerHTML = '<span class="muted small">' + (r && r.status && r.status.views >= r.status.target
? 'Set complete for today.'
: 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + '</span>';
return;
}
const ad = r.ad;
box.innerHTML = ad.imageUrl
? '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + ad.imageUrl + '" alt="member ad" style="max-height:200px"></a>'
: '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow" style="font-size:17px"><b>' + ad.title + '</b>' + (ad.body ? '<br><span class="muted">' + ad.body + '</span>' : '') + '</a>';
const btn = $('earnNextBtn');
btn.hidden = false;
btn.disabled = true;
let left = st.dwell;
// the countdown only runs while this tab is visible AND focused; the
// server separately enforces the full dwell on its own clock
let left = r.status.dwell;
btn.textContent = 'Next ad (' + left + 's)';
clearInterval(earnState.timer);
earnState.timer = setInterval(async () => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) {
btn.textContent = 'Paused: stay on this tab (' + left + 's)';
return;
}
left -= 1;
if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; }
clearInterval(earnState.timer);
btn.textContent = 'Next ad';
const v = await (await fetch('/api/my/adview', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: r.token }) })).json();
if (v.error) { IAP.status(v.error, 'bad'); btn.disabled = false; return; }
btn.disabled = false;
await fetch('/api/my/adview', { method: 'POST' }); // dwell served: count it
await earnRefresh();
}, 1000);
}
@@ -406,5 +415,11 @@
await render();
});
// ad surfaces: login ads greet the sign-in screen; members see live
// banner + text placements inside the back office (they ARE the audience)
IAP.adSlot('login', 'adSlotLogin');
IAP.adSlot('banner', 'adSlotOverview');
IAP.adSlot('text', 'adSlotSide');
render();
})();
+4 -4
View File
@@ -5,7 +5,7 @@
<title>The contract | InstantAdPay</title>
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
</head>
<body>
<div class="wrap">
@@ -129,8 +129,8 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260905f"></script>
<script src="/assets/contract.js?v=20260905f"></script>
<script src="/assets/chat.js?v=20260905f"></script>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/contract.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
</body>
</html>
+11 -5
View File
@@ -5,7 +5,7 @@
<title>InstantAdPay: advertise and earn, locked in code</title>
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
</head>
<body>
@@ -344,6 +344,12 @@
</div>
</section>
<section>
<div class="wrap">
<div class="card" id="adSlotHome" hidden style="text-align:center"></div>
</div>
</section>
<section>
<div class="wrap">
<div class="sectionhead">
@@ -399,9 +405,9 @@
</div>
</section>
<script src="/assets/common.js?v=20260905f"></script>
<script src="/assets/wallet.js?v=20260905f"></script>
<script src="/assets/home.js?v=20260905f"></script>
<script src="/assets/chat.js?v=20260905f"></script>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/wallet.js?v=20260905g"></script>
<script src="/assets/home.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
</body>
</html>
+4 -4
View File
@@ -5,7 +5,7 @@
<title>Live ledger | InstantAdPay</title>
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
</head>
<body>
<div class="wrap">
@@ -25,8 +25,8 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260905f"></script>
<script src="/assets/ledger.js?v=20260905f"></script>
<script src="/assets/chat.js?v=20260905f"></script>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/ledger.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
</body>
</html>
+8 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<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="/assets/site.css?v=20260905f">
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
</head>
<body class="bo-body">
@@ -44,6 +44,7 @@
<p class="small muted">Crypto-native? You can also <a href="#" id="walletSigninLink">sign in with just your wallet</a>.
One free signature, no email needed.</p>
<p class="small muted"><a href="/">← Back to the site</a></p>
<div class="card" id="adSlotLogin" hidden></div>
</div>
</div>
@@ -60,6 +61,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="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>
</nav>
<div class="bo-links" id="adSlotSide" hidden style="border-top:1px solid var(--line)"></div>
<div class="bo-links">
<span class="bo-cap">Site</span>
<a href="/">Ad packages</a>
@@ -116,6 +118,7 @@
<p id="qualLine" class="muted small">…</p></div>
</div>
</div>
<div class="card" id="adSlotOverview" hidden></div>
</div>
<div class="pane" id="pane-line" hidden>
@@ -250,9 +253,9 @@
</div>
</div>
<script src="/assets/common.js?v=20260905f"></script>
<script src="/assets/wallet.js?v=20260905f"></script>
<script src="/assets/my.js?v=20260905f"></script>
<script src="/assets/chat.js?v=20260905f"></script>
<script src="/assets/common.js?v=20260905g"></script>
<script src="/assets/wallet.js?v=20260905g"></script>
<script src="/assets/my.js?v=20260905g"></script>
<script src="/assets/chat.js?v=20260905g"></script>
</body>
</html>
+27
View File
@@ -7,6 +7,7 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const chain = require('./chain');
const auth = require('./auth');
@@ -34,6 +35,8 @@ function chatLimited(ip) {
}
// magic-code sign-in: emailLower -> {code, exp, tries}
const emailCodes = new Map();
// earn-view tokens: emailLower -> {token, ts} (one live token per member)
const earnTokens = new Map();
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => pushFeed(ev) });
@@ -351,9 +354,33 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await ads.viewStatus(s.email));
}
// fraud-guarded view flow: the server issues a single-use token when it
// serves the ad, and only counts the view if the dwell elapsed on the
// SERVER clock. Client-side focus tracking pauses the countdown; this is
// the floor a script cannot cheat past.
if (p === '/api/my/earnview' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const status = await ads.viewStatus(s.email);
if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status });
const type = String(u.searchParams.get('type') || 'banner');
const ad = await ads.serve(type === 'text' ? 'text' : 'banner');
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
earnTokens.set(s.email, { token, ts: Date.now() });
return json(res, 200, { ad, token, status });
}
if (p === '/api/my/adview' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const t = earnTokens.get(s.email);
const dwellMs = (ads.rates().viewDwellSeconds || 5) * 1000;
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That view did not check out. Load the next ad and let it finish.' });
const age = Date.now() - t.ts;
if (age < dwellMs - 400) return json(res, 400, { error: 'Watch the full ad first.' });
if (age > 5 * 60 * 1000) { earnTokens.delete(s.email); return json(res, 400, { error: 'That ad went stale. Load a fresh one.' }); }
earnTokens.delete(s.email); // single use
return json(res, 200, await ads.recordView(s.email));
}
if (p === '/api/my/claim' && req.method === 'POST') {