Skip to content

Commit 0772da3

Browse files
Merge pull request #668 from Gbangbolaoluwagbemiga/feat/655-embed-widget-partner-sdk
feat(embed): embeddable campaign widget + partner distribution SDK
2 parents 4a3e873 + bbccf7d commit 0772da3

9 files changed

Lines changed: 1081 additions & 29 deletions

File tree

backend/src/index.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,23 @@ export async function createApp(options = {}) {
592592

593593
const siteOrigin =
594594
process.env.SITE_ORIGIN ?? allowedOrigins.find((origin) => origin !== '*') ?? '';
595-
app.get('/embed/campaign/:id', createEmbedRoute(campaignRepository, siteOrigin));
595+
596+
// Embed endpoints use a tighter per-IP rate limit (30 req/min) to guard
597+
// against scraping while still allowing reasonable widget traffic.
598+
const embedRateLimiter = createRateLimiter({
599+
windowMs: rateLimitWindowMs,
600+
maxRequests: Math.min(30, rateLimitMaxRequests),
601+
timeProvider: /** @type {any} */ (options.rateLimit)?.timeProvider,
602+
store: rateLimitStore,
603+
});
604+
605+
app.get(
606+
'/embed/campaign/:id',
607+
embedRateLimiter,
608+
createEmbedRoute(campaignRepository, siteOrigin, {
609+
embedSecret: process.env.EMBED_ATTRIBUTION_SECRET,
610+
}),
611+
);
596612

597613
app.get('/health/rpc', async (_req, res) => {
598614
const rpcUrl = rpcPool.getHealthyRpcUrl();

backend/src/routes/embed.js

Lines changed: 168 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,43 @@
44
* Returns a minimal, iframe-safe HTML page showing a campaign card with
55
* a "Register on Trivela" CTA that opens the main site in a new tab.
66
* No navigation header, no footer, no external script dependencies.
7+
*
8+
* Query parameters:
9+
* ?partner=<id> Partner/referrer ID (alphanumeric + _-, max 64 chars).
10+
* Carried through to the registration URL for on-chain
11+
* referral attribution. Combined with a short-lived HMAC
12+
* attribution token to prevent spoofing.
13+
* ?org=<name> Org/partner display name for "Powered by" branding.
14+
* ?color=<hex> CSS hex colour (#RRGGBB) overriding the default CTA button.
15+
* ?theme=light Light theme (default: dark).
716
*/
817

18+
import { createHmac } from 'node:crypto';
19+
920
const MAX_DESC_LEN = 160;
1021

22+
// Allowed partner ID chars: letters, digits, hyphen, underscore — max 64.
23+
const PARTNER_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
24+
// Strict hex colour: #RGB or #RRGGBB
25+
const COLOR_PATTERN = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
26+
27+
/**
28+
* Sanitise a single-line user-supplied string for safe HTML interpolation.
29+
* @param {string | null | undefined} raw
30+
* @param {number} maxLen
31+
* @returns {string}
32+
*/
33+
function sanitiseText(raw, maxLen) {
34+
if (!raw) return '';
35+
return String(raw)
36+
.slice(0, maxLen)
37+
.replace(/&/g, '&amp;')
38+
.replace(/</g, '&lt;')
39+
.replace(/>/g, '&gt;')
40+
.replace(/"/g, '&quot;')
41+
.replace(/'/g, '&#39;');
42+
}
43+
1144
function truncate(text, maxLen) {
1245
if (!text) return '';
1346
return text.length <= maxLen ? text : `${text.slice(0, maxLen - 1)}…`;
@@ -26,7 +59,45 @@ function remainingSpots(campaign) {
2659
return Math.max(0, max - current);
2760
}
2861

29-
export function createEmbedRoute(campaignRepository, siteOrigin) {
62+
/**
63+
* Generate a short-lived HMAC attribution token for the given partner + campaign.
64+
* Token is bound to a 5-minute time bucket so replays beyond that window fail.
65+
*
66+
* @param {string} campaignId
67+
* @param {string} partnerId
68+
* @param {string} secret EMBED_ATTRIBUTION_SECRET env value
69+
* @returns {string} hex-encoded first 16 bytes of HMAC-SHA256
70+
*/
71+
function signAttributionToken(campaignId, partnerId, secret) {
72+
const bucket = Math.floor(Date.now() / 300_000); // 5-min rolling window
73+
const payload = `${campaignId}:${partnerId}:${bucket}`;
74+
return createHmac('sha256', secret).update(payload).digest('hex').slice(0, 32);
75+
}
76+
77+
/**
78+
* Verify an attribution token. Accepts current bucket and the immediately
79+
* preceding bucket to tolerate clock skew at window boundaries.
80+
*
81+
* @param {string} campaignId
82+
* @param {string} partnerId
83+
* @param {string} token
84+
* @param {string} secret
85+
* @returns {boolean}
86+
*/
87+
export function verifyAttributionToken(campaignId, partnerId, token, secret) {
88+
if (!token || !secret) return false;
89+
const now = Math.floor(Date.now() / 300_000);
90+
for (const bucket of [now, now - 1]) {
91+
const payload = `${campaignId}:${partnerId}:${bucket}`;
92+
const expected = createHmac('sha256', secret).update(payload).digest('hex').slice(0, 32);
93+
if (expected === token) return true;
94+
}
95+
return false;
96+
}
97+
98+
export function createEmbedRoute(campaignRepository, siteOrigin, { embedSecret = '' } = {}) {
99+
const secret = embedSecret || process.env.EMBED_ATTRIBUTION_SECRET || 'trivela-embed-dev-secret';
100+
30101
/**
31102
* @param {import('express').Request} req
32103
* @param {import('express').Response} res
@@ -42,39 +113,83 @@ export function createEmbedRoute(campaignRepository, siteOrigin) {
42113
return;
43114
}
44115

116+
// ── Query param validation ────────────────────────────────────────────────
117+
const rawPartner = typeof req.query.partner === 'string' ? req.query.partner.trim() : '';
118+
const partner = PARTNER_PATTERN.test(rawPartner) ? rawPartner : '';
119+
120+
const rawColor = typeof req.query.color === 'string' ? req.query.color.trim() : '';
121+
const customColor = COLOR_PATTERN.test(rawColor) ? rawColor : '';
122+
123+
const isDark = req.query.theme !== 'light';
124+
125+
// Sanitise org name for safe HTML interpolation.
126+
const orgName = sanitiseText(req.query.org, 48);
127+
128+
// ── Attribution token ─────────────────────────────────────────────────────
129+
const atToken = partner ? signAttributionToken(String(campaign.id), partner, secret) : '';
130+
131+
// ── Build registration URL ────────────────────────────────────────────────
132+
const registerUrl = new URL(`${siteOrigin}/campaign/${campaign.id}`);
133+
if (partner) {
134+
registerUrl.searchParams.set('ref', partner);
135+
registerUrl.searchParams.set('at', atToken);
136+
}
137+
138+
// ── Derived campaign values ───────────────────────────────────────────────
45139
const status = statusLabel(campaign);
46140
const spots = remainingSpots(campaign);
47141
const participantCount = campaign.participantCount ?? campaign.registrations ?? 0;
48-
const desc = truncate(campaign.description, MAX_DESC_LEN);
49-
const registerUrl = `${siteOrigin}/campaign/${campaign.id}`;
142+
const desc = sanitiseText(truncate(campaign.description, MAX_DESC_LEN), MAX_DESC_LEN + 10);
143+
const name = sanitiseText(campaign.name, 120);
50144
const isActive = status === 'Active';
51145

52146
const statusColor = isActive ? '#22c55e' : '#94a3b8';
53-
const btnBg = isActive ? '#3b82f6' : '#64748b';
147+
const defaultBtn = isActive ? '#3b82f6' : '#64748b';
148+
const btnBg = customColor || defaultBtn;
149+
150+
// ── Theme colours ─────────────────────────────────────────────────────────
151+
const bg = isDark ? '#0f172a' : '#f8fafc';
152+
const cardBg = isDark ? '#1e293b' : '#ffffff';
153+
const cardBorder = isDark ? '#334155' : '#e2e8f0';
154+
const textPrimary = isDark ? '#f1f5f9' : '#0f172a';
155+
const textMuted = isDark ? '#94a3b8' : '#64748b';
156+
const textMeta = isDark ? '#64748b' : '#94a3b8';
157+
const poweredColor = isDark ? '#475569' : '#94a3b8';
158+
const poweredLink = isDark ? '#64748b' : '#475569';
159+
const eyebrowColor = isDark ? '#64748b' : '#94a3b8';
160+
161+
// Campaign ID for postMessage event payloads (safe string).
162+
const safeCampaignId = sanitiseText(String(campaign.id), 64);
163+
const safePartner = sanitiseText(partner, 64);
164+
const poweredLabel = orgName ? `Powered by ${orgName} via Trivela` : 'Powered by Trivela';
54165

55166
res.setHeader('Content-Type', 'text/html; charset=utf-8');
56167
res.setHeader('X-Embed-Route', 'true');
168+
// Prevent the embed from navigating the top-level frame (belt-and-suspenders
169+
// alongside the `sandbox` attribute set by the partner SDK).
170+
res.setHeader('X-Content-Type-Options', 'nosniff');
171+
57172
res.send(`<!DOCTYPE html>
58173
<html lang="en">
59174
<head>
60175
<meta charset="UTF-8" />
61176
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
62-
<title>${campaign.name} — Trivela</title>
177+
<title>${name} — Trivela</title>
63178
<style>
64179
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
65180
body {
66181
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
67-
background: #0f172a;
68-
color: #e2e8f0;
182+
background: ${bg};
183+
color: ${textPrimary};
69184
min-height: 100vh;
70185
display: flex;
71186
align-items: flex-start;
72187
justify-content: center;
73188
padding: 16px;
74189
}
75190
.card {
76-
background: #1e293b;
77-
border: 1px solid #334155;
191+
background: ${cardBg};
192+
border: 1px solid ${cardBorder};
78193
border-radius: 12px;
79194
padding: 20px 24px;
80195
width: 100%;
@@ -84,19 +199,19 @@ export function createEmbedRoute(campaignRepository, siteOrigin) {
84199
font-size: 0.7rem;
85200
letter-spacing: 0.08em;
86201
text-transform: uppercase;
87-
color: #64748b;
202+
color: ${eyebrowColor};
88203
margin-bottom: 6px;
89204
}
90205
.name {
91206
font-size: 1.1rem;
92207
font-weight: 700;
93-
color: #f1f5f9;
208+
color: ${textPrimary};
94209
margin-bottom: 8px;
95210
line-height: 1.3;
96211
}
97212
.desc {
98213
font-size: 0.85rem;
99-
color: #94a3b8;
214+
color: ${textMuted};
100215
line-height: 1.5;
101216
margin-bottom: 16px;
102217
}
@@ -106,8 +221,8 @@ export function createEmbedRoute(campaignRepository, siteOrigin) {
106221
gap: 12px;
107222
margin-bottom: 18px;
108223
}
109-
.meta-item { font-size: 0.78rem; color: #64748b; }
110-
.meta-item strong { color: #cbd5e1; }
224+
.meta-item { font-size: 0.78rem; color: ${textMeta}; }
225+
.meta-item strong { color: ${textPrimary}; }
111226
.status-dot {
112227
display: inline-block;
113228
width: 7px; height: 7px;
@@ -128,35 +243,69 @@ export function createEmbedRoute(campaignRepository, siteOrigin) {
128243
text-decoration: none;
129244
border-radius: 8px;
130245
transition: opacity 0.15s;
246+
cursor: pointer;
131247
}
132248
.btn:hover { opacity: 0.88; }
133249
.btn:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
134250
.powered {
135251
text-align: center;
136252
margin-top: 12px;
137253
font-size: 0.68rem;
138-
color: #475569;
254+
color: ${poweredColor};
139255
}
140-
.powered a { color: #64748b; text-decoration: none; }
256+
.powered a { color: ${poweredLink}; text-decoration: none; }
141257
.powered a:hover { text-decoration: underline; }
142258
</style>
143259
</head>
144260
<body>
145261
<div class="card">
146262
<p class="eyebrow">Trivela Campaign</p>
147-
<h1 class="name">${campaign.name}</h1>
263+
<h1 class="name">${name}</h1>
148264
${desc ? `<p class="desc">${desc}</p>` : ''}
149265
<div class="meta">
150266
<span class="meta-item"><span class="status-dot"></span><strong>${status}</strong></span>
151267
<span class="meta-item">Participants: <strong>${participantCount}</strong></span>
152268
${spots !== null ? `<span class="meta-item">Spots left: <strong>${spots}</strong></span>` : ''}
153269
${campaign.rewardPerAction ? `<span class="meta-item">Reward: <strong>${campaign.rewardPerAction} pts</strong></span>` : ''}
154270
</div>
155-
<a href="${registerUrl}" target="_blank" rel="noopener noreferrer" class="btn">
271+
<a
272+
href="${registerUrl.toString()}"
273+
target="_blank"
274+
rel="noopener noreferrer"
275+
class="btn"
276+
data-trivela-register="true"
277+
>
156278
Register on Trivela ↗
157279
</a>
158-
<p class="powered">Powered by <a href="${siteOrigin}" target="_blank" rel="noopener noreferrer">Trivela</a></p>
280+
<p class="powered">
281+
${poweredLabel.replace('Trivela', `<a href="${siteOrigin}" target="_blank" rel="noopener noreferrer">Trivela</a>`)}
282+
</p>
159283
</div>
284+
<script>
285+
(function () {
286+
var campaignId = ${JSON.stringify(safeCampaignId)};
287+
var partner = ${JSON.stringify(safePartner)};
288+
var origin = ${JSON.stringify(siteOrigin || '*')};
289+
290+
function post(type, payload) {
291+
try {
292+
var msg = { source: 'trivela-widget', type: type, payload: payload };
293+
window.parent.postMessage(msg, origin || '*');
294+
} catch (_) {}
295+
}
296+
297+
// Signal that the widget has loaded successfully.
298+
post('trivela:ready', { campaignId: campaignId, partner: partner });
299+
300+
// Fire trivela:register_click when the CTA is activated.
301+
var btn = document.querySelector('[data-trivela-register]');
302+
if (btn) {
303+
btn.addEventListener('click', function () {
304+
post('trivela:register_click', { campaignId: campaignId, partner: partner });
305+
});
306+
}
307+
})();
308+
</script>
160309
</body>
161310
</html>`);
162311
};

0 commit comments

Comments
 (0)