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+
920const MAX_DESC_LEN = 160 ;
1021
22+ // Allowed partner ID chars: letters, digits, hyphen, underscore — max 64.
23+ const PARTNER_PATTERN = / ^ [ A - Z a - z 0 - 9 _ - ] { 1 , 64 } $ / ;
24+ // Strict hex colour: #RGB or #RRGGBB
25+ const COLOR_PATTERN = / ^ # (?: [ 0 - 9 A - F a - f ] { 3 } | [ 0 - 9 A - F a - 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, '&' )
38+ . replace ( / < / g, '<' )
39+ . replace ( / > / g, '>' )
40+ . replace ( / " / g, '"' )
41+ . replace ( / ' / g, ''' ) ;
42+ }
43+
1144function 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