From e61da960ea5e1b64f1825f3b7f47be5d3ade26da Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 03:17:18 +0000 Subject: [PATCH] feat: migrate to Transak Secure Widget URL API Transak now requires widget URLs to be generated server-side via their gateway session API. Direct query-parameter URLs are deprecated. - Add getAccessToken() with 7-day caching for partner access tokens - Add createSecureWidgetUrl() that calls the gateway session endpoint - Falls back to legacy direct URL if gateway returns an error (e.g. production IP not yet whitelisted) - Update rCart and rFlows to use the secure API Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rcart/mod.ts | 4 +- modules/rflows/lib/transak-onramp.ts | 4 +- shared/transak.ts | 113 ++++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index e7a14f6..c0c48c6 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -30,7 +30,7 @@ import { type CartItem, type CartStatus, } from './schemas'; import { extractProductFromUrl } from './extract'; -import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak'; +import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak'; import QRCode from 'qrcode'; import { createTransport, type Transporter } from "nodemailer"; import { @@ -1790,7 +1790,7 @@ routes.post("/api/payments/:id/transak-session", async (c) => { widgetParams.defaultFiatCurrency = p.fiatCurrency; } - const widgetUrl = createTransakWidgetUrl(widgetParams); + const widgetUrl = await createSecureWidgetUrl(widgetParams); const transakEnv = getTransakEnv(); return c.json({ widgetUrl, env: transakEnv }); diff --git a/modules/rflows/lib/transak-onramp.ts b/modules/rflows/lib/transak-onramp.ts index 4c344a1..6d38f40 100644 --- a/modules/rflows/lib/transak-onramp.ts +++ b/modules/rflows/lib/transak-onramp.ts @@ -4,7 +4,7 @@ */ import type { OnrampProvider, OnrampSessionRequest, OnrampSessionResult } from './onramp-provider'; -import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../../shared/transak'; +import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey } from '../../../shared/transak'; export class TransakOnrampAdapter implements OnrampProvider { id = 'transak' as const; @@ -40,7 +40,7 @@ export class TransakOnrampAdapter implements OnrampProvider { } if (req.returnUrl) widgetParams.redirectURL = req.returnUrl; - const widgetUrl = createTransakWidgetUrl(widgetParams); + const widgetUrl = await createSecureWidgetUrl(widgetParams); return { widgetUrl, provider: 'transak' }; } } diff --git a/shared/transak.ts b/shared/transak.ts index ecb162f..164db3e 100644 --- a/shared/transak.ts +++ b/shared/transak.ts @@ -1,20 +1,31 @@ /** * Transak API utilities — shared across rFlows and rCart. * - * Builds Transak widget URLs using direct query parameters. - * The gateway session API has auth issues, so we use the direct - * URL approach which Transak still supports. + * Uses the Secure Widget URL API (mandatory since 2025): + * 1. Get a Partner Access Token via refresh-token endpoint (cached 7 days) + * 2. Call the gateway session endpoint with widget params + * 3. Return the widgetUrl containing a single-use sessionId * * Environment: TRANSAK_ENV controls staging vs production. * API keys are split: TRANSAK_API_KEY_STAGING / TRANSAK_API_KEY_PRODUCTION * (falls back to legacy TRANSAK_API_KEY if per-env keys aren't set). */ -const TRANSAK_WIDGET_BASE = 'https://global.transak.com'; -const TRANSAK_WIDGET_BASE_STG = 'https://global-stg.transak.com'; - export type TransakEnv = 'STAGING' | 'PRODUCTION'; +const API_URLS = { + STAGING: 'https://api-stg.transak.com', + PRODUCTION: 'https://api.transak.com', +} as const; + +const GATEWAY_URLS = { + STAGING: 'https://api-gateway-stg.transak.com', + PRODUCTION: 'https://api-gateway.transak.com', +} as const; + +// Cached access token (valid for 7 days) +let _cachedToken: { token: string; expiresAt: number; env: TransakEnv } | null = null; + /** Get the current Transak environment */ export function getTransakEnv(): TransakEnv { return (process.env.TRANSAK_ENV as TransakEnv) || 'STAGING'; @@ -29,8 +40,8 @@ export function getTransakApiKey(): string { ) || process.env.TRANSAK_API_KEY || ''; } -/** Get the webhook secret for the current Transak environment */ -export function getTransakWebhookSecret(): string { +/** Get the API secret for the current Transak environment */ +export function getTransakApiSecret(): string { const env = getTransakEnv(); return (env === 'PRODUCTION' ? process.env.TRANSAK_WEBHOOK_SECRET_PRODUCTION @@ -38,20 +49,102 @@ export function getTransakWebhookSecret(): string { ) || process.env.TRANSAK_WEBHOOK_SECRET || ''; } +/** Get the webhook secret for the current Transak environment */ +export function getTransakWebhookSecret(): string { + return getTransakApiSecret(); +} + /** Extract root domain from a hostname (e.g. "demo.rspace.online" → "rspace.online") */ export function extractRootDomain(host: string): string { const parts = host.replace(/:\d+$/, '').split('.'); return parts.length > 2 ? parts.slice(-2).join('.') : parts.join('.'); } +/** + * Get a Partner Access Token (cached for 7 days). + * Calls the refresh-token endpoint only when the cached token has expired. + */ +async function getAccessToken(): Promise { + const env = getTransakEnv(); + const now = Math.floor(Date.now() / 1000); + + if (_cachedToken && _cachedToken.env === env && _cachedToken.expiresAt > now + 60) { + return _cachedToken.token; + } + + const apiKey = getTransakApiKey(); + const apiSecret = getTransakApiSecret(); + if (!apiKey || !apiSecret) throw new Error('Transak API key or secret not configured'); + + const apiUrl = API_URLS[env]; + const res = await fetch(`${apiUrl}/partners/api/v2/refresh-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-secret': apiSecret, + }, + body: JSON.stringify({ apiKey }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Transak refresh-token failed: ${res.status} ${err}`); + } + + const data = await res.json() as { data: { accessToken: string; expiresAt: number } }; + _cachedToken = { + token: data.data.accessToken, + expiresAt: data.data.expiresAt, + env, + }; + + console.log(`[transak] Access token refreshed (env=${env}, expires=${new Date(data.data.expiresAt * 1000).toISOString()})`); + return _cachedToken.token; +} + +/** + * Create a Secure Widget URL via the Transak gateway session API. + * Returns a URL with a single-use sessionId (valid for 5 minutes). + */ +export async function createSecureWidgetUrl( + widgetParams: Record, +): Promise { + const env = getTransakEnv(); + const accessToken = await getAccessToken(); + const gatewayUrl = GATEWAY_URLS[env]; + + const res = await fetch(`${gatewayUrl}/api/v2/auth/session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'access-token': accessToken, + }, + body: JSON.stringify({ widgetParams }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error(`[transak] Gateway session failed (${res.status}):`, err); + // Fall back to direct URL construction if gateway fails (e.g. IP not whitelisted) + console.warn('[transak] Falling back to direct widget URL'); + return createTransakWidgetUrl(widgetParams as Record); + } + + const data = await res.json() as { data: { widgetUrl: string } }; + return data.data.widgetUrl; +} + +/** Legacy: build widget URL from query parameters (fallback only) */ export function createTransakWidgetUrl(params: Record): string { const env = getTransakEnv(); - const base = env === 'PRODUCTION' ? TRANSAK_WIDGET_BASE : TRANSAK_WIDGET_BASE_STG; + const base = env === 'PRODUCTION' + ? 'https://global.transak.com' + : 'https://global-stg.transak.com'; const url = new URL(base); for (const [key, value] of Object.entries(params)) { if (value != null && value !== '') { - url.searchParams.set(key, value); + url.searchParams.set(key, String(value)); } }