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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 20:00:46 -07:00
parent 3f71b219bb
commit e8a54f1eb6
2 changed files with 92 additions and 14 deletions

View File

@ -3764,7 +3764,8 @@ class FolkFlowsApp extends HTMLElement {
<button id="onramp-close" style="position:absolute;top:8px;right:12px;z-index:10;
background:none;border:none;color:white;font-size:24px;cursor:pointer">&times;</button>
<iframe src="${url}" style="width:100%;height:100%;border:none"
allow="camera;microphone;payment"></iframe>
allow="camera;microphone;payment"
referrerpolicy="strict-origin-when-cross-origin"></iframe>
</div>`;
document.body.appendChild(modal);

View File

@ -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<string> {
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<string, string>): Promise<string> {
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<FlowsDoc>(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<string, string> = {
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",
});
});