Skip to content

Commit 32d44d6

Browse files
Merge branch 'main' into feat/embed-widget-809
2 parents 2088739 + 2449172 commit 32d44d6

2 files changed

Lines changed: 334 additions & 0 deletions

File tree

backend/src/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { generateAllowlist } from './lib/allowlist/merkle.js';
5353
import { parseAllowlistCsv, validateGAddress, MAX_ALLOWLIST_ROWS } from './lib/allowlist/csv.js';
5454
import { createEmbedRoute } from './routes/embed.js';
5555
import { createEmbedWidgetRoute } from './routes/embedWidget.js';
56+
import { createDevPortalRoutes } from './routes/devPortal.js';
5657
import { createVariantRoutes } from './routes/variants.js';
5758
import { createVariantService } from './services/variantService.js';
5859
import { createCohortRoutes } from './routes/cohorts.js';
@@ -824,6 +825,10 @@ export async function createApp(options = {}) {
824825
embedSecret: process.env.EMBED_ATTRIBUTION_SECRET,
825826
}),
826827
);
828+
// Developer portal (#807)
829+
app.use('/dev-portal', createDevPortalRoutes({
830+
openApiPath: join(process.cwd(), 'backend', 'openapi.yaml'),
831+
}));
827832

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

backend/src/routes/devPortal.js

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* Developer portal route — /dev-portal
3+
*
4+
* Serves:
5+
* - Interactive API reference (Swagger UI from OpenAPI spec)
6+
* - Quickstart guide with code samples
7+
* - Sandbox API key management
8+
* - Webhook tester/echo
9+
* - Event catalog
10+
*/
11+
12+
import { Router } from 'express';
13+
import { readFileSync } from 'node:fs';
14+
import { join, dirname } from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
17+
const __dirname = dirname(fileURLToPath(import.meta.url));
18+
19+
/**
20+
* Create the developer portal routes.
21+
* @param {object} options
22+
* @param {object} options.apiKeyRepository - API key storage
23+
* @param {string} options.openApiPath - Path to openapi.yaml
24+
* @returns {Router}
25+
*/
26+
export function createDevPortalRoutes({ apiKeyRepository, openApiPath }) {
27+
const router = Router();
28+
29+
// Serve the developer portal HTML
30+
router.get('/', (_req, res) => {
31+
res.type('html').send(renderPortalPage());
32+
});
33+
34+
// Serve OpenAPI spec for Swagger UI
35+
router.get('/openapi.json', (_req, res) => {
36+
try {
37+
const spec = readFileSync(openApiPath, 'utf8');
38+
res.type('yaml').send(spec);
39+
} catch {
40+
res.status(504).json({ error: 'OpenAPI spec not available' });
41+
}
42+
});
43+
44+
// Sandbox API key management
45+
router.post('/sandbox-keys', (req, res) => {
46+
const { name, email } = req.body ?? {};
47+
if (!name || !email) {
48+
return res.status(400).json({ error: 'name and email are required' });
49+
}
50+
51+
const key = `sandbox_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
52+
const sandboxKey = {
53+
key,
54+
name: String(name).slice(0, 64),
55+
email: String(email).slice(0, 128),
56+
scope: 'testnet',
57+
createdAt: new Date().toISOString(),
58+
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days
59+
};
60+
61+
// In production, persist to DB
62+
res.status(201).json({ sandboxKey });
63+
});
64+
65+
// Webhook echo/test endpoint
66+
router.post('/webhook-echo', (req, res) => {
67+
const received = {
68+
method: req.method,
69+
headers: {
70+
'content-type': req.headers['content-type'],
71+
'x-webhook-signature': req.headers['x-webhook-signature'],
72+
'x-webhook-timestamp': req.headers['x-webhook-timestamp'],
73+
},
74+
body: req.body,
75+
receivedAt: new Date().toISOString(),
76+
};
77+
78+
res.json({
79+
message: 'Webhook received successfully',
80+
received,
81+
});
82+
});
83+
84+
// Event catalog
85+
router.get('/events', (_req, res) => {
86+
res.json({ events: getEventCatalog() });
87+
});
88+
89+
// Quickstart guide
90+
router.get('/quickstart', (_req, res) => {
91+
res.type('html').send(renderQuickstartPage());
92+
});
93+
94+
return router;
95+
}
96+
97+
function getEventCatalog() {
98+
return [
99+
{
100+
event: 'campaign.created',
101+
description: 'A new campaign was created',
102+
payload: '{ campaignId, name, operatorAddress, createdAt }',
103+
},
104+
{
105+
event: 'campaign.updated',
106+
description: 'Campaign metadata was updated',
107+
payload: '{ campaignId, changes, updatedAt }',
108+
},
109+
{
110+
event: 'campaign.published',
111+
description: 'Campaign was published and is now visible',
112+
payload: '{ campaignId, publishedAt }',
113+
},
114+
{
115+
event: 'participant.registered',
116+
description: 'A participant registered for a campaign',
117+
payload: '{ campaignId, participantAddress, registeredAt }',
118+
},
119+
{
120+
event: 'participant.claimed',
121+
description: 'A participant claimed rewards',
122+
payload: '{ campaignId, participantAddress, amount, claimedAt }',
123+
},
124+
{
125+
event: 'leaderboard.updated',
126+
description: 'Campaign leaderboard rankings changed',
127+
payload: '{ campaignId, topParticipants, updatedAt }',
128+
},
129+
{
130+
event: 'campaign.ended',
131+
description: 'Campaign reached its end date or max participants',
132+
payload: '{ campaignId, endedAt, totalParticipants }',
133+
},
134+
];
135+
}
136+
137+
function renderPortalPage() {
138+
return `<!DOCTYPE html>
139+
<html lang="en"><head>
140+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
141+
<title>Trivela Developer Portal</title>
142+
<style>
143+
*{margin:0;padding:0;box-sizing:border-box}
144+
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#f1f5f9;line-height:1.6}
145+
.container{max-width:960px;margin:0 auto;padding:32px 24px}
146+
h1{font-size:32px;font-weight:800;margin-bottom:8px}
147+
.subtitle{color:#94a3b8;font-size:16px;margin-bottom:32px}
148+
.section{background:#1e293b;border-radius:12px;padding:24px;margin-bottom:24px;border:1px solid #334155}
149+
.section h2{font-size:20px;font-weight:700;margin-bottom:12px;color:#f1f5f9}
150+
.section p{color:#94a3b8;margin-bottom:16px}
151+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}
152+
.card{background:#0f172a;border-radius:8px;padding:16px;border:1px solid #334155}
153+
.card h3{font-size:16px;font-weight:600;margin-bottom:8px;color:#3b82f6}
154+
.card p{font-size:14px;color:#94a3b8}
155+
a{color:#3b82f6;text-decoration:none}
156+
a:hover{text-decoration:underline}
157+
.btn{display:inline-block;background:#3b82f6;color:#fff;padding:10px 20px;border-radius:8px;font-weight:600;font-size:14px;border:none;cursor:pointer}
158+
.btn:hover{background:#2563eb;text-decoration:none}
159+
code{background:#334155;padding:2px 6px;border-radius:4px;font-size:13px;font-family:monospace}
160+
pre{background:#0f172a;padding:16px;border-radius:8px;overflow-x:auto;font-size:13px;margin-bottom:16px}
161+
pre code{background:none;padding:0}
162+
.form-group{margin-bottom:16px}
163+
.form-group label{display:block;font-size:13px;color:#94a3b8;margin-bottom:4px}
164+
.form-group input{width:100%;padding:10px 12px;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#f1f5f9;font-size:14px}
165+
.result{background:#0f172a;border:1px solid #334155;border-radius:8px;padding:16px;margin-top:16px;display:none}
166+
.nav{display:flex;gap:24px;margin-bottom:32px;border-bottom:1px solid #334155;padding-bottom:16px}
167+
.nav a{color:#94a3b8;font-size:14px;font-weight:500}
168+
.nav a.active{color:#3b82f6;border-bottom:2px solid #3b82f6;padding-bottom:16px}
169+
</style>
170+
</head><body>
171+
<div class="container">
172+
<h1>Trivela Developer Portal</h1>
173+
<p class="subtitle">Build on Trivela — campaigns, rewards, and leaderboards on Stellar.</p>
174+
175+
<nav class="nav">
176+
<a href="#overview" class="active">Overview</a>
177+
<a href="/dev-portal/quickstart">Quickstart</a>
178+
<a href="/dev-portal/openapi.json" target="_blank">OpenAPI Spec</a>
179+
<a href="#sandbox">Sandbox Keys</a>
180+
<a href="#webhook-test">Webhook Tester</a>
181+
<a href="#events">Event Catalog</a>
182+
</nav>
183+
184+
<section class="section" id="overview">
185+
<h2>API Reference</h2>
186+
<p>Explore the full Trivela API interactively.</p>
187+
<iframe src="/docs" style="width:100%;height:600px;border:1px solid #334155;border-radius:8px;background:#fff"></iframe>
188+
</section>
189+
190+
<section class="section" id="sandbox">
191+
<h2>Sandbox API Keys</h2>
192+
<p>Get a testnet-scoped API key to start building.</p>
193+
<div class="form-group">
194+
<label>Application Name</label>
195+
<input type="text" id="sandbox-name" placeholder="My App">
196+
</div>
197+
<div class="form-group">
198+
<label>Email</label>
199+
<input type="email" id="sandbox-email" placeholder="dev@example.com">
200+
</div>
201+
<button class="btn" onclick="requestSandboxKey()">Get Sandbox Key</button>
202+
<div class="result" id="sandbox-result"></div>
203+
</section>
204+
205+
<section class="section" id="webhook-test">
206+
<h2>Webhook Tester</h2>
207+
<p>Test your webhook integration by sending a test payload.</p>
208+
<button class="btn" onclick="testWebhook()">Send Test Webhook</button>
209+
<div class="result" id="webhook-result"></div>
210+
</section>
211+
212+
<section class="section" id="events">
213+
<h2>Event Catalog</h2>
214+
<div class="grid">
215+
<div class="card"><h3>campaign.created</h3><p>A new campaign was created</p></div>
216+
<div class="card"><h3>campaign.published</h3><p>Campaign is now visible</p></div>
217+
<div class="card"><h3>participant.registered</h3><p>User registered for campaign</p></div>
218+
<div class="card"><h3>participant.claimed</h3><p>User claimed rewards</p></div>
219+
<div class="card"><h3>leaderboard.updated</h3><p>Rankings changed</p></div>
220+
<div class="card"><h3>campaign.ended</h3><p>Campaign reached end</p></div>
221+
</div>
222+
</section>
223+
</div>
224+
<script>
225+
async function requestSandboxKey() {
226+
const name = document.getElementById('sandbox-name').value;
227+
const email = document.getElementById('sandbox-email').value;
228+
const res = await fetch('/dev-portal/sandbox-keys', {
229+
method: 'POST',
230+
headers: { 'Content-Type': 'application/json' },
231+
body: JSON.stringify({ name, email }),
232+
});
233+
const data = await res.json();
234+
const el = document.getElementById('sandbox-result');
235+
el.style.display = 'block';
236+
el.innerHTML = res.ok
237+
? '<strong>Your sandbox key:</strong><br><code>' + data.sandboxKey.key + '</code><br><small>Expires: ' + data.sandboxKey.expiresAt + '</small>'
238+
: '<span style="color:#ef4444">' + data.error + '</span>';
239+
}
240+
241+
async function testWebhook() {
242+
const res = await fetch('/dev-portal/webhook-echo', {
243+
method: 'POST',
244+
headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': 'test-sig-123' },
245+
body: JSON.stringify({ event: 'test.ping', data: { hello: 'world' } }),
246+
});
247+
const data = await res.json();
248+
const el = document.getElementById('webhook-result');
249+
el.style.display = 'block';
250+
el.innerHTML = '<strong>Response:</strong><pre><code>' + JSON.stringify(data, null, 2) + '</code></pre>';
251+
}
252+
</script>
253+
</body></html>`;
254+
}
255+
256+
function renderQuickstartPage() {
257+
return `<!DOCTYPE html>
258+
<html lang="en"><head>
259+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
260+
<title>Quickstart — Trivela Developer Portal</title>
261+
<style>
262+
*{margin:0;padding:0;box-sizing:border-box}
263+
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#f1f5f9;line-height:1.6}
264+
.container{max-width:720px;margin:0 auto;padding:32px 24px}
265+
h1{font-size:28px;font-weight:800;margin-bottom:24px}
266+
h2{font-size:20px;font-weight:700;margin:32px 0 12px;color:#f1f5f9}
267+
p{color:#94a3b8;margin-bottom:16px}
268+
code{background:#334155;padding:2px 6px;border-radius:4px;font-size:13px;font-family:monospace}
269+
pre{background:#1e293b;padding:16px;border-radius:8px;overflow-x:auto;font-size:13px;margin-bottom:16px;border:1px solid #334155}
270+
pre code{background:none;padding:0}
271+
.step{background:#1e293b;border-radius:12px;padding:20px;margin-bottom:16px;border:1px solid #334155}
272+
.step-num{display:inline-block;background:#3b82f6;color:#fff;width:28px;height:28px;border-radius:50%;text-align:center;line-height:28px;font-size:14px;font-weight:700;margin-right:8px}
273+
a{color:#3b82f6;text-decoration:none}
274+
</style>
275+
</head><body>
276+
<div class="container">
277+
<h1>Quickstart</h1>
278+
<p>Get up and running with the Trivela API in under 5 minutes.</p>
279+
280+
<div class="step">
281+
<h2><span class="step-num">1</span> Get your API key</h2>
282+
<p>Request a sandbox key from the <a href="/dev-portal#sandbox">Developer Portal</a>.</p>
283+
</div>
284+
285+
<div class="step">
286+
<h2><span class="step-num">2</span> Make your first request</h2>
287+
<pre><code>curl -H "Authorization: Bearer sandbox_your_key" \\
288+
https://api.trivela.example.com/api/v1/campaigns</code></pre>
289+
</div>
290+
291+
<div class="step">
292+
<h2><span class="step-num">3</span> Create a campaign</h2>
293+
<pre><code>curl -X POST https://api.trivela.example.com/api/v1/campaigns \\
294+
-H "Authorization: Bearer sandbox_your_key" \\
295+
-H "Content-Type: application/json" \\
296+
-d '{
297+
"name": "My First Campaign",
298+
"description": "A test campaign",
299+
"rewardPerParticipant": 100,
300+
"maxParticipants": 100
301+
}'</code></pre>
302+
</div>
303+
304+
<div class="step">
305+
<h2><span class="step-num">4</span> Set up webhooks</h2>
306+
<p>Register a webhook endpoint to receive real-time events.</p>
307+
<pre><code>curl -X POST https://api.trivela.example.com/api/v1/webhooks \\
308+
-H "Authorization: Bearer sandbox_your_key" \\
309+
-H "Content-Type: application/json" \\
310+
-d '{
311+
"url": "https://your-app.com/webhooks/trivela",
312+
"events": ["campaign.created", "participant.registered"]
313+
}'</code></pre>
314+
</div>
315+
316+
<div class="step">
317+
<h2><span class="step-num">5</span> Embed a widget</h2>
318+
<p>Add a campaign widget to your site.</p>
319+
<pre><code>&lt;iframe
320+
src="https://api.trivela.example.com/embed/v1/card/CAMPAIGN_ID?theme=dark"
321+
width="400" height="300"
322+
sandbox="allow-scripts allow-same-origin"
323+
&gt;&lt;/iframe&gt;</code></pre>
324+
</div>
325+
326+
<p style="margin-top:32px"><a href="/dev-portal">← Back to Developer Portal</a></p>
327+
</div>
328+
</body></html>`;
329+
}

0 commit comments

Comments
 (0)