From e8a54f1eb66560e17d16f73724ddc22952f553a4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 20:00:46 -0700 Subject: [PATCH] feat(rflows): migrate to Transak API-based widget URL Transak deprecated direct query-parameter URLs. The new flow uses their Create Widget URL API with a Partner Access Token to generate one-time sessionId-based URLs server-side. Also stops exposing the API key in config endpoints and adds referrerpolicy to the iframe. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 3 +- modules/rflows/mod.ts | 103 +++++++++++++++++--- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 2b2f877..66b53af 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -3764,7 +3764,8 @@ class FolkFlowsApp extends HTMLElement { + allow="camera;microphone;payment" + referrerpolicy="strict-origin-when-cross-origin"> `; document.body.appendChild(modal); diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 2b525eb..f630c28 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -21,6 +21,88 @@ let _openfort: OpenfortProvider | null = null; const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010"; +// ─── Transak API-based widget URL (mandatory since migration) ──── +let _transakAccessToken: string | null = null; +let _transakTokenExpiry = 0; + +async function getTransakAccessToken(): Promise { + if (_transakAccessToken && Date.now() < _transakTokenExpiry) return _transakAccessToken; + + const apiKey = process.env.TRANSAK_API_KEY; + const apiSecret = process.env.TRANSAK_SECRET; + if (!apiKey || !apiSecret) throw new Error("Transak credentials not configured"); + + const env = process.env.TRANSAK_ENV || 'PRODUCTION'; + const baseUrl = env === 'PRODUCTION' + ? 'https://api.transak.com' + : 'https://api-stg.transak.com'; + + const res = await fetch(`${baseUrl}/partners/api/v2/refresh-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-secret': apiSecret, + }, + body: JSON.stringify({ apiKey }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Transak token refresh failed (${res.status}): ${text}`); + } + + const data = await res.json() as any; + _transakAccessToken = data.data?.accessToken || data.accessToken; + if (!_transakAccessToken) throw new Error("No accessToken in Transak response"); + + // Cache for 6 days (tokens valid for 7) + _transakTokenExpiry = Date.now() + 6 * 24 * 60 * 60 * 1000; + console.log('[rflows] Transak access token refreshed'); + return _transakAccessToken; +} + +async function createTransakWidgetUrl(params: Record): Promise { + const accessToken = await getTransakAccessToken(); + const env = process.env.TRANSAK_ENV || 'PRODUCTION'; + const gatewayUrl = env === 'PRODUCTION' + ? 'https://api-gateway.transak.com' + : 'https://api-gateway-stg.transak.com'; + + const res = await fetch(`${gatewayUrl}/api/v2/auth/session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'access-token': accessToken, + }, + body: JSON.stringify({ widgetParams: params }), + }); + + if (!res.ok) { + const text = await res.text(); + // If token expired, clear cache and retry once + if (res.status === 401 || res.status === 403) { + _transakAccessToken = null; + _transakTokenExpiry = 0; + const retryToken = await getTransakAccessToken(); + const retry = await fetch(`${gatewayUrl}/api/v2/auth/session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'access-token': retryToken, + }, + body: JSON.stringify({ widgetParams: params }), + }); + if (!retry.ok) throw new Error(`Transak widget URL failed on retry (${retry.status}): ${await retry.text()}`); + const retryData = await retry.json() as any; + return retryData.data?.widgetUrl; + } + throw new Error(`Transak widget URL failed (${res.status}): ${text}`); + } + + const data = await res.json() as any; + return data.data?.widgetUrl; +} + function ensureDoc(space: string): FlowsDoc { const docId = flowsDocId(space); let doc = _syncServer!.getDoc(docId); @@ -180,16 +262,13 @@ routes.post("/api/flows/user-onramp", async (c) => { const sessionId = crypto.randomUUID(); - // 2. Transak: build widget URL server-side + // 2. Transak: create widget URL via API const transakApiKey = process.env.TRANSAK_API_KEY; if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503); - const transakEnv = process.env.TRANSAK_ENV || 'PRODUCTION'; - const baseUrl = transakEnv === 'PRODUCTION' - ? 'https://global.transak.com' - : 'https://global-stg.transak.com'; - const params = new URLSearchParams({ + + const widgetParams: Record = { apiKey: transakApiKey, - environment: transakEnv, + referrerDomain: 'rspace.online', cryptoCurrencyCode: 'USDC', network: 'base', defaultCryptoCurrency: 'USDC', @@ -198,9 +277,9 @@ routes.post("/api/flows/user-onramp", async (c) => { email, themeColor: '6366f1', hideMenu: 'true', - }); - if (returnUrl) params.set('redirectURL', returnUrl); - const widgetUrl = `${baseUrl}?${params}`; + }; + if (returnUrl) widgetParams.redirectURL = returnUrl; + const widgetUrl = await createTransakWidgetUrl(widgetParams); console.log(`[rflows] On-ramp session created: provider=transak session=${sessionId} wallet=${wallet.address}`); @@ -255,15 +334,13 @@ routes.get("/api/onramp/config", (c) => { return c.json({ provider: "transak", available: ["transak"], - apiKey: process.env.TRANSAK_API_KEY || "", - environment: process.env.TRANSAK_ENV || "PRODUCTION", }); }); // Legacy endpoint — keep for backwards compat routes.get("/api/transak/config", (c) => { return c.json({ - apiKey: process.env.TRANSAK_API_KEY || "", + provider: "transak", environment: process.env.TRANSAK_ENV || "PRODUCTION", }); });