- SDK white-label: the Yuno Web SDK loaded from a foreign host, verifying the renamed globals, events, and DOM tokens.
- Hosted checkout white-label (Payment Link): the
sdk-checkoutapp (normallycheckout.<env>.y.uno) fronted by the merchant origin/sub-path, socheckout.y.uno/payment?session=…can be mapped ontotheir.host/<base-path>/payment?session=….
Why this server exists
The Yuno Web SDK historically ships with ayuno- prefixed surface: window.Yuno, the yuno-sdk-ready event, and yuno-* CSS classes and data attributes. The white-label release introduces a parallel surface:
To verify the rename actually shipped, the SDK must be loaded from a host that is not
*.y.uno. If it loads from a Yuno origin, branded leaks would slip through unnoticed. This server is the harness: it serves the SDK from localhost:9090 (or any non-Yuno hostname you point it at) and forwards every request to the real Yuno upstreams transparently.
The same harness also fronts the hosted Payment Link checkout: it serves the sdk-checkout SPA routes from the proxy origin, rewrites the app’s asset origin back onto itself, and forwards the checkout BFF and status WebSocket upstream. This lets the hosted checkout be white-labeled onto a merchant origin/sub-path without any *.y.uno request leaking from the page. See Hosted checkout white-label (Payment Link).
How it works
- The browser only ever talks to the proxy origin. No
*.y.unorequest originates from the page itself; otherwise the SDK is not really being tested against a foreign host. - All upstream domains are reachable from the proxy. SDK assets, the card-form micro-app, the 3DS micro-app, the REST API, and the WebSocket service are all separate hostnames that must each be configurable.
- CORS is owned by the proxy, not the upstream. Upstream CORS would respond with
api.y.unoorigins; the proxy strips those and writes its own, echoing the caller.
Architecture
A single Node.js process built on three libraries:express: routing and middleware for HTTP.node-fetch: outbound HTTP to upstreams, for streaming bodies and header control.http-proxy: WebSocket upgrades, because Express middleware never seesUpgraderequests.
Request lifecycle
-
Base-path strip runs before everything else, and only when
BASE_PATHis set. It rewritesreq.url/req.originalUrlto drop the configured sub-path so all downstream matching and forwarding sees root-relative paths. A no-op (not even registered) for a root mount. SeeBASE_PATH: sub-path mounting. -
CORS middleware runs next. It echoes the caller’s
Origin, handlesOPTIONSpreflight, and setsAccess-Control-Allow-Credentials: true. The proxy is the CORS authority. -
Local routes match before anything else:
/,/static/*,/whitelabel-info. These never touch an upstream. -
Backend pass-through matches
/v1/*and/v2/*(the Yuno REST surface used by the SDK) and forwards vianode-fetchtoBACKEND_URL. Hop-by-hop headers are stripped;access-control-*headers from the upstream are dropped so they cannot override the proxy’s own CORS. The browserCookieheader is also dropped on these forwards. The partner page’s localhost cookies (session, analytics, URL-valued ones, …) don’t belong on a cross-origin call toapi.y.unoand can trip the upstream WAF with a403; the API authenticates via the public API key. -
Checkout BFF pass-through matches
/checkout-bff/*and forwards toCHECKOUT_BFF_UPSTREAM, sharing the same forwarder as the backend (body re-serialized, hop-by-hop andaccess-control-*andCookiestripped). Thesdk-checkoutapp calls its BFF through the white-labeledapiUrl(host/<base-path>/checkout-bff/v1), so after the base-path strip the proxy sees/checkout-bff/*(GET /checkout-bff/v1/checkout-info/{session},POST /checkout-bff/v1/checkout/payment). See Hosted checkout white-label (Payment Link). -
Checkout SPA routes match
/payment,/payment/status, and/enroll(GET), registered before the SDK catch-all. Each fetchesCHECKOUT_UPSTREAM/index.htmland rewrites the app’s absolute asset origin (PUBLIC_URL, e.g.checkout.<env>.y.uno) toBASE_PATH, so the CRA bundle, favicon, and manifest load back through the proxy instead of leaking to the Yuno host. react-router resolves the actual route client-side fromwindow.location. -
SDK/asset catch-all matches every remaining
GET/HEAD. A small dispatcher (pickSdkUpstream) picks the right upstream based on path, checked top to bottom so the checkout/3DS/card rules win first:- Checkout app assets: the CRA bundle (
/static/(js|css|media)/*) and root public files (/favicon.ico,/manifest.json,/robots.txt,/asset-manifest.json) →CHECKOUT_UPSTREAM. Checked first so they don’t fall through to the SDK icon upstream. - 3DS micro-app paths (
/challenge.html,/redirect.html,/session-id.html, and/assets/(challenge|redirect|session-id|validate-url)*) →SDK_3DS_UPSTREAM. - Card micro-app paths (
/v<semver>/pages/*,/v<semver>/assets/*) →SDK_CARD_UPSTREAM. - Static-asset paths (
/icons/*,/css/*,/brands/*,/c2p/*) →SDK_STATIC_UPSTREAM(sdk.prod.y.uno). - Icon-asset paths (
/sdk-web/*,/flags/*, and bare root brand images like/Visa.png,/boleto_logosimbolo.png) →SDK_ICONS_UPSTREAM(icons.prod.y.uno). - Everything else →
SDK_UPSTREAM.
icons.prod.y.uno/sdk.prod.y.uno, bypassing the white-label host. Recent SDK builds host-swap them onto the proxy origin (path preserved), so the proxy must forward them back out to the two CDNs by path prefix. These upstreams are always*.prod.y.unoregardless of the target environment. - Checkout app assets: the CRA bundle (
-
WebSocket upgrades are handled on the underlying
http.Server, not Express./checkout-websocket-notification-ms/ws/{payment,enrollment}→BACKEND_WS_URL(the hosted-checkout status stream); everything else follows the same SDK / card / 3DS split as HTTP.
Version normalization
The main SDK bundle is versioned (/v1.7.4/main.js). Two complications:
- Partner pages may hardcode a version that the upstream no longer publishes.
- The “current” version drifts over time.
/v<x>/* SDK request to that version before sending it upstream. Card-app and 3DS paths are not rewritten; they have their own publish cadences.
Hosted checkout white-label (Payment Link)
A merchant white-labels Yuno’s hosted checkout by mappingcheckout.<env>.y.uno/payment?session=… onto their own host/<BASE_PATH>/payment?session=…. The harness emulates this locally with two upstreams: CHECKOUT_UPSTREAM (the sdk-checkout app shell) and CHECKOUT_BFF_UPSTREAM (the BFF it calls):
- SPA routes:
/payment,/payment/status, and/enrolleach fetchCHECKOUT_UPSTREAM/index.html. The served HTML’s asset links are absolute (PUBLIC_URL=checkout.<env>.y.uno), so the proxy rewrites that origin toBASE_PATH, keeping the CRA bundle, favicon, and manifest on the proxy origin instead of leaking back to the Yuno host. react-router resolves the actual route client-side fromwindow.location. - BFF calls: the checkout app’s axios
baseURLis the white-labeledapiUrl(host/<BASE_PATH>/checkout-bff/v1). After the base-path strip the proxy sees/checkout-bff/*and forwards it toCHECKOUT_BFF_UPSTREAM(GET /checkout-bff/v1/checkout-info/{session},POST /checkout-bff/v1/checkout/payment). - Status WebSocket:
/checkout-websocket-notification-ms/ws/{payment,enrollment}is forwarded toBACKEND_WS_URL.
The BFF host is
<env>.y.uno directly, with no api- prefix (sandbox.y.uno, prod.y.uno), unlike the SDK’s REST surface on api[-env].y.uno. Set CHECKOUT_UPSTREAM and CHECKOUT_BFF_UPSTREAM to the same environment as your checkout session.http://localhost:9090<BASE_PATH>/payment?session=<checkout-session> with a matching CHECKOUT_UPSTREAM / CHECKOUT_BFF_UPSTREAM for the session’s environment. The checkout renders under the non-Yuno origin and calls the BFF back through the proxy.
SDK_MAIN_JS — pinning the SDK version
SDK_MAIN_JS decides which build of the SDK every browser loads. It does two related things:
- Template injection. The proxy serves
pages/index.htmlwith the literal__SDK_MAIN_JS__placeholder replaced by this path. A partner page can write<script src="__SDK_MAIN_JS__">and get, for example,/v1.9.4/main.js. - Version normalization target. The proxy parses the
/v<x>/segment out ofSDK_MAIN_JSand rewrites any incoming/v<other>/main.js(and adjacent/v<other>/*.js,/v<other>/*.css) request to that same version. So ifSDK_MAIN_JS=/v1.9.4/main.js, a request for/v1.5.0/main.jsis silently fetched as/v1.9.4/main.jsfromSDK_UPSTREAM.
Resolution order at boot
SDK_MAIN_JS explicitly is the only way to skip the upstream fetch entirely. This is useful when versions.json is unreachable, you want a deterministic startup, or the upstream’s latest is not what you want to test against.
Format
<semver> matches [\d.]+(?:-[\w.]+)?: dot-separated numbers with an optional pre-release suffix.
The path must start with
/v and contain /main.js for normalization to work. If the regular expression does not match, the path is used for template injection but version rewriting is disabled.
Common use cases
Test a specific SDK release end-to-end:v1.9.4, and any hardcoded /v<other>/*.js in partner pages is silently rewritten to v1.9.4 against SDK_UPSTREAM.
Quiet startup (no upstream versions.json call):
SDK_UPSTREAM is a local file server that does not expose versions.json.
Reproduce a customer bug on an older version:
v1.7.4 is still published on SDK_UPSTREAM, every browser that hits the proxy loads it, with no rebuild required.
Gotchas
- Card and 3DS upstreams are not affected.
SDK_MAIN_JSonly pins the main SDK bundle’s version. The card micro-app (/v<x>/pages/*) and the 3DS micro-app keep their own version segments. - Setting it disables auto-detection. If a newer SDK version is published upstream, you keep loading the pinned version until you change the env var and restart.
- Restart required. The value is read once at boot. No hot reload.
- Mismatch with upstream is silent. Pinning to a version the upstream does not publish results in a
404for/v<your-pin>/main.js. Check the network tab to spot it. - No query strings or hash. Path only.
BASE_PATH — sub-path mounting
A partner gateway is often mounted under a sub-path rather than at the origin root. Zuora, for example, serves the SDK from https://host/hosted-payment-methods/hosted-payment-form/orchestrator instead of https://host/. Recent SDK builds preserve that prefix on every asset, API, and WebSocket request (the configured apiUrl / assetUrl carry it), so a request for an icon arrives as …/orchestrator/sdk-web/foo.svg, not /sdk-web/foo.svg.
Without help, those prefixed paths would never match the proxy’s root-level routing. BASE_PATH closes the gap: set it to the sub-path the proxy is “mounted” under, and the proxy strips the prefix from every incoming request (local routes, the /v1–/v2 backend forward, the asset catch-all, and WebSocket upgrades) before routing. The existing root-level rules then match unchanged.
Leave it empty for a root mount (the default: behaviour is identical to not having the feature).
What it changes
- Prefix strip. A top middleware rewrites
req.url/req.originalUrlto dropBASE_PATHso everything downstream operates on root-relative paths. WebSocket upgrades get the same strip on the underlyinghttp.Server. - Template injection. The
__SDK_MAIN_JS__placeholder is emitted with the prefix, so the served landing page points at<BASE_PATH>/v<x>/main.js. /whitelabel-inforeports the resolvedbasePath(ornullat root) alongside the upstream config.
Format
Partner page setup
WhenBASE_PATH is set, the partner page must carry the same prefix in two places:
- Load the bundle from
http://localhost:9090<BASE_PATH>/v<ver>/main.js. - Initialize with
apiUrl/assetUrl=http://localhost:9090<BASE_PATH>.
Versioned
assetUrl. You can pin a bundle version on assetUrl (…/orchestrator/v1.0). The SDK uses that path for JS chunks but strips the trailing /v<semver> when resolving host-swapped CDN assets (icons and fonts aren’t versioned), so both assetUrl = …/orchestrator and assetUrl = …/orchestrator/v1.0 route icons correctly.Gotchas
- The prefix lives in the URL, not a header. Both the bundle
<script src>and theapiUrl/assetUrlmust includeBASE_PATH, or requests land at the root and the strip is never exercised. - Strip is exact-prefix. Only
BASE_PATH,BASE_PATH/…, andBASE_PATH?…are rewritten. A path that merely resembles the prefix is left alone. - WebSocket upgrades are stripped too, on the
http.Serverupgradehandler, since Express middleware never sees them.
Building a server like this
1. Bootstrap the project
Use
node-fetch@2 (CommonJS) unless you have a build step; v3 is ESM-only.2. Define your upstream map
SDK_CARD_UPSTREAM and SDK_3DS_UPSTREAM default to SDK_UPSTREAM when unset, and BACKEND_WS_URL defaults to BACKEND_URL. Keep this fallback behaviour, it makes single-env testing easier. SDK_STATIC_UPSTREAM / SDK_ICONS_UPSTREAM are the exception: they default to the fixed sdk.prod.y.uno / icons.prod.y.uno CDNs (the SDK host-swaps these from *.prod.y.uno regardless of environment), not to SDK_UPSTREAM. CHECKOUT_UPSTREAM / CHECKOUT_BFF_UPSTREAM default to sandbox (checkout.sandbox.y.uno / sandbox.y.uno); the checkout-asset rule is checked before the SDK icon rules so the CRA bundle and root files (/favicon.ico, …) don’t fall through to icons.prod.y.uno.
If you need to emulate a partner gateway mounted under a sub-path, register a strip middleware before all routes that rewrites
req.url / req.originalUrl to drop BASE_PATH (and apply the same strip on the http.Server upgrade handler). Every route below then sees root-relative paths. Skip it entirely when BASE_PATH is empty. See BASE_PATH: sub-path mounting.3. Set up CORS as the proxy’s responsibility
*) so credentialed requests work, and short-circuit preflights. When you later forward upstream responses, strip every access-control-* header from the upstream or it will override yours.
4. Filter hop-by-hop headers in both directions
Per RFC 7230 §6.1:content-length is the classic bug: node-fetch sets it, but if you also copy the original value the response truncates.
5. Forward the API surface
express.json() has already consumed the request body, so for non-GET methods you re-serialize req.body. Don’t try to req.pipe(upstream) after the body is consumed.
Drop the
Cookie header before forwarding. The partner page’s browser cookies belong to the proxy origin (session, analytics, URL-valued ones like spage) and a genuine cross-origin call to api.y.uno would never carry them. Forwarding them only risks the upstream WAF rejecting the request with a 403. The API authenticates via the public API key, not cookies. (WebSocket upgrades are the exception; see step 7.)6. Catch-all proxy for SDK assets
7. Handle WebSocket upgrades on the http.Server
Express middleware does not seeUpgrade requests. Hook into the http server directly:
http-proxy negotiates ws:// vs wss:// from the target URL scheme.
8. Wire up environment variables
Usedotenv. Document per-environment values, not just defaults. For Yuno:
- SDK services follow
<service>[.<env>].y.uno:sdk-web.y.uno,sdk-web.staging.y.uno,sdk-web.dev.y.uno. - API surface uses
api[-<env>].y.uno:api.y.uno,api-staging.y.uno,api-dev.y.uno. Note the hyphen, not a dot. - The WebSocket service has no
api-prefix; it’s<env>.y.unodirectly (y.uno,staging.y.uno,dev.y.uno).
.env.example so users don’t guess.
9. Ship a minimal landing page
Serveindex.html at / and a /whitelabel-info JSON endpoint that returns the resolved upstream config. The landing page calls the JSON endpoint and renders it, so anyone debugging the proxy sees exactly what’s routed where without grepping logs.
Pitfalls and edge cases
- Route order matters. The SDK catch-all is registered last. Anything you
app.useafter it never runs. - CORS authority must be the proxy. Letting upstream
access-control-*headers through means credentialed cross-origin requests randomly fail when the upstream’s allow-list does not includelocalhost. - Hop-by-hop headers are bidirectional. Strip them on both legs.
content-lengthis the one that bites. - Body consumption is a one-shot. Once
express.json()parses a request, the underlying stream is drained. Re-serialize fromreq.bodyif you need to forward aPOST. - Streaming, not buffering, for assets.
upstream.body.pipe(res)keeps memory flat even for multi-MB bundles. - Version normalization is
SDK_UPSTREAM-only. Card and 3DS micro-apps have their own version segments; rewriting them breaks the upstream URL. The static / icons CDNs aren’t versioned, so they aren’t normalized either. - Static CDN assets are host-swapped too. Recent SDK builds rewrite their hard-coded
icons.prod.y.uno/sdk.prod.y.unoasset URLs onto the white-label host, so the proxy must forward/icons,/css,/brands,/c2p→sdk.prod.y.unoand/sdk-web,/flags, bare/*.png→icons.prod.y.uno. Older SDK builds load these straight from the CDN and never reach the proxy. Exercising this end-to-end needs a recent SDK build and a partner page that inits with{ apiUrl: '<proxy origin>' }(orassetUrl). - Don’t forward the partner page’s cookies to the API. Strip
Cookieon/v1//v2forwards. The browser’s localhost cookies (session, analytics, URL-valued ones likespage) don’t belong on a cross-origin call toapi.y.unoand can trip the upstream WAF with a403. The API authenticates via the public API key. - WebSocket auth is whatever headers
http-proxysees. Do not stripcookieorauthorizationfrom WS upgrades; the cookie strip applies only to the HTTP/v1//v2backend forwards. .envis gitignored. Always keep.env.examplein sync; it is the spec for anyone cloning the repo.
Verification checklist
Once your proxy is running, verify in browser DevTools:- Network tab: no requests to any
*.y.unohost originate from the merchant page. All traffic is to the proxy origin. window.SdkPaymentsis defined;window.Yunois either undefined or a legacy alias.sdk-payments-readyevent fires ondocumentafter the SDK boots.- No
yuno-*DOM tokens in the rendered output, except for the trust-mark allow-list (yuno-badge,secure-by-yuno,yuno-typography-provider,yuno-c2p,yuno-redirect,yuno-debug,data-yuno-session-id).
Reference implementation
The full source lives in theyuno-sdk-web/white-label-proxy-server repository. About 350 lines in server.js; the whole thing fits in one file because there is no business logic, just routing and header hygiene.
See also
- White Label (Web SDK): the SDK-side overrides this harness verifies.
- yuno-payments/yuno-sdk-web/tree/main/white-label-proxy-server: full source.
- RFC 7230 §6.1: Connection-specific headers
- http-proxy WebSocket docs