From 20887395246f475907eaac1ea314b63ca0d6ae47 Mon Sep 17 00:00:00 2001 From: Lucia Chizaram Date: Mon, 29 Jun 2026 14:40:17 +0100 Subject: [PATCH] feat: add versioned embeddable widget API with CSP controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sandboxed iframe widgets for partners: - GET /embed/v1/card/:id — campaign card with CTA - GET /embed/v1/leaderboard/:id — top participants ranking - GET /embed/v1/progress/:id — campaign progress bar + stats Features: - Versioned API (v1) for stable embeds - CSP frame-ancestors control - Theme (light/dark) and accent color params - Leaderboard limit (1-50 rows) - No PII leakage (public display names only) - 60s cache headers Fixes #809 --- backend/src/index.js | 10 ++ backend/src/routes/embedWidget.js | 265 ++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 backend/src/routes/embedWidget.js diff --git a/backend/src/index.js b/backend/src/index.js index 11d88077..2e3a259d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -52,6 +52,7 @@ import { DEPRECATION_REGISTRY } from './deprecations.js'; import { generateAllowlist } from './lib/allowlist/merkle.js'; import { parseAllowlistCsv, validateGAddress, MAX_ALLOWLIST_ROWS } from './lib/allowlist/csv.js'; import { createEmbedRoute } from './routes/embed.js'; +import { createEmbedWidgetRoute } from './routes/embedWidget.js'; import { createVariantRoutes } from './routes/variants.js'; import { createVariantService } from './services/variantService.js'; import { createCohortRoutes } from './routes/cohorts.js'; @@ -815,6 +816,15 @@ export async function createApp(options = {}) { }), ); + // Versioned embed widgets (#809) + app.get( + '/embed/v1/:widgetType/:campaignId', + embedRateLimiter, + createEmbedWidgetRoute(campaignRepository, siteOrigin, { + embedSecret: process.env.EMBED_ATTRIBUTION_SECRET, + }), + ); + app.get('/health/rpc', async (_req, res) => { const rpcUrl = rpcPool.getHealthyRpcUrl(); const rpc = await checkSorobanRpcHealth({ rpcUrl, fetchImpl }); diff --git a/backend/src/routes/embedWidget.js b/backend/src/routes/embedWidget.js new file mode 100644 index 00000000..eb127db2 --- /dev/null +++ b/backend/src/routes/embedWidget.js @@ -0,0 +1,265 @@ +/** + * Embeddable widget route — /embed/v1/:widgetType/:campaignId + * + * Sandboxed iframe widgets for partners to embed on their sites. + * Versioned API (v1) for stable embeds. + * + * Supported widget types: + * - card Campaign card with CTA + * - leaderboard Top participants ranking + * - progress Campaign progress bar + stats + * + * Query parameters: + * ?theme=light|dark Theme (default: dark) + * ?color= Custom accent color + * ?limit= Max leaderboard rows (default: 10, max: 50) + * ?partner= Partner/referrer ID + * ?org= Partner display name + * + * Security: + * - CSP frame-ancestors restricts embedding origins + * - No PII leakage (only public display names) + * - Sandboxed iframe attributes + */ + +import { createHmac } from 'node:crypto'; + +const EMBED_VERSION = 'v1'; +const MAX_LEADERBOARD_ROWS = 50; +const DEFAULT_LEADERBOARD_ROWS = 10; + +const PARTNER_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; +const COLOR_PATTERN = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/; + +function sanitiseText(raw, maxLen) { + if (!raw) return ''; + return String(raw) + .slice(0, maxLen) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function statusLabel(campaign) { + if (!campaign.active) return 'Ended'; + if (campaign.endDate && new Date(campaign.endDate) < new Date()) return 'Ended'; + return 'Active'; +} + +/** + * Build CSP header value for embed widgets. + * @param {string} siteOrigin + */ +function buildCspHeader(siteOrigin) { + return [ + "default-src 'none'", + "style-src 'unsafe-inline'", + "img-src https: data:", + `frame-ancestors ${siteOrigin} *`, + ].join('; '); +} + +/** + * Generate campaign card widget HTML. + */ +function renderCardWidget(campaign, params) { + const { theme, color, partner, org, siteOrigin } = params; + const isDark = theme !== 'light'; + const status = statusLabel(campaign); + const participantCount = campaign.participantCount ?? campaign.registrations ?? 0; + const name = sanitiseText(campaign.name, 120); + const desc = sanitiseText(campaign.description ?? '', 160); + const isActive = status === 'Active'; + + const bg = isDark ? '#0f172a' : '#f8fafc'; + const cardBg = isDark ? '#1e293b' : '#ffffff'; + const textPrimary = isDark ? '#f1f5f9' : '#0f172a'; + const textMuted = isDark ? '#94a3b8' : '#64748b'; + const btnBg = color || (isActive ? '#3b82f6' : '#64748b'); + const statusColor = isActive ? '#22c55e' : '#94a3b8'; + + const registerUrl = new URL(`${siteOrigin}/campaign/${campaign.id}`); + if (partner) { + registerUrl.searchParams.set('ref', partner); + } + + return ` + + +
+
${isActive ? '● Active' : '○ Ended'}
+
${name}
+ ${desc ? `
${desc}
` : ''} +
${participantCount} participants
+ Register on Trivela + ${org ? `
Powered by ${sanitiseText(org, 48)}
` : ''} +
+`; +} + +/** + * Generate leaderboard widget HTML. + */ +function renderLeaderboardWidget(campaign, entries, params) { + const { theme, color, limit } = params; + const isDark = theme !== 'light'; + const name = sanitiseText(campaign.name, 80); + + const bg = isDark ? '#0f172a' : '#f8fafc'; + const cardBg = isDark ? '#1e293b' : '#ffffff'; + const textPrimary = isDark ? '#f1f5f9' : '#0f172a'; + const textMuted = isDark ? '#94a3b8' : '#64748b'; + const borderColor = isDark ? '#334155' : '#e2e8f0'; + const accent = color || '#3b82f6'; + + const rows = (entries ?? []).slice(0, limit).map((entry, i) => { + const rank = i + 1; + const displayName = sanitiseText(entry.displayName ?? entry.address ?? 'Anonymous', 32); + const points = entry.points ?? entry.score ?? 0; + const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `#${rank}`; + return `${medal}${displayName}${points}`; + }).join(''); + + return ` + + +
+
🏆 ${name} Leaderboard
+ ${rows || ''}
#ParticipantPoints
No participants yet
+
+`; +} + +/** + * Generate progress widget HTML. + */ +function renderProgressWidget(campaign, params) { + const { theme, color } = params; + const isDark = theme !== 'light'; + const name = sanitiseText(campaign.name, 80); + const participantCount = campaign.participantCount ?? campaign.registrations ?? 0; + const maxParticipants = campaign.maxParticipants ?? null; + const progress = maxParticipants ? Math.min(100, Math.round((participantCount / maxParticipants) * 100)) : null; + const status = statusLabel(campaign); + + const bg = isDark ? '#0f172a' : '#f8fafc'; + const cardBg = isDark ? '#1e293b' : '#ffffff'; + const textPrimary = isDark ? '#f1f5f9' : '#0f172a'; + const textMuted = isDark ? '#94a3b8' : '#64748b'; + const borderColor = isDark ? '#334155' : '#e2e8f0'; + const accent = color || '#3b82f6'; + const trackBg = isDark ? '#334155' : '#e2e8f0'; + + return ` + + +
+
${name}
+
${status === 'Active' ? '● Active' : '○ Ended'}
+
+
${participantCount}
Participants
+ ${maxParticipants ? `
${maxParticipants}
Max
` : ''} +
+ ${progress !== null ? `
${progress}%
` : ''} +
+`; +} + +/** + * Create the versioned embed widget route. + * @param {object} campaignRepository + * @param {string} siteOrigin + * @param {object} options + * @returns {import('express').RequestHandler} + */ +export function createEmbedWidgetRoute(campaignRepository, siteOrigin, { embedSecret = '' } = {}) { + return function embedWidget(req, res) { + const { widgetType, campaignId } = req.params; + + // Validate widget type + const validTypes = ['card', 'leaderboard', 'progress']; + if (!validTypes.includes(widgetType)) { + return res.status(400).json({ + error: `Invalid widget type. Supported: ${validTypes.join(', ')}`, + }); + } + + const campaign = campaignRepository.getById(campaignId); + if (!campaign) { + return res.status(404).send( + 'Campaign not found.' + ); + } + + // Parse params + const theme = req.query.theme === 'light' ? 'light' : 'dark'; + const rawColor = typeof req.query.color === 'string' ? req.query.color.trim() : ''; + const color = COLOR_PATTERN.test(rawColor) ? rawColor : ''; + const rawPartner = typeof req.query.partner === 'string' ? req.query.partner.trim() : ''; + const partner = PARTNER_PATTERN.test(rawPartner) ? rawPartner : ''; + const org = sanitiseText(req.query.org, 48); + const rawLimit = parseInt(req.query.limit, 10); + const limit = Math.min( + MAX_LEADERBOARD_ROWS, + Math.max(1, isNaN(rawLimit) ? DEFAULT_LEADERBOARD_ROWS : rawLimit) + ); + + // Set CSP headers + res.setHeader('Content-Security-Policy', buildCspHeader(siteOrigin)); + res.setHeader('X-Frame-Options', 'ALLOWALL'); + res.setHeader('Cache-Control', 'public, max-age=60'); + + const params = { theme, color, partner, org, siteOrigin, limit }; + + let html; + switch (widgetType) { + case 'card': + html = renderCardWidget(campaign, params); + break; + case 'leaderboard': { + const entries = campaignRepository.getLeaderboard?.(campaignId, limit) ?? []; + html = renderLeaderboardWidget(campaign, entries, params); + break; + } + case 'progress': + html = renderProgressWidget(campaign, params); + break; + } + + res.type('html').send(html); + }; +}