Skip to main content
A small Express server that emulates a merchant’s own origin (or sub-path) in front of the Yuno stack, so both white-label surfaces can be exercised end-to-end from a non-Yuno origin before shipping a partner-hosted integration:
  • 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-checkout app (normally checkout.<env>.y.uno) fronted by the merchant origin/sub-path, so checkout.y.uno/payment?session=… can be mapped onto their.host/<base-path>/payment?session=….
This is a companion to the White Label page. Read it first for the SDK-side overrides this harness is built to verify.

Why this server exists

The Yuno Web SDK historically ships with a yuno- 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 — so the hosted checkout can 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

Three invariants make the harness useful:
  1. The browser only ever talks to the proxy origin. No *.y.uno request originates from the page itself — otherwise the SDK is not really being tested against a foreign host.
  2. 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.
  3. CORS is owned by the proxy, not the upstream. Upstream CORS would respond with api.y.uno origins; 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 sees Upgrade requests.

Request lifecycle

  1. Base-path strip runs before everything else, and only when BASE_PATH is set. It rewrites req.url / req.originalUrl to 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. See BASE_PATH — sub-path mounting.
  2. CORS middleware runs next. It echoes the caller’s Origin, handles OPTIONS preflight, and sets Access-Control-Allow-Credentials: true. The proxy is the CORS authority.
  3. Local routes match before anything else: /, /static/*, /whitelabel-info. These never touch an upstream.
  4. Backend pass-through matches /v1/* and /v2/* (the Yuno REST surface used by the SDK) and forwards via node-fetch to BACKEND_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 browser Cookie header 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 to api.y.uno and can trip the upstream WAF with a 403; the API authenticates via the public API key.
  5. Checkout BFF pass-through matches /checkout-bff/* and forwards to CHECKOUT_BFF_UPSTREAM, sharing the same forwarder as the backend (body re-serialized, hop-by-hop and access-control-* and Cookie stripped). The sdk-checkout app calls its BFF through the white-labeled apiUrl (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).
  6. Checkout SPA routes match /payment, /payment/status, and /enroll (GET), registered before the SDK catch-all. Each fetches CHECKOUT_UPSTREAM/index.html and rewrites the app’s absolute asset origin (PUBLIC_URL, e.g. checkout.<env>.y.uno) to BASE_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 from window.location.
  7. 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.
    The static / icons branches exist because the SDK used to load these straight from 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.uno regardless of the target environment.
  8. 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.
The proxy resolves the canonical version once at boot and rewrites every incoming /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. A merchant white-labels Yuno’s hosted checkout by mapping checkout.<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 /enroll each fetch CHECKOUT_UPSTREAM/index.html. The served HTML’s asset links are absolute (PUBLIC_URL = checkout.<env>.y.uno), so the proxy rewrites that origin to BASE_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 from window.location.
  • BFF calls — the checkout app’s axios baseURL is the white-labeled apiUrl (host/<BASE_PATH>/checkout-bff/v1). After the base-path strip the proxy sees /checkout-bff/* and forwards it to CHECKOUT_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 to BACKEND_WS_URL.
The BFF host is <env>.y.uno directly — 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.
The sdk-checkout app gates the white-labeled apiUrl / assetUrl against an allowlist (REACT_APP_WHITE_LABEL_ALLOWED_HOSTS). To exercise this through the proxy, localhost must be on that allowlist in the target environment — otherwise the app ignores the override and calls Yuno directly.
To try it, open 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:
  1. Template injection. The proxy serves pages/index.html with the literal __SDK_MAIN_JS__ placeholder replaced by this path. A partner page can write <script src="__SDK_MAIN_JS__"> and get e.g. /v1.9.4/main.js.
  2. Version normalization target. The proxy parses the /v<x>/ segment out of SDK_MAIN_JS and rewrites any incoming /v<other>/main.js (and adjacent /v<other>/*.js, /v<other>/*.css) request to that same version. So if SDK_MAIN_JS=/v1.9.4/main.js, a request for /v1.5.0/main.js is silently fetched as /v1.9.4/main.js from SDK_UPSTREAM.

Resolution order at boot

If steps 1 and 2 both fail, you get the fallback and a warning is logged. Setting SDK_MAIN_JS explicitly is the only way to skip the upstream fetch entirely — 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 regex 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:
Every page now loads 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):
Useful in air-gapped environments or when SDK_UPSTREAM is a local file server that does not expose versions.json. Reproduce a customer bug on an older version:
As long as v1.7.4 is still published on SDK_UPSTREAM, every browser that hits the proxy loads it — no rebuild required.

Gotchas

  • Card and 3DS upstreams are not affected. SDK_MAIN_JS only 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 404 for /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

  1. Prefix strip. A top middleware rewrites req.url / req.originalUrl to drop BASE_PATH so everything downstream operates on root-relative paths. WebSocket upgrades get the same strip on the underlying http.Server.
  2. 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.
  3. /whitelabel-info reports the resolved basePath (or null at root) alongside the upstream config.

Format

Leading slash, no trailing slash (trailing slashes are stripped). Empty or unset = root mount.

Partner page setup

When BASE_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 the apiUrl / assetUrl must include BASE_PATH, or requests land at the root and the strip is never exercised.
  • Strip is exact-prefix. Only BASE_PATH, BASE_PATH/…, and BASE_PATH?… are rewritten. A path that merely resembles the prefix is left alone.
  • WebSocket upgrades are stripped too — on the http.Server upgrade handler, 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

Echo the caller’s origin (not *) 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:
Filter these out of both inbound (request to upstream) and outbound (upstream to client) headers. Forgetting 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

Register the catch-all last. It matches everything, so any middleware added after it is unreachable unless you guard it explicitly.

7. Handle WebSocket upgrades on the http.Server

Express middleware does not see Upgrade requests. Hook into the http server directly:
http-proxy negotiates ws:// vs wss:// from the target URL scheme.

8. Wire up environment variables

Use dotenv. Document per-environment values, not just defaults. For Yuno:
  • SDK services follow <service>[.<env>].y.unosdk-web.y.uno, sdk-web.staging.y.uno, sdk-web.dev.y.uno.
  • API surface uses api[-<env>].y.unoapi.y.uno, api-staging.y.uno, api-dev.y.uno. Note the hyphen, not a dot.
  • The WebSocket service has no api- prefix — <env>.y.uno directly (y.uno, staging.y.uno, dev.y.uno).
These naming inconsistencies cause more 404s than any other class of bug. Encode them in your .env.example so users don’t guess.

9. Ship a minimal landing page

Serve index.html at / and a /whitelabel-info JSON endpoint that returns the resolved upstream config. The landing page calls the JSON endpoint and renders it — 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.use after 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 include localhost.
  • Hop-by-hop headers are bidirectional. Strip them on both legs. content-length is the one that bites.
  • Body consumption is a one-shot. Once express.json() parses a request, the underlying stream is drained. Re-serialize from req.body if you need to forward a POST.
  • 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.uno asset URLs onto the white-label host, so the proxy must forward /icons, /css, /brands, /c2psdk.prod.y.uno and /sdk-web, /flags, bare /*.pngicons.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>' } (or assetUrl).
  • Don’t forward the partner page’s cookies to the API. Strip Cookie on /v1//v2 forwards — the browser’s localhost cookies (session, analytics, URL-valued ones like spage) don’t belong on a cross-origin call to api.y.uno and can trip the upstream WAF with a 403. The API authenticates via the public API key.
  • WebSocket auth is whatever headers http-proxy sees. Do not strip cookie or authorization from WS upgrades — the cookie strip applies only to the HTTP /v1//v2 backend forwards.
  • .env is gitignored. Always keep .env.example in sync — it is the spec for anyone cloning the repo.

Verification checklist

Once your proxy is running, verify in browser DevTools:
  1. Network tab — no requests to any *.y.uno host originate from the merchant page. All traffic is to the proxy origin.
  2. window.SdkPayments is defined; window.Yuno is either undefined or a legacy alias.
  3. sdk-payments-ready event fires on document after the SDK boots.
  4. 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 the yuno-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