Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
574 changes: 572 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions packages/stash-pay/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,61 @@ failures from the checkout page. Invalid URLs and mount errors use **`onError`**
- `loadTimeout` remains **opt-in** for slow or unreachable hosts (e.g. a valid-looking
URL whose server never responds).
- `update()` with a new invalid `checkoutUrl` still emits `onError` only (does not throw).

---

## Already on 2.2.x? Upgrading to 2.3.0

**2.3.0 is a minor release with a Safari / WebKit behavior change.** Chromium and
other non-WebKit hosts are unchanged (iframe drawer). No pie-mono / DNS / CNAME
work is required — partners only need an npm bump of `@stashgg/stash-pay`.

### What changed

On WebKit (desktop Safari + all iOS browsers), the SDK **no longer mounts the
checkout iframe**. It **same-tab redirects** with `location.assign(checkoutUrl)`
so Apple Pay / Google Pay run first-party with **no extra tab**.

| | ≤ 2.2.x | 2.3.0 |
| --- | --- | --- |
| Chrome / Edge / Firefox | iframe drawer | iframe drawer (unchanged) |
| Safari / iOS | iframe drawer (GPay often fails; checkout may open a 2nd `dpm=gpay` tab) | same-tab redirect to checkout |

### Callbacks / return path (important)

After redirect the **host page is gone**. Do **not** expect in-page `onSuccess` /
`onFailure` / `onClose` on WebKit for that session. Fulfillment stays on
**server webhooks**. Return the user to the game/store via checkout **success /
cancel return URLs** (or existing “back to game” configuration) in your Stash
Pay integration.

`onOpen` and `onReady` still fire immediately before `location.assign` (ready
has no iframe load to wait on).

### What you should do

1. Bump `@stashgg/stash-pay` to `^2.3.0` and republish / redeploy the host.
2. Confirm webhook + return-URL handling covers Safari (drawer callbacks will not).
3. Optional: handle `onTopLevelNavigation` for analytics before the redirect.
4. Optional opt-out: `preferRedirectOnWebKit: false` keeps the iframe path
(wallets on Safari will still hit the known GPay iframe failure / possible
second-tab handoff inside checkout).

```tsx
<StashPay
isOpen={open}
checkoutUrl={url}
onTopLevelNavigation={({ url, mode }) => {
// mode === 'redirect' — host is about to navigate to `url`
}}
onSuccess={(e) => {
/* Chrome drawer path only — not WebKit after redirect */
}}
onClose={() => setOpen(false)}
/>
```

### Unchanged

- Chromium iframe path, success/failure latching, postMessage envelopes.
- `openExternalBrowser` bridge helper (orthogonal — used when partners keep Safari iframes).
52 changes: 52 additions & 0 deletions packages/stash-pay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ try {
| `allowedCheckoutHosts` | `string[]` | — | Optional host allowlist for `checkoutUrl`. Entries are exact hosts (`pay.stash.gg`) or `*.domain` wildcards (apex + any subdomain). If set and the URL's host is not allowed, pre-flight validation fails with `DOMAIN_NOT_ALLOWED`. Distinct from `iframe.allowedOrigins`, which validates `postMessage` origins. |
| `loadTimeout` | `number` (ms) | — | If the checkout iframe does not load within this many ms, `onError` fires with `NETWORK_ERROR`. Omitted or `0` disables the timeout (opt-in). |
| `debug` | `boolean` | `false` | When `true`, logs SDK lifecycle and callback traces via `console.log` (`[stash-pay]` prefix). |
| `preferRedirectOnWebKit` | `boolean` | `true` | On WebKit (desktop Safari + all iOS browsers), **same-tab redirect** to checkout via `location.assign` instead of the iframe drawer so Apple Pay / Google Pay can complete with **no extra tab**. Chromium always keeps the iframe drawer. Set `false` to force the iframe path (not recommended for Safari wallets). |
| `onTopLevelNavigation` | `(info: { url: string; mode: 'redirect' }) => void` | — | Fired immediately before WebKit same-tab redirect. |
| `injectStyles` | `boolean` | UMD: `true`, else `false` | Runtime `<style>` injection toggle. |
| `cspNonce` | `string` | — | Applied to the injected `<style>` when runtime injection is enabled. |
| `onOpen / onClose / onReady / onSuccess / onFailure / onProcessing` | fn | — | Callbacks. |
Expand Down Expand Up @@ -407,6 +409,55 @@ window.parent.postMessage(
Both envelopes resolve to the same typed `onSuccess` callback — pick
whichever is easier to emit from the checkout page.

## Safari / WebKit (same-tab redirect)

On **WebKit** (desktop Safari and every iOS browser), Google Pay cannot complete
inside a **cross-origin iframe** — storage partitioning breaks the
`pay.google.com` popup handoff (`OR_BIBED_15`, crash/reload loops). Starting in
**2.3.0**, the SDK detects WebKit and **skips the iframe drawer**: it navigates
the **same tab** to checkout with `location.assign(checkoutUrl)` so wallets run
first-party. **No second tab** (unlike checkout’s in-iframe `?dpm=gpay` handoff).

| Engine | Behavior |
| --- | --- |
| WebKit (Safari / iOS) | Same-tab `location.assign` — host page leaves; no iframe, no `window.open` |
| Chromium / others | Unchanged iframe drawer |

### Callbacks after redirect

After `location.assign`, the **host page unloads**. In-page `onSuccess`,
`onFailure`, and `onClose` for that session **will not run**. Purchase
completion remains via **server webhooks** / your backend (the existing Stash
Pay model). To return the player to the game/store after pay, configure checkout
**success / cancel return URLs** (or equivalent “back to game” behavior) in your
Stash Pay integration — do not rely on the drawer callbacks on WebKit.

Before navigation the SDK still fires, in order:

1. `onTopLevelNavigation({ url, mode: 'redirect' })` (if provided)
2. `onOpen`
3. `onReady` (immediately — there is no iframe load event)

…then `location.assign`. Anything after that is best-effort and usually lost.

### Opt-out

```ts
open({ checkoutUrl, preferRedirectOnWebKit: false });
```

Not recommended for wallet flows on Safari. `isWebKitEngine()` is exported if
hosts need the same detection.

### Why not a new tab?

Partners asked for **no extra tabs**. Same-tab redirect is the SDK-only way to
get first-party wallets without a partner DNS / same-origin proxy. A
`window.open` path would also leave the host alive for `postMessage` callbacks,
but creates a second surface — explicitly rejected for this release.

## Security notes

## Security notes

- The default iframe `sandbox` includes `allow-same-origin` — this is required for the bridge installation, for the checkout page to read its own cookies, redirect through 3DS providers, and drive the webhook round-trip. Override via `iframe.sandbox` if you understand the implications.
Expand All @@ -426,6 +477,7 @@ See [MIGRATION.md](./MIGRATION.md). Highlights:
- Callbacks now fire **before** the auto-close animation starts.
- Width prop unchanged; new `height`, `position`, `backdrop`, `theme`, `iframe`, dismiss and auto-close flags are all additive.
- **2.2.x:** pre-flight failures from `open()` throw `StashPayError` and return no handle — see [Upgrading to 2.2.x](./MIGRATION.md#already-on-21x-upgrading-to-22x) in MIGRATION.md.
- **2.3.0:** on WebKit, checkout **same-tab redirects** (not the iframe drawer) — see [Safari / WebKit](#safari--webkit-same-tab-redirect) and [Upgrading to 2.3.0](./MIGRATION.md#already-on-22x-upgrading-to-230).

## Browser support

Expand Down
8 changes: 6 additions & 2 deletions packages/stash-pay/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stashgg/stash-pay",
"version": "2.2.4",
"version": "2.3.0",
"description": "Embeddable Stash Pay checkout SDK — React component + framework-agnostic core + script-tag UMD.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -35,6 +35,8 @@
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rimraf dist"
},
"keywords": [
Expand Down Expand Up @@ -71,8 +73,10 @@
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"happy-dom": "^17.6.3",
"rimraf": "^6.0.1",
"tsup": "^8.3.5",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.7"
}
}
132 changes: 132 additions & 0 deletions packages/stash-pay/src/core/__tests__/controller-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StashPayController } from "../controller";

const CHECKOUT_URL = "https://checkout.stash.gg/pay/abc";

function stubWebKitNavigator(): void {
vi.stubGlobal("navigator", {
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
platform: "MacIntel",
maxTouchPoints: 0,
});
}

function stubChromiumNavigator(): void {
vi.stubGlobal("navigator", {
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
platform: "MacIntel",
maxTouchPoints: 0,
});
}

describe("StashPayController WebKit same-tab redirect", () => {
let openSpy: ReturnType<typeof vi.fn>;
let assignSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
openSpy = vi.fn();
assignSpy = vi.fn();
vi.stubGlobal("open", openSpy);
Object.defineProperty(window, "location", {
configurable: true,
value: {
...window.location,
assign: assignSpy,
href: "http://localhost/",
},
});
document.body.innerHTML = "";
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.body.innerHTML = "";
});

it("redirects same-tab on WebKit (no iframe, no window.open)", () => {
stubWebKitNavigator();
const onTopLevelNavigation = vi.fn();
const onOpen = vi.fn();
const onReady = vi.fn();

const controller = new StashPayController({
checkoutUrl: CHECKOUT_URL,
onTopLevelNavigation,
onOpen,
onReady,
});
controller.mount();

expect(openSpy).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
expect(onTopLevelNavigation).toHaveBeenCalledWith({
url: CHECKOUT_URL,
mode: "redirect",
});
expect(onOpen).toHaveBeenCalled();
expect(onReady).toHaveBeenCalled();
expect(assignSpy).toHaveBeenCalledWith(CHECKOUT_URL);
expect(controller.isOpen).toBe(true);

controller.destroy();
});

it("fires onOpen and onReady before location.assign", () => {
stubWebKitNavigator();
const order: string[] = [];
const controller = new StashPayController({
checkoutUrl: CHECKOUT_URL,
onOpen: () => order.push("open"),
onReady: () => order.push("ready"),
onTopLevelNavigation: () => order.push("nav"),
});
assignSpy.mockImplementation(() => order.push("assign"));
controller.mount();

expect(order).toEqual(["nav", "open", "ready", "assign"]);
controller.destroy();
});

it("opt-out preferRedirectOnWebKit keeps iframe path on WebKit", () => {
stubWebKitNavigator();
const controller = new StashPayController({
checkoutUrl: CHECKOUT_URL,
preferRedirectOnWebKit: false,
});
controller.mount();

expect(assignSpy).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).not.toBeNull();
controller.destroy();
});

it("Chromium keeps iframe drawer", () => {
stubChromiumNavigator();
const controller = new StashPayController({
checkoutUrl: CHECKOUT_URL,
});
controller.mount();

expect(assignSpy).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).not.toBeNull();
controller.destroy();
});

it("still validates checkoutUrl before redirect", () => {
stubWebKitNavigator();
const onError = vi.fn();
const controller = new StashPayController({
checkoutUrl: "not-a-url",
onError,
});

expect(() => controller.mount()).toThrow();
expect(onError).toHaveBeenCalled();
expect(assignSpy).not.toHaveBeenCalled();
});
});
Loading