glitch-javascript-sdk 3.10.7 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/browser/hosting-runtime.js +12 -2
- package/dist/browser/hosting-runtime.js.map +1 -1
- package/dist/cjs/index.js +876 -6
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/api/FestivalNetworking.d.ts +286 -0
- package/dist/esm/api/GameShows.d.ts +22 -0
- package/dist/esm/api/Messages.d.ts +4 -1
- package/dist/esm/api/Microtransactions.d.ts +554 -0
- package/dist/esm/api/index.d.ts +2 -0
- package/dist/esm/index.d.ts +8 -0
- package/dist/esm/index.js +766 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/routes/FestivalNetworkingRoute.d.ts +7 -0
- package/dist/esm/routes/MicrotransactionsRoute.d.ts +8 -0
- package/dist/esm/util/MicrotransactionBridge.d.ts +93 -0
- package/dist/esm/util/MicrotransactionOverlay.d.ts +59 -0
- package/dist/esm/util/Requests.d.ts +5 -3
- package/dist/index.d.ts +1010 -5
- package/guides/microtransactions.md +385 -0
- package/package.json +7 -4
- package/src/api/FestivalNetworking.ts +216 -0
- package/src/api/GameShows.ts +24 -0
- package/src/api/Messages.ts +4 -1
- package/src/api/Microtransactions.ts +509 -0
- package/src/api/index.ts +2 -0
- package/src/index.ts +8 -0
- package/src/routes/FestivalNetworkingRoute.ts +33 -0
- package/src/routes/GameShowsRoute.ts +1 -0
- package/src/routes/MicrotransactionsRoute.ts +46 -0
- package/src/util/MicrotransactionBridge.ts +193 -0
- package/src/util/MicrotransactionOverlay.ts +302 -0
- package/src/util/Requests.ts +14 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Route from './interface';
|
|
2
|
+
import HTTP_METHODS from '../constants/HttpMethods';
|
|
3
|
+
|
|
4
|
+
/** Consumer-facing commerce routes. Signed provider webhooks are deliberately not client APIs. */
|
|
5
|
+
class MicrotransactionsRoute {
|
|
6
|
+
public static routes: { [key: string]: Route } = {
|
|
7
|
+
uploadMedia: { url: '/titles/{title_id}/microtransactions/media', method: HTTP_METHODS.POST },
|
|
8
|
+
mcpUploadMedia: { url: '/mcp/v1/titles/{title_id}/microtransactions/media', method: HTTP_METHODS.POST },
|
|
9
|
+
settings: { url: '/titles/{title_id}/microtransactions/settings', method: HTTP_METHODS.GET },
|
|
10
|
+
updateSettings: { url: '/titles/{title_id}/microtransactions/settings', method: HTTP_METHODS.PUT },
|
|
11
|
+
readiness: { url: '/titles/{title_id}/microtransactions/readiness', method: HTTP_METHODS.GET },
|
|
12
|
+
products: { url: '/titles/{title_id}/microtransactions/products', method: HTTP_METHODS.GET },
|
|
13
|
+
createProduct: { url: '/titles/{title_id}/microtransactions/products', method: HTTP_METHODS.POST },
|
|
14
|
+
updateProduct: { url: '/titles/{title_id}/microtransactions/products/{product_id}', method: HTTP_METHODS.PUT },
|
|
15
|
+
archiveProduct: { url: '/titles/{title_id}/microtransactions/products/{product_id}/archive', method: HTTP_METHODS.POST },
|
|
16
|
+
providers: { url: '/titles/{title_id}/microtransactions/providers', method: HTTP_METHODS.GET },
|
|
17
|
+
earnings: { url: '/titles/{title_id}/microtransactions/earnings', method: HTTP_METHODS.GET },
|
|
18
|
+
orders: { url: '/titles/{title_id}/microtransactions/orders', method: HTTP_METHODS.GET },
|
|
19
|
+
order: { url: '/titles/{title_id}/microtransactions/orders/{order_id}', method: HTTP_METHODS.GET },
|
|
20
|
+
refund: { url: '/titles/{title_id}/microtransactions/orders/{order_id}/refund', method: HTTP_METHODS.POST },
|
|
21
|
+
replayDelivery: { url: '/titles/{title_id}/microtransactions/deliveries/{delivery_id}/replay', method: HTTP_METHODS.POST },
|
|
22
|
+
catalog: { url: '/titles/{title_id}/microtransactions/catalog', method: HTTP_METHODS.GET },
|
|
23
|
+
createQuote: { url: '/titles/{title_id}/microtransactions/quotes', method: HTTP_METHODS.POST },
|
|
24
|
+
createCheckoutSession: { url: '/titles/{title_id}/microtransactions/checkout-sessions', method: HTTP_METHODS.POST },
|
|
25
|
+
createRestoreSession: { url: '/titles/{title_id}/microtransactions/restore-sessions', method: HTTP_METHODS.POST },
|
|
26
|
+
checkoutSession: { url: '/titles/{title_id}/microtransactions/checkout-sessions/{session_id}', method: HTTP_METHODS.GET },
|
|
27
|
+
checkoutFramePolicy: { url: '/titles/{title_id}/microtransactions/checkout-sessions/{session_id}/frame-policy', method: HTTP_METHODS.GET },
|
|
28
|
+
framePolicy: { url: '/titles/{title_id}/microtransactions/frame-policy', method: HTTP_METHODS.GET },
|
|
29
|
+
authenticateCheckoutSession: { url: '/titles/{title_id}/microtransactions/checkout-sessions/{session_id}/authenticate', method: HTTP_METHODS.POST },
|
|
30
|
+
checkout: { url: '/titles/{title_id}/microtransactions/checkout-sessions/{session_id}/checkout', method: HTTP_METHODS.POST },
|
|
31
|
+
reconcileCheckout: { url: '/titles/{title_id}/microtransactions/checkout-sessions/{session_id}/reconcile', method: HTTP_METHODS.POST },
|
|
32
|
+
createHandoff: { url: '/titles/{title_id}/microtransactions/checkout-sessions/{session_id}/handoff', method: HTTP_METHODS.POST },
|
|
33
|
+
claimHandoff: { url: '/titles/{title_id}/microtransactions/handoffs/claim', method: HTTP_METHODS.POST },
|
|
34
|
+
restoreHandoff: { url: '/titles/{title_id}/microtransactions/handoffs/restore', method: HTTP_METHODS.POST },
|
|
35
|
+
verifyIntegration: { url: '/titles/{title_id}/microtransactions/integration/verify', method: HTTP_METHODS.POST },
|
|
36
|
+
entitlements: { url: '/titles/{title_id}/microtransactions/entitlements', method: HTTP_METHODS.GET },
|
|
37
|
+
myPurchases: { url: '/titles/{title_id}/microtransactions/me/purchases', method: HTTP_METHODS.GET },
|
|
38
|
+
consume: { url: '/titles/{title_id}/microtransactions/consume', method: HTTP_METHODS.POST },
|
|
39
|
+
requestRefund: { url: '/titles/{title_id}/microtransactions/refund-requests', method: HTTP_METHODS.POST },
|
|
40
|
+
acknowledgeDelivery: { url: '/titles/{title_id}/microtransactions/deliveries/{delivery_id}/acknowledge', method: HTTP_METHODS.POST },
|
|
41
|
+
mcpCapabilities: { url: '/mcp/v1/titles/{title_id}/microtransactions/capabilities', method: HTTP_METHODS.GET },
|
|
42
|
+
mcpOperation: { url: '/mcp/v1/titles/{title_id}/microtransactions/operations/{operation}', method: HTTP_METHODS.POST },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default MicrotransactionsRoute;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { MicrotransactionHandoffClaim } from '../api/Microtransactions';
|
|
2
|
+
|
|
3
|
+
/** A one-time notification, never a receipt or authorization to grant goods. */
|
|
4
|
+
export interface MicrotransactionPurchaseMessage {
|
|
5
|
+
type: 'glitch.microtransaction.updated';
|
|
6
|
+
version: 1;
|
|
7
|
+
title_id: string;
|
|
8
|
+
checkout_session_id: string;
|
|
9
|
+
order_id: string;
|
|
10
|
+
/** Cryptographically random value bound to the checkout session at creation. */
|
|
11
|
+
nonce: string;
|
|
12
|
+
/** Server-issued one-time code; not an account or player bearer token. */
|
|
13
|
+
claim_code: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Exact authoritative /handoffs/claim response, not the hosted session DTO. */
|
|
17
|
+
export type MicrotransactionVerifiedSession = MicrotransactionHandoffClaim;
|
|
18
|
+
|
|
19
|
+
export interface MicrotransactionBridgeOptions<T extends MicrotransactionVerifiedSession> {
|
|
20
|
+
titleId: string;
|
|
21
|
+
checkoutSessionId: string;
|
|
22
|
+
/** Exact trusted Glitch checkout origin, with no path, wildcard, or credentials. */
|
|
23
|
+
checkoutOrigin: string;
|
|
24
|
+
/** The actual Window returned by window.open or the checkout iframe.contentWindow. */
|
|
25
|
+
checkoutWindow: Window;
|
|
26
|
+
/** At least 128 bits of randomness; use createMicrotransactionNonce(). */
|
|
27
|
+
nonce: string;
|
|
28
|
+
/**
|
|
29
|
+
* Exchange message.claim_code at Glitch using claimHandoff(titleId,
|
|
30
|
+
* {claim_code, nonce, return_origin: window.location.origin,
|
|
31
|
+
* checkout_session_id}). Return response.data.data, the actual claim DTO.
|
|
32
|
+
* The message has already passed source/origin/nonce checks but is still NOT
|
|
33
|
+
* proof of payment. The server validates and consumes the one-time code.
|
|
34
|
+
* Never exchange a site-wide login token with the game or put credentials in
|
|
35
|
+
* postMessage, analytics, logs, or query strings.
|
|
36
|
+
*/
|
|
37
|
+
verify: (message: MicrotransactionPurchaseMessage) => Promise<T>;
|
|
38
|
+
/**
|
|
39
|
+
* Restore using the previously verified scoped token plus getOrder and
|
|
40
|
+
* listEntitlements. Return the claim identity/token with current inventory.
|
|
41
|
+
* Never redeem the code again. When its token expires, return the player to
|
|
42
|
+
* the authenticated hosted flow to obtain a fresh scoped handoff.
|
|
43
|
+
*/
|
|
44
|
+
refresh?: (previous: T) => Promise<T>;
|
|
45
|
+
/** Refresh display/inventory from the verified result. Make local effects idempotent. */
|
|
46
|
+
onVerified: (result: T) => void | Promise<void>;
|
|
47
|
+
/** A failed refresh is not a failed payment. Keep the same session and retry. */
|
|
48
|
+
onError?: (error: unknown) => void;
|
|
49
|
+
/** Explicit local development only; production checkout must use HTTPS. */
|
|
50
|
+
allowLocalDevelopment?: boolean;
|
|
51
|
+
/** Defaults to window. Useful for browser integration tests. */
|
|
52
|
+
eventTarget?: Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Restore authenticates an existing receipt in Glitch and creates a new session. */
|
|
56
|
+
export interface MicrotransactionRestoreBridgeOptions<T extends MicrotransactionVerifiedSession> extends Omit<MicrotransactionBridgeOptions<T>, 'checkoutSessionId'> {
|
|
57
|
+
/** Previously verified receipt/order ID. This, not the old expired session ID, is pinned. */
|
|
58
|
+
orderId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MicrotransactionBridge {
|
|
62
|
+
/**
|
|
63
|
+
* Refresh only after a successful claim, using options.refresh and its scoped
|
|
64
|
+
* token. Concurrent refreshes share one request. A lost first-claim response
|
|
65
|
+
* requires a fresh handoff from the authenticated hosted page, not code replay
|
|
66
|
+
* or a new payment. No automatic polling or token refresh is performed.
|
|
67
|
+
*/
|
|
68
|
+
refresh(): Promise<void>;
|
|
69
|
+
/** Remove the listener. In-flight results cannot call onVerified after disposal. */
|
|
70
|
+
dispose(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Generate a 256-bit browser nonce. Fails closed without secure Web Crypto. */
|
|
74
|
+
export function createMicrotransactionNonce(): string {
|
|
75
|
+
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
|
|
76
|
+
throw new Error('Secure Web Crypto is required for a checkout nonce.');
|
|
77
|
+
}
|
|
78
|
+
const bytes = new Uint8Array(32);
|
|
79
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
80
|
+
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Listen for Glitch-hosted, game-branded checkout changes with strict origin,
|
|
85
|
+
* source, title, session, and nonce binding. Message data cannot grant an item.
|
|
86
|
+
* The caller always verifies the session at Glitch before updating inventory.
|
|
87
|
+
*
|
|
88
|
+
* Prefer openMicrotransactionOverlay with the exact server-returned URL. The
|
|
89
|
+
* game stays mounted; no top-level navigation fallback is permitted. If an
|
|
90
|
+
* embedded flow is unavailable, show retry/close and preserve the game state.
|
|
91
|
+
* refresh() requires an already verified claim. Keep
|
|
92
|
+
* secrets in memory/session storage or a URL fragment, never query parameters.
|
|
93
|
+
* Dispose on game unmount/account change. Reconnect restores ownership through
|
|
94
|
+
* the authenticated entitlement API, not a saved "purchase successful" flag.
|
|
95
|
+
*/
|
|
96
|
+
export function createMicrotransactionBridge<T extends MicrotransactionVerifiedSession>(
|
|
97
|
+
options: MicrotransactionBridgeOptions<T>
|
|
98
|
+
): MicrotransactionBridge {
|
|
99
|
+
if (!options.checkoutSessionId) throw new Error('The original checkout session is required.');
|
|
100
|
+
return createBridge(options, options.checkoutSessionId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Restore-only bridge for /games/:titleId/purchases/restore. Pin the prior order,
|
|
105
|
+
* fresh nonce, exact Glitch origin and opened window. The hosted signed-in page
|
|
106
|
+
* creates a NEW session; the server-verified claim must match that new session
|
|
107
|
+
* and the pinned order. This does not relax purchase-session binding.
|
|
108
|
+
*
|
|
109
|
+
* Open the hosted restore page, never request the account JWT in the game.
|
|
110
|
+
* verify(message) exchanges the code using message.checkout_session_id. All
|
|
111
|
+
* duplicate-code, expiry and same-player refresh safeguards still apply.
|
|
112
|
+
*/
|
|
113
|
+
export function createMicrotransactionRestoreBridge<T extends MicrotransactionVerifiedSession>(
|
|
114
|
+
options: MicrotransactionRestoreBridgeOptions<T>
|
|
115
|
+
): MicrotransactionBridge {
|
|
116
|
+
if (!/^[A-Za-z0-9_:-]{1,160}$/.test(options.orderId)) throw new Error('The original verified order is required for restore.');
|
|
117
|
+
return createBridge(options, undefined, options.orderId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createBridge<T extends MicrotransactionVerifiedSession>(
|
|
121
|
+
options: MicrotransactionBridgeOptions<T> | MicrotransactionRestoreBridgeOptions<T>,
|
|
122
|
+
checkoutSessionId?: string,
|
|
123
|
+
expectedOrderId?: string
|
|
124
|
+
): MicrotransactionBridge {
|
|
125
|
+
const origin = new URL(options.checkoutOrigin);
|
|
126
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname) || origin.hostname.endsWith('.test') || (origin.hostname === 'www.glitch.local' && origin.port === '3000');
|
|
127
|
+
if (origin.origin !== options.checkoutOrigin || origin.username || origin.password ||
|
|
128
|
+
(origin.protocol !== 'https:' && !(options.allowLocalDevelopment && local && origin.protocol === 'http:'))) {
|
|
129
|
+
throw new Error('Checkout requires an exact trusted HTTPS origin.');
|
|
130
|
+
}
|
|
131
|
+
if (!options.checkoutWindow || !options.titleId ||
|
|
132
|
+
!/^[A-Za-z0-9_-]{22,128}$/.test(options.nonce)) {
|
|
133
|
+
throw new Error('Checkout window, title, session, and secure nonce are required.');
|
|
134
|
+
}
|
|
135
|
+
const target = options.eventTarget ?? window;
|
|
136
|
+
let disposed = false;
|
|
137
|
+
let inFlight: Promise<void> | undefined;
|
|
138
|
+
let verified: T | undefined;
|
|
139
|
+
const attemptedCodes = new Set<string>();
|
|
140
|
+
|
|
141
|
+
const accept = async (result: T, orderId: string, sessionId: string): Promise<void> => {
|
|
142
|
+
if (disposed) return;
|
|
143
|
+
if (result.title_id !== options.titleId || result.checkout_session_id !== sessionId || result.order_id !== orderId ||
|
|
144
|
+
(verified && verified.player_id !== result.player_id) ||
|
|
145
|
+
typeof result.player_id !== 'string' || !result.player_id || typeof result.player_token !== 'string' || !result.player_token ||
|
|
146
|
+
!Array.isArray(result.entitlements) || !Number.isFinite(Date.parse(result.expires_at)) || Date.parse(result.expires_at) <= Date.now()) {
|
|
147
|
+
throw new Error('Checkout verification returned an invalid or differently bound claim.');
|
|
148
|
+
}
|
|
149
|
+
verified = result;
|
|
150
|
+
await options.onVerified(result);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const refresh = (): Promise<void> => {
|
|
154
|
+
if (disposed) return Promise.resolve();
|
|
155
|
+
if (inFlight) return inFlight;
|
|
156
|
+
if (!verified || !options.refresh) return Promise.reject(new Error('A verified claim and scoped-token refresh callback are required.'));
|
|
157
|
+
const previous = verified;
|
|
158
|
+
inFlight = Promise.resolve().then(() => options.refresh!(previous)).then(result => accept(result, previous.order_id, previous.checkout_session_id)).finally(() => { inFlight = undefined; });
|
|
159
|
+
return inFlight;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const listener = (event: MessageEvent<unknown>): void => {
|
|
163
|
+
if (disposed || event.origin !== options.checkoutOrigin || event.source !== options.checkoutWindow) return;
|
|
164
|
+
const value = event.data;
|
|
165
|
+
if (!value || typeof value !== 'object') return;
|
|
166
|
+
const message = value as Partial<MicrotransactionPurchaseMessage>;
|
|
167
|
+
if (message.type !== 'glitch.microtransaction.updated' || message.version !== 1 || message.title_id !== options.titleId ||
|
|
168
|
+
(checkoutSessionId && message.checkout_session_id !== checkoutSessionId) || message.nonce !== options.nonce ||
|
|
169
|
+
typeof message.checkout_session_id !== 'string' || !/^[A-Za-z0-9_:-]{1,160}$/.test(message.checkout_session_id) ||
|
|
170
|
+
(expectedOrderId && message.order_id !== expectedOrderId) ||
|
|
171
|
+
typeof message.order_id !== 'string' || !/^[A-Za-z0-9_:-]{1,160}$/.test(message.order_id) ||
|
|
172
|
+
typeof message.claim_code !== 'string' || !/^[A-Za-z0-9_-]{40,128}$/.test(message.claim_code)) return;
|
|
173
|
+
if (inFlight || attemptedCodes.has(message.claim_code) || attemptedCodes.size >= 8) return;
|
|
174
|
+
if (verified && (verified.order_id !== message.order_id || verified.checkout_session_id !== message.checkout_session_id)) return;
|
|
175
|
+
// Mark BEFORE network I/O. Even a timeout can mean the server consumed it.
|
|
176
|
+
attemptedCodes.add(message.claim_code);
|
|
177
|
+
const handoff = message as MicrotransactionPurchaseMessage;
|
|
178
|
+
inFlight = Promise.resolve().then(() => options.verify(handoff)).then(result => accept(result, handoff.order_id, handoff.checkout_session_id)).finally(() => { inFlight = undefined; });
|
|
179
|
+
void inFlight.catch(error => {
|
|
180
|
+
if (!disposed) options.onError?.(error);
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
target.addEventListener('message', listener as EventListener);
|
|
184
|
+
return {
|
|
185
|
+
refresh,
|
|
186
|
+
dispose: () => {
|
|
187
|
+
disposed = true;
|
|
188
|
+
verified = undefined;
|
|
189
|
+
attemptedCodes.clear();
|
|
190
|
+
target.removeEventListener('message', listener as EventListener);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import Microtransactions, { MicrotransactionCheckoutSession, MicrotransactionCreatedCheckoutSession, MicrotransactionHandoffClaim, MicrotransactionOrder } from '../api/Microtransactions';
|
|
2
|
+
import { createMicrotransactionBridge, MicrotransactionBridge } from './MicrotransactionBridge';
|
|
3
|
+
|
|
4
|
+
export interface MicrotransactionOverlayOptions {
|
|
5
|
+
titleId: string;
|
|
6
|
+
/** Exact configured Glitch HTTPS origin, never taken from postMessage data. */
|
|
7
|
+
checkoutOrigin: string;
|
|
8
|
+
/** Result of createCheckoutSession or createRestoreSession; keep its capability private. */
|
|
9
|
+
session: MicrotransactionCreatedCheckoutSession;
|
|
10
|
+
/** Replace displayed inventory from verified backend data; never increment blindly. */
|
|
11
|
+
onVerified: (claim: MicrotransactionHandoffClaim) => void | Promise<void>;
|
|
12
|
+
/** Pause game input/audio here. The SDK never unmounts or resets the game. */
|
|
13
|
+
onOpen?: () => void;
|
|
14
|
+
/** Resume game input/audio here. Called once, even on escape/error cleanup. */
|
|
15
|
+
onClose?: (reason: 'dismissed' | 'completed' | 'unavailable') => void;
|
|
16
|
+
/** Receipt/status-only update after close; this callback does not authorize item grants. */
|
|
17
|
+
onOrderUpdate?: (order: MicrotransactionOrder | null) => void | Promise<void>;
|
|
18
|
+
onError?: (error: unknown) => void;
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Permit HTTP loopback/.test origins only for explicit local development. */
|
|
21
|
+
allowLocalDevelopment?: boolean;
|
|
22
|
+
/** Defaults to the caller's document, including when the game itself is embedded. */
|
|
23
|
+
document?: Document;
|
|
24
|
+
/** Bounded network timeout; defaults to 15 seconds. */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
/** Per-phase iframe load/application-ready timeout; 1–60 seconds, defaults to 20 seconds. */
|
|
27
|
+
frameLoadTimeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hosted page signals usable checkout/account UI, never payment or inventory authority. */
|
|
31
|
+
export interface MicrotransactionReadyMessage {
|
|
32
|
+
type: 'glitch.microtransaction.ready';
|
|
33
|
+
version: 1;
|
|
34
|
+
title_id: string;
|
|
35
|
+
checkout_session_id: string;
|
|
36
|
+
nonce: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MicrotransactionOverlay {
|
|
40
|
+
readonly element: HTMLDialogElement;
|
|
41
|
+
readonly iframe: HTMLIFrameElement;
|
|
42
|
+
/** Refresh verified inventory, or limited receipt status before the first claim. */
|
|
43
|
+
refresh(): Promise<void>;
|
|
44
|
+
/** Removes only this modal, restores focus/input, and refreshes authoritative state. */
|
|
45
|
+
close(reason?: 'dismissed' | 'completed' | 'unavailable'): Promise<void>;
|
|
46
|
+
/** Reload only the same checkout iframe/session. Never starts another payment. */
|
|
47
|
+
retry(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let overlaySequence = 0;
|
|
51
|
+
const activeOverlays = new WeakMap<Document, MicrotransactionOverlay>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mount Glitch checkout IN the running game. The game document, URL and session
|
|
55
|
+
* stay intact. Uses a modal dialog with focus restore and a sandboxed payment
|
|
56
|
+
* iframe. No top-navigation permission or top-level/popup-blocked fallback exists.
|
|
57
|
+
* Only bank/OAuth verification may open a controlled provider window from inside
|
|
58
|
+
* the frame. If embedding is unavailable, show retry/close instead of navigating.
|
|
59
|
+
*
|
|
60
|
+
* Receipt messages must originate from this exact iframe.contentWindow and pass
|
|
61
|
+
* origin/title/session/nonce checks. The SDK redeems the one-time claim at Glitch
|
|
62
|
+
* and uses the returned scoped player token only on commerce requests. Closing
|
|
63
|
+
* does not cancel an uncertain payment, grant goods, or discard the game state.
|
|
64
|
+
*/
|
|
65
|
+
export function openMicrotransactionOverlay(options: MicrotransactionOverlayOptions): MicrotransactionOverlay {
|
|
66
|
+
const doc = options.document ?? document;
|
|
67
|
+
const win = doc.defaultView;
|
|
68
|
+
if (!win || !doc.body) throw new Error('A mounted game document is required.');
|
|
69
|
+
if (activeOverlays.has(doc)) throw new Error('A checkout is already open. Resume or close that same purchase first.');
|
|
70
|
+
const session = options.session;
|
|
71
|
+
const parsed = new URL(session.hosted_url);
|
|
72
|
+
const path = `/games/${encodeURIComponent(options.titleId)}/checkout/${encodeURIComponent(session.id)}`;
|
|
73
|
+
if (parsed.origin !== options.checkoutOrigin || parsed.pathname !== path || parsed.search || parsed.username || parsed.password ||
|
|
74
|
+
new URLSearchParams(parsed.hash.slice(1)).get('token') !== session.session_token || !session.session_token ||
|
|
75
|
+
(session.checkout_session_id && session.checkout_session_id !== session.id)) {
|
|
76
|
+
throw new Error('The hosted checkout URL does not match its title, session and capability.');
|
|
77
|
+
}
|
|
78
|
+
const timeout = Math.min(60000, Math.max(1000, options.timeoutMs ?? 15000));
|
|
79
|
+
const requestedFrameTimeout = options.frameLoadTimeoutMs ?? 20000;
|
|
80
|
+
const frameTimeout = Number.isFinite(requestedFrameTimeout) ? Math.min(60000, Math.max(1000, requestedFrameTimeout)) : 20000;
|
|
81
|
+
const beforeFocus = doc.activeElement as HTMLElement | null;
|
|
82
|
+
const beforeOverflow = doc.body.style.overflow;
|
|
83
|
+
const dialog = doc.createElement('dialog');
|
|
84
|
+
const id = `glitch-commerce-modal-${++overlaySequence}`;
|
|
85
|
+
dialog.id = id;
|
|
86
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
87
|
+
dialog.setAttribute('aria-labelledby', `${id}-title`);
|
|
88
|
+
dialog.setAttribute('role', 'dialog');
|
|
89
|
+
dialog.style.cssText = 'position:fixed;inset:16px;margin:auto;padding:0;border:1px solid #cbd5e1;border-radius:16px;width:min(1100px,calc(100vw - 32px));height:min(850px,calc(100dvh - 32px));max-width:none;max-height:none;background:#fff;color:#172033;box-shadow:0 24px 90px #0008;z-index:2147483647;overflow:hidden;';
|
|
90
|
+
const style = doc.createElement('style');
|
|
91
|
+
style.textContent = `#${id}::backdrop{background:rgba(8,16,32,.72)}#${id} button:focus-visible{outline:3px solid #4263eb;outline-offset:3px}@media(max-width:600px){#${id}{inset:0!important;width:100vw!important;height:100dvh!important;border-radius:0!important}}`;
|
|
92
|
+
const header = doc.createElement('div');
|
|
93
|
+
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-bottom:1px solid #e2e8f0;height:56px;box-sizing:border-box;';
|
|
94
|
+
const heading = doc.createElement('h2');
|
|
95
|
+
heading.id = `${id}-title`;
|
|
96
|
+
heading.textContent = options.label || (session.intent === 'restore' ? 'Restore game purchases' : 'Secure game checkout');
|
|
97
|
+
heading.style.cssText = 'margin:0;font:600 17px/1.3 system-ui,sans-serif;color:#172033;';
|
|
98
|
+
const closeButton = doc.createElement('button');
|
|
99
|
+
closeButton.type = 'button';
|
|
100
|
+
closeButton.textContent = 'Close';
|
|
101
|
+
closeButton.setAttribute('aria-label', 'Close checkout and return to game');
|
|
102
|
+
closeButton.style.cssText = 'border:1px solid #cbd5e1;border-radius:8px;padding:7px 14px;background:#fff;color:#172033;font:600 14px system-ui,sans-serif;cursor:pointer;';
|
|
103
|
+
const retryButton = doc.createElement('button');
|
|
104
|
+
retryButton.type = 'button';
|
|
105
|
+
retryButton.textContent = 'Retry';
|
|
106
|
+
retryButton.setAttribute('aria-label', 'Reload this same checkout without starting another purchase');
|
|
107
|
+
retryButton.style.cssText = closeButton.style.cssText;
|
|
108
|
+
const controls = doc.createElement('div');
|
|
109
|
+
controls.style.cssText = 'display:flex;gap:8px;';
|
|
110
|
+
controls.append(retryButton, closeButton);
|
|
111
|
+
header.append(heading, controls);
|
|
112
|
+
const status = doc.createElement('div');
|
|
113
|
+
status.setAttribute('role', 'status');
|
|
114
|
+
status.style.cssText = 'padding:6px 16px;font:12px/1.4 system-ui,sans-serif;background:#f1f5f9;color:#172033;min-height:28px;box-sizing:border-box;';
|
|
115
|
+
status.textContent = 'Your game stays open. Loading secure checkout…';
|
|
116
|
+
const iframe = doc.createElement('iframe');
|
|
117
|
+
iframe.title = heading.textContent;
|
|
118
|
+
iframe.setAttribute('allow', 'payment');
|
|
119
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox');
|
|
120
|
+
iframe.referrerPolicy = 'no-referrer';
|
|
121
|
+
iframe.style.cssText = 'display:block;width:100%;height:calc(100% - 84px);border:0;background:#fff;';
|
|
122
|
+
iframe.src = session.hosted_url;
|
|
123
|
+
dialog.append(style, header, status, iframe);
|
|
124
|
+
doc.body.append(dialog);
|
|
125
|
+
const frameWindow = iframe.contentWindow;
|
|
126
|
+
if (!frameWindow) { dialog.remove(); throw new Error('In-game checkout embedding is unavailable. Your game has not navigated.'); }
|
|
127
|
+
|
|
128
|
+
let closed = false;
|
|
129
|
+
let closing: Promise<void> | undefined;
|
|
130
|
+
let verified: MicrotransactionHandoffClaim | undefined;
|
|
131
|
+
let bridge: MicrotransactionBridge;
|
|
132
|
+
let frameLoadTimer: number | undefined;
|
|
133
|
+
let appReadyTimer: number | undefined;
|
|
134
|
+
let applicationReady = false;
|
|
135
|
+
const blocked: Array<{ element: HTMLElement; inert: boolean; ariaHidden: string | null }> = [];
|
|
136
|
+
const report = (error: unknown): void => { try { options.onError?.(error); } catch { /* Consumer error handlers cannot prevent modal cleanup. */ } };
|
|
137
|
+
const clearWatchdogs = (): void => {
|
|
138
|
+
if (frameLoadTimer !== undefined) win.clearTimeout(frameLoadTimer);
|
|
139
|
+
if (appReadyTimer !== undefined) win.clearTimeout(appReadyTimer);
|
|
140
|
+
frameLoadTimer = undefined;
|
|
141
|
+
appReadyTimer = undefined;
|
|
142
|
+
};
|
|
143
|
+
const unavailable = (reason: 'load' | 'ready' | 'error'): void => {
|
|
144
|
+
if (closed) return;
|
|
145
|
+
clearWatchdogs();
|
|
146
|
+
status.setAttribute('role', 'alert');
|
|
147
|
+
status.textContent = reason === 'ready'
|
|
148
|
+
? 'Checkout did not become ready. Retry this same checkout or Close; your game remains open.'
|
|
149
|
+
: 'Checkout could not load here. Retry this same checkout or Close; your game remains open.';
|
|
150
|
+
report(new Error(reason === 'ready'
|
|
151
|
+
? 'Embedded checkout readiness timed out; no navigation or new payment was attempted.'
|
|
152
|
+
: 'Embedded checkout loading unavailable; no navigation or new payment was attempted.'));
|
|
153
|
+
};
|
|
154
|
+
const markApplicationReady = (): void => {
|
|
155
|
+
if (closed) return;
|
|
156
|
+
applicationReady = true;
|
|
157
|
+
clearWatchdogs();
|
|
158
|
+
status.setAttribute('role', 'status');
|
|
159
|
+
status.textContent = 'Checkout is ready. Your game remains open underneath.';
|
|
160
|
+
};
|
|
161
|
+
const armLoadWatchdog = (): void => {
|
|
162
|
+
clearWatchdogs();
|
|
163
|
+
applicationReady = false;
|
|
164
|
+
frameLoadTimer = win.setTimeout(() => unavailable('load'), frameTimeout);
|
|
165
|
+
};
|
|
166
|
+
const receipt = async (): Promise<void> => {
|
|
167
|
+
const response = await Microtransactions.getCheckoutSession(options.titleId, session.id, { checkoutToken: session.session_token, timeout });
|
|
168
|
+
const current: MicrotransactionCheckoutSession = response.data.data;
|
|
169
|
+
if (current.id !== session.id || current.title.id !== options.titleId) throw new Error('Checkout status returned a different title or session.');
|
|
170
|
+
await options.onOrderUpdate?.(current.order);
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
bridge = createMicrotransactionBridge({
|
|
174
|
+
titleId: options.titleId, checkoutSessionId: session.id, checkoutOrigin: options.checkoutOrigin,
|
|
175
|
+
checkoutWindow: frameWindow, nonce: session.nonce, eventTarget: win,
|
|
176
|
+
allowLocalDevelopment: options.allowLocalDevelopment,
|
|
177
|
+
verify: async message => (await Microtransactions.claimHandoff(options.titleId, {
|
|
178
|
+
claim_code: message.claim_code, checkout_session_id: session.id, nonce: session.nonce, return_origin: win.location.origin,
|
|
179
|
+
}, { timeout })).data.data,
|
|
180
|
+
refresh: async previous => {
|
|
181
|
+
const auth = { playerToken: previous.player_token, timeout };
|
|
182
|
+
const order = (await Microtransactions.getOrder(options.titleId, previous.order_id, auth)).data.data;
|
|
183
|
+
if (order.id !== previous.order_id || order.title_id !== options.titleId) throw new Error('Restored order identity mismatch.');
|
|
184
|
+
const inventory = (await Microtransactions.listEntitlements(options.titleId, undefined, auth)).data.data;
|
|
185
|
+
await options.onOrderUpdate?.(order);
|
|
186
|
+
return { ...previous, entitlements: inventory.entitlements };
|
|
187
|
+
},
|
|
188
|
+
onVerified: async result => { markApplicationReady(); verified = result; await options.onVerified(result); },
|
|
189
|
+
onError: report,
|
|
190
|
+
});
|
|
191
|
+
} catch (error) { dialog.remove(); throw error; }
|
|
192
|
+
|
|
193
|
+
const refresh = async (): Promise<void> => {
|
|
194
|
+
if (verified) await bridge.refresh();
|
|
195
|
+
else await receipt();
|
|
196
|
+
};
|
|
197
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
198
|
+
if (event.key === 'Escape') { event.preventDefault(); void overlay.close(); }
|
|
199
|
+
if (event.key === 'Tab' && !closed) {
|
|
200
|
+
if (event.shiftKey && doc.activeElement === retryButton) { event.preventDefault(); iframe.focus(); }
|
|
201
|
+
else if (!event.shiftKey && doc.activeElement === iframe) { event.preventDefault(); retryButton.focus(); }
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const onCancel = (event: Event): void => { event.preventDefault(); void overlay.close(); };
|
|
205
|
+
const onCloseMessage = (event: MessageEvent<unknown>): void => {
|
|
206
|
+
if (closed || event.origin !== options.checkoutOrigin || event.source !== frameWindow || !event.data || typeof event.data !== 'object') return;
|
|
207
|
+
const message = event.data as Record<string, unknown>;
|
|
208
|
+
if (message.version !== 1 || message.title_id !== options.titleId ||
|
|
209
|
+
message.checkout_session_id !== session.id || message.nonce !== session.nonce) return;
|
|
210
|
+
if (message.type === 'glitch.microtransaction.ready') { markApplicationReady(); return; }
|
|
211
|
+
if (message.type !== 'glitch.microtransaction.close') return;
|
|
212
|
+
void overlay.close(verified ? 'completed' : 'dismissed');
|
|
213
|
+
};
|
|
214
|
+
const onFrameError = (): void => { unavailable('error'); };
|
|
215
|
+
const onFrameLoad = (): void => {
|
|
216
|
+
if (closed) return;
|
|
217
|
+
if (frameLoadTimer !== undefined) win.clearTimeout(frameLoadTimer);
|
|
218
|
+
frameLoadTimer = undefined;
|
|
219
|
+
if (applicationReady) return;
|
|
220
|
+
status.setAttribute('role', 'status');
|
|
221
|
+
status.textContent = 'Checkout document loaded. Waiting for the secure checkout interface…';
|
|
222
|
+
// A document load is not proof the app rendered. Repeated loads cannot
|
|
223
|
+
// indefinitely extend this bounded wait; only explicit Retry resets it.
|
|
224
|
+
if (appReadyTimer === undefined) appReadyTimer = win.setTimeout(() => unavailable('ready'), frameTimeout);
|
|
225
|
+
};
|
|
226
|
+
const overlay: MicrotransactionOverlay = {
|
|
227
|
+
element: dialog, iframe, refresh,
|
|
228
|
+
retry: () => {
|
|
229
|
+
if (closed) return;
|
|
230
|
+
status.textContent = 'Retrying the same secure checkout. Your game stays open.';
|
|
231
|
+
status.setAttribute('role', 'status');
|
|
232
|
+
armLoadWatchdog();
|
|
233
|
+
iframe.src = session.hosted_url;
|
|
234
|
+
},
|
|
235
|
+
close: (reason = 'dismissed') => {
|
|
236
|
+
if (closing) return closing;
|
|
237
|
+
if (closed) return Promise.resolve();
|
|
238
|
+
closed = true;
|
|
239
|
+
clearWatchdogs();
|
|
240
|
+
status.textContent = 'Finishing secure purchase verification. Your game stays open.';
|
|
241
|
+
closing = (async () => {
|
|
242
|
+
try {
|
|
243
|
+
// The dialog remains mounted until in-flight claim AND inventory callback finish.
|
|
244
|
+
try { await bridge.refresh(); } catch (error) { if (verified) throw error; }
|
|
245
|
+
} catch (error) { report(error); }
|
|
246
|
+
finally {
|
|
247
|
+
win.removeEventListener('keydown', onKey, true);
|
|
248
|
+
win.removeEventListener('message', onCloseMessage);
|
|
249
|
+
dialog.removeEventListener('cancel', onCancel);
|
|
250
|
+
iframe.removeEventListener('load', onFrameLoad);
|
|
251
|
+
iframe.removeEventListener('error', onFrameError);
|
|
252
|
+
dialog.remove();
|
|
253
|
+
doc.body.style.overflow = beforeOverflow;
|
|
254
|
+
for (const item of blocked) {
|
|
255
|
+
item.element.inert = item.inert;
|
|
256
|
+
if (item.ariaHidden === null) item.element.removeAttribute('aria-hidden');
|
|
257
|
+
else item.element.setAttribute('aria-hidden', item.ariaHidden);
|
|
258
|
+
}
|
|
259
|
+
activeOverlays.delete(doc);
|
|
260
|
+
beforeFocus?.isConnected && beforeFocus.focus();
|
|
261
|
+
try { options.onClose?.(reason); } catch (error) { report(error); }
|
|
262
|
+
// No claimed player token means status-only recovery, never a grant.
|
|
263
|
+
if (!verified) { try { await receipt(); } catch (error) { report(error); } }
|
|
264
|
+
bridge.dispose();
|
|
265
|
+
}
|
|
266
|
+
})();
|
|
267
|
+
return closing;
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
closeButton.addEventListener('click', () => { void overlay.close(); });
|
|
271
|
+
retryButton.addEventListener('click', overlay.retry);
|
|
272
|
+
iframe.addEventListener('error', onFrameError);
|
|
273
|
+
iframe.addEventListener('load', onFrameLoad);
|
|
274
|
+
dialog.addEventListener('cancel', onCancel);
|
|
275
|
+
win.addEventListener('keydown', onKey, true);
|
|
276
|
+
win.addEventListener('message', onCloseMessage);
|
|
277
|
+
doc.body.style.overflow = 'hidden';
|
|
278
|
+
if (typeof dialog.showModal === 'function') {
|
|
279
|
+
try { dialog.showModal(); } catch (error) { void overlay.close('unavailable'); throw error; }
|
|
280
|
+
} else {
|
|
281
|
+
dialog.setAttribute('open', '');
|
|
282
|
+
for (const child of Array.from(doc.body.children)) {
|
|
283
|
+
if (child !== dialog && child instanceof win.HTMLElement) {
|
|
284
|
+
const element = child as HTMLElement;
|
|
285
|
+
blocked.push({ element, inert: element.inert, ariaHidden: element.getAttribute('aria-hidden') });
|
|
286
|
+
element.inert = true;
|
|
287
|
+
element.setAttribute('aria-hidden', 'true');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
activeOverlays.set(doc, overlay);
|
|
292
|
+
closeButton.focus();
|
|
293
|
+
try { options.onOpen?.(); } catch (error) { void overlay.close('unavailable'); throw error; }
|
|
294
|
+
if (!closed) armLoadWatchdog();
|
|
295
|
+
return overlay;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Same in-game modal for anonymous-safe restore sessions; never opens a new payment. */
|
|
299
|
+
export function openMicrotransactionRestoreOverlay(options: MicrotransactionOverlayOptions): MicrotransactionOverlay {
|
|
300
|
+
if (options.session.intent !== 'restore') throw new Error('Create a restore session before opening purchase recovery.');
|
|
301
|
+
return openMicrotransactionOverlay(options);
|
|
302
|
+
}
|
package/src/util/Requests.ts
CHANGED
|
@@ -226,7 +226,8 @@ class Requests {
|
|
|
226
226
|
file: File | Blob,
|
|
227
227
|
data?: any,
|
|
228
228
|
params?: Record<string, any>,
|
|
229
|
-
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
|
229
|
+
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void,
|
|
230
|
+
options?: Pick<AxiosRequestConfig, 'signal' | 'timeout'>
|
|
230
231
|
): AxiosPromise<Response<T>> {
|
|
231
232
|
// Process URL and params
|
|
232
233
|
if (params && Object.keys(params).length > 0) {
|
|
@@ -268,6 +269,8 @@ class Requests {
|
|
|
268
269
|
data: formData,
|
|
269
270
|
headers,
|
|
270
271
|
onUploadProgress,
|
|
272
|
+
signal: options?.signal,
|
|
273
|
+
timeout: options?.timeout,
|
|
271
274
|
});
|
|
272
275
|
}
|
|
273
276
|
|
|
@@ -435,7 +438,7 @@ class Requests {
|
|
|
435
438
|
|
|
436
439
|
|
|
437
440
|
|
|
438
|
-
public static processRoute<T>(route: Route, data?: object, routeReplace?: { [key: string]: any }, params?: Record<string, any>): AxiosPromise<Response<T>> {
|
|
441
|
+
public static processRoute<T>(route: Route, data?: object, routeReplace?: { [key: string]: any }, params?: Record<string, any>, options?: Pick<AxiosRequestConfig, 'signal' | 'timeout' | 'headers'> & { excludeCommunityContext?: boolean }): AxiosPromise<Response<T>> {
|
|
439
442
|
let url = route.url;
|
|
440
443
|
|
|
441
444
|
if (routeReplace) {
|
|
@@ -444,6 +447,15 @@ class Requests {
|
|
|
444
447
|
}
|
|
445
448
|
}
|
|
446
449
|
|
|
450
|
+
if (options) {
|
|
451
|
+
const query = { ...params, ...(Requests.community_id && !options.excludeCommunityContext ? { community_id: Requests.community_id } : {}) };
|
|
452
|
+
return axios({
|
|
453
|
+
method: route.method, url: Requests.buildUrl(url, query), data,
|
|
454
|
+
headers: { 'Content-Type': 'application/json', ...(Requests.authToken ? { Authorization: `Bearer ${Requests.authToken}` } : {}), ...options.headers },
|
|
455
|
+
signal: options.signal, timeout: options.timeout,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
447
459
|
if (route.method == HTTP_METHODS.GET) {
|
|
448
460
|
return Requests.get(url, params);
|
|
449
461
|
} else if (route.method == HTTP_METHODS.POST) {
|