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) <noreply@anthropic.com>
This commit is contained in:
parent
4f87807438
commit
e61da960ea
|
|
@ -30,7 +30,7 @@ import {
|
||||||
type CartItem, type CartStatus,
|
type CartItem, type CartStatus,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import { extractProductFromUrl } from './extract';
|
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 QRCode from 'qrcode';
|
||||||
import { createTransport, type Transporter } from "nodemailer";
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -1790,7 +1790,7 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
|
||||||
widgetParams.defaultFiatCurrency = p.fiatCurrency;
|
widgetParams.defaultFiatCurrency = p.fiatCurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetUrl = createTransakWidgetUrl(widgetParams);
|
const widgetUrl = await createSecureWidgetUrl(widgetParams);
|
||||||
const transakEnv = getTransakEnv();
|
const transakEnv = getTransakEnv();
|
||||||
|
|
||||||
return c.json({ widgetUrl, env: transakEnv });
|
return c.json({ widgetUrl, env: transakEnv });
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OnrampProvider, OnrampSessionRequest, OnrampSessionResult } from './onramp-provider';
|
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 {
|
export class TransakOnrampAdapter implements OnrampProvider {
|
||||||
id = 'transak' as const;
|
id = 'transak' as const;
|
||||||
|
|
@ -40,7 +40,7 @@ export class TransakOnrampAdapter implements OnrampProvider {
|
||||||
}
|
}
|
||||||
if (req.returnUrl) widgetParams.redirectURL = req.returnUrl;
|
if (req.returnUrl) widgetParams.redirectURL = req.returnUrl;
|
||||||
|
|
||||||
const widgetUrl = createTransakWidgetUrl(widgetParams);
|
const widgetUrl = await createSecureWidgetUrl(widgetParams);
|
||||||
return { widgetUrl, provider: 'transak' };
|
return { widgetUrl, provider: 'transak' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* Transak API utilities — shared across rFlows and rCart.
|
* Transak API utilities — shared across rFlows and rCart.
|
||||||
*
|
*
|
||||||
* Builds Transak widget URLs using direct query parameters.
|
* Uses the Secure Widget URL API (mandatory since 2025):
|
||||||
* The gateway session API has auth issues, so we use the direct
|
* 1. Get a Partner Access Token via refresh-token endpoint (cached 7 days)
|
||||||
* URL approach which Transak still supports.
|
* 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.
|
* Environment: TRANSAK_ENV controls staging vs production.
|
||||||
* API keys are split: TRANSAK_API_KEY_STAGING / TRANSAK_API_KEY_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).
|
* (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';
|
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 */
|
/** Get the current Transak environment */
|
||||||
export function getTransakEnv(): TransakEnv {
|
export function getTransakEnv(): TransakEnv {
|
||||||
return (process.env.TRANSAK_ENV as TransakEnv) || 'STAGING';
|
return (process.env.TRANSAK_ENV as TransakEnv) || 'STAGING';
|
||||||
|
|
@ -29,8 +40,8 @@ export function getTransakApiKey(): string {
|
||||||
) || process.env.TRANSAK_API_KEY || '';
|
) || process.env.TRANSAK_API_KEY || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the webhook secret for the current Transak environment */
|
/** Get the API secret for the current Transak environment */
|
||||||
export function getTransakWebhookSecret(): string {
|
export function getTransakApiSecret(): string {
|
||||||
const env = getTransakEnv();
|
const env = getTransakEnv();
|
||||||
return (env === 'PRODUCTION'
|
return (env === 'PRODUCTION'
|
||||||
? process.env.TRANSAK_WEBHOOK_SECRET_PRODUCTION
|
? process.env.TRANSAK_WEBHOOK_SECRET_PRODUCTION
|
||||||
|
|
@ -38,20 +49,102 @@ export function getTransakWebhookSecret(): string {
|
||||||
) || process.env.TRANSAK_WEBHOOK_SECRET || '';
|
) || 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") */
|
/** Extract root domain from a hostname (e.g. "demo.rspace.online" → "rspace.online") */
|
||||||
export function extractRootDomain(host: string): string {
|
export function extractRootDomain(host: string): string {
|
||||||
const parts = host.replace(/:\d+$/, '').split('.');
|
const parts = host.replace(/:\d+$/, '').split('.');
|
||||||
return parts.length > 2 ? parts.slice(-2).join('.') : parts.join('.');
|
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<string> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
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<string, string>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, string>): string {
|
export function createTransakWidgetUrl(params: Record<string, string>): string {
|
||||||
const env = getTransakEnv();
|
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);
|
const url = new URL(base);
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value != null && value !== '') {
|
if (value != null && value !== '') {
|
||||||
url.searchParams.set(key, value);
|
url.searchParams.set(key, String(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue