From 96b77278d4f03dd9bdd150553807c485137da714 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 20:32:23 -0700 Subject: [PATCH] fix(transak): use direct widget URL instead of broken gateway API The Transak gateway session API consistently returns 401 despite valid access tokens. Switch to direct URL construction (query params on global.transak.com) which Transak still supports and is simpler. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/mod.ts | 42 +++++++------- modules/rflows/lib/transak-onramp.ts | 4 +- shared/transak.ts | 87 ++++------------------------ 3 files changed, 34 insertions(+), 99 deletions(-) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index dff08b8..38a3942 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1368,26 +1368,22 @@ routes.post("/api/payments/:id/transak-session", async (c) => { const networkMap: Record = { 8453: 'base', 84532: 'base', 1: 'ethereum' }; - try { - const widgetUrl = await createTransakWidgetUrl({ - apiKey: transakApiKey, - referrerDomain: 'rspace.online', - cryptoCurrencyCode: p.token, - network: networkMap[p.chainId] || 'base', - defaultCryptoCurrency: p.token, - walletAddress: p.recipientAddress, - disableWalletAddressForm: 'true', - cryptoAmount: p.amount, - partnerOrderId: `pay-${paymentId}`, - email, - themeColor: '6366f1', - hideMenu: 'true', - }); + const widgetUrl = createTransakWidgetUrl({ + apiKey: transakApiKey, + referrerDomain: 'rspace.online', + cryptoCurrencyCode: p.token, + network: networkMap[p.chainId] || 'base', + defaultCryptoCurrency: p.token, + walletAddress: p.recipientAddress, + disableWalletAddressForm: 'true', + cryptoAmount: p.amount, + partnerOrderId: `pay-${paymentId}`, + email, + themeColor: '6366f1', + hideMenu: 'true', + }); - return c.json({ widgetUrl }); - } catch (err) { - return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); - } + return c.json({ widgetUrl }); }); function paymentToResponse(p: PaymentRequestMeta) { @@ -1465,7 +1461,7 @@ routes.post("/api/group-buys", async (c) => { _syncServer!.setDoc(docId, doc); const host = c.req.header('host') || 'rspace.online'; - const shareUrl = `https://${host}/${space}/rcart/buy/${buyId}`; + const shareUrl = `https://${host}/${space}/rcart/group-buy/${buyId}`; return c.json({ id: buyId, shareUrl }, 201); }); @@ -1575,7 +1571,7 @@ routes.post("/api/group-buys/:id/pledge", async (c) => { }); // ── Page route: group buy page ── -routes.get("/buy/:id", (c) => { +routes.get("/group-buy/:id", (c) => { const space = c.req.param("space") || "demo"; const buyId = c.req.param("id"); return c.html(renderShell({ @@ -1728,7 +1724,9 @@ export const cartModule: RSpaceModule = { acceptsFeeds: ["economic", "data"], outputPaths: [ { path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" }, - { path: "products", name: "Products", icon: "🛍️", description: "Print-on-demand product catalog" }, + { path: "catalog", name: "Catalog", icon: "🛍️", description: "Print-on-demand product catalog" }, { path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" }, + { path: "payments", name: "Payments", icon: "💳", description: "Payment requests and invoices" }, + { path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" }, ], }; diff --git a/modules/rflows/lib/transak-onramp.ts b/modules/rflows/lib/transak-onramp.ts index fe55757..6895a93 100644 --- a/modules/rflows/lib/transak-onramp.ts +++ b/modules/rflows/lib/transak-onramp.ts @@ -11,7 +11,7 @@ export class TransakOnrampAdapter implements OnrampProvider { name = 'Transak'; isAvailable(): boolean { - return !!(process.env.TRANSAK_API_KEY && process.env.TRANSAK_SECRET); + return !!process.env.TRANSAK_API_KEY; } async createSession(req: OnrampSessionRequest): Promise { @@ -32,7 +32,7 @@ export class TransakOnrampAdapter implements OnrampProvider { }; if (req.returnUrl) widgetParams.redirectURL = req.returnUrl; - const widgetUrl = await createTransakWidgetUrl(widgetParams); + const widgetUrl = createTransakWidgetUrl(widgetParams); return { widgetUrl, provider: 'transak' }; } } diff --git a/shared/transak.ts b/shared/transak.ts index 4ed52a5..5f304ce 100644 --- a/shared/transak.ts +++ b/shared/transak.ts @@ -1,87 +1,24 @@ /** * Transak API utilities — shared across rFlows and rCart. * - * Handles access token management (cached 6 days) and - * widget URL generation via Transak's session API. + * 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. */ -let _transakAccessToken: string | null = null; -let _transakTokenExpiry = 0; - -export 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 TRANSAK_WIDGET_BASE = 'https://global.transak.com'; +const TRANSAK_WIDGET_BASE_STG = 'https://global-stg.transak.com'; +export function createTransakWidgetUrl(params: Record): string { const env = process.env.TRANSAK_ENV || 'PRODUCTION'; - const baseUrl = env === 'PRODUCTION' - ? 'https://api.transak.com' - : 'https://api-stg.transak.com'; + const base = env === 'PRODUCTION' ? TRANSAK_WIDGET_BASE : TRANSAK_WIDGET_BASE_STG; - 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('[transak] Access token refreshed'); - return _transakAccessToken; -} - -export 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; + const url = new URL(base); + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== '') { + url.searchParams.set(key, value); } - throw new Error(`Transak widget URL failed (${res.status}): ${text}`); } - const data = await res.json() as any; - return data.data?.widgetUrl; + return url.toString(); }