glitch-javascript-sdk 3.10.8 → 4.0.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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Node 24+ example: verify and durably queue Glitch Ed25519 notifications.
3
+ * Run behind your HTTPS reverse proxy. This is NOT an inventory grant engine:
4
+ * a worker/game session refreshes current entitlements with its own authorized
5
+ * player session after durable queue handling. Never apply payload snapshots.
6
+ */
7
+ import { createPublicKey, verify } from 'node:crypto';
8
+ import { createServer } from 'node:http';
9
+ import { DatabaseSync } from 'node:sqlite';
10
+ import { isAbsolute } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ const uuid = /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/;
14
+ function reject(code, status = 400) { const error = new Error(code); error.code = code; error.status = status; throw error; }
15
+ function decodeBase64(value, length) {
16
+ if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) reject('invalid_base64');
17
+ const bytes = Buffer.from(value, 'base64');
18
+ if (bytes.length !== length || bytes.toString('base64') !== value) reject('invalid_base64');
19
+ return bytes;
20
+ }
21
+
22
+ /** Configuration comes from authenticated delivery-settings, never the message. */
23
+ export function createDeliveryInbox({ titleId, environment, keyId, publicKeyBase64, databasePath }) {
24
+ if (!uuid.test(titleId) || !uuid.test(keyId) || !['sandbox', 'live'].includes(environment)) reject('invalid_receiver_configuration');
25
+ if (typeof databasePath !== 'string' || !isAbsolute(databasePath)) reject('durable_absolute_database_path_required');
26
+ // Ed25519 raw public keys need the standard SubjectPublicKeyInfo DER wrapper.
27
+ const publicKey = createPublicKey({ key: Buffer.concat([
28
+ Buffer.from('302a300506032b6570032100', 'hex'), decodeBase64(publicKeyBase64, 32)
29
+ ]), format: 'der', type: 'spki' });
30
+ const db = new DatabaseSync(databasePath);
31
+ db.exec('PRAGMA busy_timeout=5000');
32
+ db.exec(`CREATE TABLE IF NOT EXISTS commerce_notifications (
33
+ title_id TEXT NOT NULL, environment TEXT NOT NULL, event_id TEXT NOT NULL,
34
+ order_id TEXT NOT NULL, event_type TEXT NOT NULL, order_version INTEGER NOT NULL,
35
+ received_at INTEGER NOT NULL, handled_at INTEGER,
36
+ PRIMARY KEY(title_id, environment, event_id)
37
+ )`);
38
+ const existing = db.prepare('SELECT order_id,event_type FROM commerce_notifications WHERE title_id=? AND environment=? AND event_id=?');
39
+ const insert = db.prepare('INSERT INTO commerce_notifications(title_id,environment,event_id,order_id,event_type,order_version,received_at) VALUES(?,?,?,?,?,?,?)');
40
+
41
+ return {
42
+ /** rawBody must be the EXACT bytes received before any JSON parser. */
43
+ receive(headers, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
44
+ if (!Buffer.isBuffer(rawBody) || rawBody.length > 1048576) reject('invalid_body_size', 413);
45
+ const h = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
46
+ if (h['x-glitch-signature-algorithm'] !== 'ed25519' || h['x-glitch-key-id'] !== keyId) reject('unexpected_signing_key_or_algorithm', 401);
47
+ const timestamp = h['x-glitch-timestamp'];
48
+ if (typeof timestamp !== 'string' || !/^[0-9]{1,12}$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > 300) reject('expired_timestamp', 401);
49
+ const signature = decodeBase64(h['x-glitch-signature'], 64);
50
+ const signed = Buffer.concat([Buffer.from(timestamp + '.', 'ascii'), rawBody]);
51
+ if (!verify(null, signed, publicKey, signature)) reject('invalid_signature', 401);
52
+ let event;
53
+ try { event = JSON.parse(rawBody.toString('utf8')); } catch { reject('invalid_json'); }
54
+ if (!event || event.title_id !== titleId || event.environment !== environment || !uuid.test(event.id || '')
55
+ || event.id !== h['x-glitch-event-id'] || typeof event.type !== 'string'
56
+ || !Number.isSafeInteger(event.order_version) || event.order_version < 0
57
+ || !event.authoritative_order || !uuid.test(event.authoritative_order.id || '')
58
+ || event.authoritative_order.title_id !== titleId || event.authoritative_order.environment !== environment) reject('event_scope_mismatch', 401);
59
+ const orderId = event.authoritative_order.id;
60
+ let duplicate = false;
61
+ db.exec('BEGIN IMMEDIATE');
62
+ try {
63
+ const prior = existing.get(titleId, environment, event.id);
64
+ if (prior) {
65
+ if (prior.order_id !== orderId || prior.event_type !== event.type) reject('event_identity_conflict', 409);
66
+ duplicate = true;
67
+ } else {
68
+ // Queue IDs only. Aggregate balances in an older signed notification
69
+ // are not safe to apply across out-of-order events from other orders.
70
+ insert.run(titleId, environment, event.id, orderId, event.type, event.order_version, nowSeconds);
71
+ }
72
+ db.exec('COMMIT');
73
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
74
+ // Retry bodies may carry refreshed authoritative facts. The stable event
75
+ // identity is deduped; embedded inventory is NEVER reapplied on a retry.
76
+ return { event_id: event.id, duplicate };
77
+ },
78
+ close() { db.close(); },
79
+ };
80
+ }
81
+
82
+ /** Complete HTTP receiver. JSON/body middleware must not run before this handler. */
83
+ export function createDeliveryServer(inbox) {
84
+ return createServer(async (request, response) => {
85
+ if (request.method !== 'POST' || request.url !== '/glitch/commerce') { response.writeHead(404).end(); return; }
86
+ try {
87
+ const chunks = []; let size = 0;
88
+ for await (const chunk of request) {
89
+ size += chunk.length;
90
+ if (size > 1048576) reject('invalid_body_size', 413);
91
+ chunks.push(chunk);
92
+ }
93
+ const acknowledgement = inbox.receive(request.headers, Buffer.concat(chunks));
94
+ response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
95
+ response.end(JSON.stringify(acknowledgement)); // Only after durable commit.
96
+ } catch (error) {
97
+ response.writeHead(error.status || 500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
98
+ response.end(JSON.stringify({ error: error.code || 'delivery_handling_failed' }));
99
+ // Do not log raw payload, signatures, credentials or private player data.
100
+ }
101
+ });
102
+ }
103
+
104
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
105
+ process.umask(0o077); // Keep the new application inbox/journal private.
106
+ const inbox = createDeliveryInbox({
107
+ titleId: process.env.GLITCH_TITLE_ID,
108
+ environment: process.env.GLITCH_COMMERCE_ENVIRONMENT,
109
+ keyId: process.env.GLITCH_DELIVERY_KEY_ID,
110
+ publicKeyBase64: process.env.GLITCH_DELIVERY_PUBLIC_KEY,
111
+ databasePath: process.env.GAME_DELIVERY_DATABASE,
112
+ });
113
+ const server = createDeliveryServer(inbox);
114
+ server.listen(Number(process.env.PORT || 8787), '127.0.0.1');
115
+ const stop = () => server.close(() => { inbox.close(); process.exit(0); });
116
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
117
+ }
@@ -0,0 +1,466 @@
1
+ # Game microtransactions
2
+
3
+ **Player checkout/history minimum: SDK `3.15.0`, which is published.** The new
4
+ administrative direct-management API below is a **major SDK `4.0.0` migration**.
5
+ Verify usable published versions independently before installing; a reviewed
6
+ local4.0 candidate is not proof of publication. Server-side MCP catalog/provider
7
+ setup does not require installing or publishing the game SDK, so do not block
8
+ authorized server configuration while runtime package work is pending.
9
+
10
+ The SDK entry point is `Glitch.api.Microtransactions`. All HTTP methods return an
11
+ Axios response whose `response.data.data` contains the typed commerce result.
12
+ Configure the ordinary API base once using the existing Glitch configuration.
13
+ Never put a developer/MCP/install token into a shipped browser game.
14
+
15
+ ## Developer setup
16
+
17
+ 1. Read `settings`, `readiness`, `providers` and `listProducts` with a signed-in
18
+ title administrator or scoped MCP caller. Use sandbox for development. Authorized
19
+ commerce operations execute directly; no custom confirmation/human-review gate.
20
+ The **Enable in-game purchases** and **Show ads** switches live only on the
21
+ game's Pricing/monetization page. Microtransactions is for products, media,
22
+ prices, orders and integration—not a second location for those switches.
23
+ 2. `uploadMedia(titleId, file, onProgress?)` reuses existing Glitch Media with
24
+ trusted title/actor ownership. Attach returned `id` to `media_ids` or
25
+ `branding.logo_media_id`. No scheduler or social-library post is created.
26
+ 3. Create a product draft using `createProduct`. Prices use integer minor units,
27
+ not floating-point money: USD 499 means $4.99; JPY 499 means ¥499. Supported
28
+ currencies are USD/EUR/GBP/CAD/AUD/JPY/BRL/INR/KRW; provider coverage can be
29
+ narrower. Each price is 1–100000 minor units, with country `US` or `*` fallback.
30
+ Provider-specific purchase minima are checked separately (sandbox USD: 50).
31
+ 4. Product types are durable, consumable, currency, bundle or pass. Grants have
32
+ a stable key, quantity and kind. Durable quantity is one. Pass requires
33
+ `duration_seconds` between 60 and 31536000, with no active stacking. Existing
34
+ purchased grants cannot be changed in place: create a new SKU. No recurring
35
+ subscriptions, gifts, paid random loot, cash-out or cross-game wallet support.
36
+ 5. Configure the exact game origin, regions/currencies, support contact and
37
+ game name/accent/logo with `updateSettings`. The fixed commission is 1200bp
38
+ (12%) of discounted pre-tax subtotal. Actual provider costs are separate.
39
+ Taxes are separate; pending earnings are not a verified available payout.
40
+ 6. Run a real approved provider sandbox purchase, verify game delivery/claim,
41
+ then call `verifyIntegration(titleId, {order_id})`. This records
42
+ real evidence, not a self-certified integration checkbox. Actual external
43
+ provider/account/tax capability and global sales emergency controls still apply.
44
+
45
+ ## SDK4.0 administrative migration
46
+
47
+ Player checkout, restore, inventory and self-history routes remain compatible with
48
+ SDK3.15. The administrative changes are deliberately major:
49
+
50
+ - `refundOrder(titleId,orderId,{reason,amount_minor?,idempotency_key},options?)`
51
+ now requires a stable caller key. The SDK never generates it. Legacy `confirm`
52
+ is optional/ignored, not authorization. Keep one refund intent outside retries;
53
+ same key + changed payload conflicts, and unknown outcomes stay pinned.
54
+ - `providers(titleId,{environment}?,options?)` returns factual `configured` and
55
+ `available` values instead of an `approved` badge. `account` is the platform
56
+ processor; `payout_account` is the game's target and must be evaluated separately.
57
+ The older second-argument request-options overload remains compatible.
58
+ - `listProducts(titleId,{page,per_page,status,sku}?,options?)` now discovers the
59
+ complete catalog. `sku` matches exactly; status is draft/active/archived. Product
60
+ pages default to 200 records (1–200), unlike financial/history lists below.
61
+ Follow `pagination.has_more_pages`; absence on page one is not proof a SKU is
62
+ unused. Query the exact SKU after an uncertain create before retrying it.
63
+ The no-filter call and older request-options overload remain compatible.
64
+ - `updateProvider`, `refreshProvider` and `createProviderOnboarding` manage routes
65
+ and owned Stripe onboarding. Onboarding also requires a stable key and returns
66
+ `onboarding_url` for the same owned account on retry, not an arbitrary payee ID.
67
+ - `getDeliverySettings`/`updateDeliverySettings` expose enabled/URL and only public
68
+ verification material. Private signing keys remain server-side. Actual DNS/IP,
69
+ HTTPS and provider requirements are validated without an approval workflow.
70
+ Legacy `updateSettings.webhook_url` additionally requires commerce:fulfill as
71
+ well as commerce:write; prefer the dedicated delivery-settings methods.
72
+ - `listOrders`, `listRefunds`, `listDeliveries` and `listPayouts` use page1–10000,
73
+ per_page1–100(default25) and return arrays plus pagination. Get IDs from discovery.
74
+ `getOrder` retains the player receipt path; management relationships are optional
75
+ and can be finance-redacted. Omitted fields do not prove no records exist.
76
+ - `reconcileOrder`, `getRefund` and `reconcileRefund` inspect/recover the original
77
+ provider operations. A linked refund request or pending/unknown execution is not
78
+ a completed refund. Keep `execution_refund_id`, `execution_status` and actual
79
+ `order_refunded_minor` distinct. Transfers are not automatically bank-paid payouts.
80
+ - All commerce calls omit unrelated global community context without mutating the
81
+ stored community/auth state. Other SDK features retain their own context behavior.
82
+
83
+ Example stable refund intent (authorized commerce:finance only):
84
+
85
+ ```ts
86
+ const refundIntent = {
87
+ reason: 'Customer refund', amount_minor: 199,
88
+ idempotency_key: crypto.randomUUID(), // ONCE for this intent, outside retries.
89
+ };
90
+ async function submitOrRetryRefund() {
91
+ return Glitch.api.Microtransactions.refundOrder(titleId, orderId, refundIntent);
92
+ }
93
+ // If response is unknown/lost, keep refundIntent. Inspect/reconcile its original
94
+ // operation instead of making another key or blindly starting another refund.
95
+ ```
96
+
97
+ Provider configuration reuses existing platform credentials; never send platform
98
+ Stripe/Xsolla API keys or MCP credentials as settings. A new owned title/environment
99
+ Xsolla `webhook_secret` is write-only(16–512 chars) under finance scope, encrypted
100
+ server-side and never returned/audited. Existing platform/historical bindings cannot
101
+ be overwritten. Keep this out of runtime game code, logs and raw JSON editors.
102
+ Missing external setup returns factual reasons; saving configuration is not proof
103
+ that a provider can accept payments. Global sales-off may block new purchases while
104
+ authorized configuration and historical refunds remain available.
105
+
106
+ ### Signed server-delivery receiver
107
+
108
+ [The complete Node24+ receiver example](commerce-delivery-receiver.mjs) verifies
109
+ the exact raw body before parsing and durably queues event IDs without applying
110
+ embedded inventory. Pin `title_id`, environment, `key_id` and the base64 raw32-byte
111
+ `verification_public_key` from authenticated delivery settings, never from a message.
112
+ Require algorithm `ed25519`, `X-Glitch-Key-Id`, UNIX-second `X-Glitch-Timestamp`
113
+ within300 seconds, and `X-Glitch-Event-Id === body.id`. `X-Glitch-Signature` is base64
114
+ raw64 bytes over timestamp + `.` + exact raw JSON bytes. ACK2xx `{event_id}` only
115
+ after durable handling/commit. Duplicate IDs stay deduped across restarts.
116
+
117
+ Do not increment inventory or replace aggregate balances from webhook snapshots:
118
+ cross-order notifications can arrive out of order. An authorized game/player
119
+ refreshes current `listEntitlements`, or a real server adapter uses monotonic
120
+ inventory revisions. Legacy HMAC delivery is a separate configured algorithm;
121
+ leave its original secrets unchanged and reject message-selected algorithm/key
122
+ downgrades. The example needs Node24 only; it does not change the core SDK/MCP
123
+ runtime requirement or replace actual payment/browser3DS verification.
124
+
125
+ Product limits: SKU/grant key 1–100 alphanumeric/underscore/dot/hyphen characters;
126
+ name 255 characters; description 4000; 10 distinct Media UUIDs; 50 prices; 30
127
+ distinct grants; 30 localizations. Settings allow 20 exact origins (255 chars
128
+ each), 100 countries and 20 supported currencies. Branding name maximum 100.
129
+ Server capability schemas remain authoritative.
130
+
131
+ Required product fields are **SKU\***, **Name\***, **Type\***, **Prices\*** and
132
+ **Grants\***; each price needs currency, country and integer minor-unit amount,
133
+ and each grant needs key, quantity and kind. A pass also needs duration in seconds.
134
+ For a pack of **100 Timber** spent building things, choose product type `currency`
135
+ (or `consumable`) and grant `{key:'timber',quantity:100,kind:'consumable'}`. A durable
136
+ grant means lasting ownership and cannot be spent; it is wrong for building Timber.
137
+
138
+ ## Hosted checkout and account creation
139
+
140
+ `createCheckoutSession(titleId, {product_id,quantity,country,currency,environment,
141
+ channel:'web',return_origin,nonce})` is anonymous-safe and only opens the purchase
142
+ flow. Use `createMicrotransactionNonce()` to generate the nonce and retain it in
143
+ the game. `hosted_url` points to Glitch's game-branded checkout with the session
144
+ secret in a `#token` fragment, never a query parameter.
145
+
146
+ Use `openMicrotransactionOverlay` to mount an accessible modal iframe inside the
147
+ running game, even if the game is itself embedded. The game document, URL,
148
+ session, canvas and state remain intact. Do not navigate the game away for checkout
149
+ or provide a top-level fallback. The iframe permits payments and controlled
150
+ bank/OAuth verification windows but has no top-navigation sandbox permission.
151
+ When embedding or required verification is unavailable, offer retry/close while
152
+ preserving the game. Retry reloads only the same session, not a new charge.
153
+
154
+ The overlay bounds each document-loading and application-ready wait to 20 seconds
155
+ by default (`frameLoadTimeoutMs`, clamped to 1–60 seconds). A missing iframe load
156
+ or error event cannot leave the player indefinitely at Loading. Timeout displays
157
+ explicit Retry/Close guidance without initiating payment or navigating the game.
158
+ A document `load` only starts the bounded application-ready wait; it is not proof
159
+ that checkout rendered. The hosted page sends
160
+ `{type:'glitch.microtransaction.ready',version:1,title_id,checkout_session_id,nonce}`
161
+ only after its valid-session account/checkout UI is usable. Exact origin/source/
162
+ session/nonce checks protect this signal, and it never grants inventory. Retry
163
+ resets timers; verified readiness and close clear them.
164
+
165
+ The read-only `getCheckoutFramePolicy(titleId,sessionId)` returns only approved
166
+ `frame_ancestors` and expiry; the hosted document uses that server-owned policy.
167
+ It does not expose a checkout token or player data.
168
+
169
+ Inside Glitch, the page uses existing sign-in/registration/OAuth and binds the
170
+ checkout session once to that user. Session methods require
171
+ `{checkoutToken: sessionSecret}` as their last options argument. The existing
172
+ signed-in account JWT stays inside Glitch, not the game. The SDK sends the limited
173
+ session secret only in `X-Checkout-Token` and never temporarily changes global auth.
174
+
175
+ `checkout(titleId, sessionId, {idempotency_key,accept_terms:true}, options)` returns
176
+ an order, quote, provider and UI mode. Stripe uses `ui_mode:'embedded'`, a limited
177
+ `client_secret` and public `publishable_key`; mount Stripe Embedded Checkout
178
+ inside the branded page, never build custom raw card fields. Approved Xsolla
179
+ routes use `ui_mode:'xsolla'` and its official Pay Station flow. Disclose payment
180
+ methods that need external authentication.
181
+
182
+ Stripe 3DS is part of checkout. Exercise challenge success, cancel/failure,
183
+ timeout and reload in the real sandbox. Keep the same order/session/idempotency
184
+ key through authentication. `action_required`, provider authorization, redirect,
185
+ and client completion are not paid/delivered proof. Call
186
+ `reconcileCheckoutSession(titleId, sessionId, options)` to query the original
187
+ attempt; never charge another provider after an uncertain submission.
188
+
189
+ ## Verified game/account handoff
190
+
191
+ After verified payment and fulfillment, `createHandoff` returns an event with
192
+ `type:'glitch.microtransaction.updated'`, `version:1`, `title_id`,
193
+ `checkout_session_id`, `order_id`, `nonce`, and a one-time `claim_code` valid for
194
+ two minutes. The hosted page targets the exact approved game origin. It never
195
+ posts the account JWT or a player token.
196
+
197
+ The SDK does the secure return work for you: it checks the actual checkout iframe,
198
+ origin, title, session and nonce, exchanges the one-time claim with Glitch, and
199
+ then calls **your `onVerified` function with the server-verified result**. This is
200
+ where your game connects the player account and displays the purchased inventory.
201
+ You do not need to write your own `message` listener or grant items from a browser
202
+ event. Closing checkout without a successful claim does not call this a purchase.
203
+
204
+ ### Minimal working Timber shop
205
+
206
+ Copy this JavaScript module into your game's browser bundle. Call
207
+ `installTimberShop` with the title/product IDs and API/checkout origins shown by
208
+ your game integration settings. For local testing use your approved local origins
209
+ and `allowLocalDevelopment:true`; for production use approved HTTPS endpoints.
210
+ The example creates its own small UI, so no undefined HTML elements or game helper
211
+ functions are required. It never takes the player away from the game.
212
+
213
+ ```js
214
+ import Glitch, {
215
+ createMicrotransactionNonce,
216
+ openMicrotransactionOverlay,
217
+ openMicrotransactionRestoreOverlay,
218
+ } from 'glitch-javascript-sdk';
219
+
220
+ export function installTimberShop({ titleId, productId, apiBaseUrl, checkoutOrigin,
221
+ environment = 'sandbox', allowLocalDevelopment = false }) {
222
+ Glitch.util.Requests.setBaseUrl(apiBaseUrl); // Set API location, NOT global auth.
223
+ const api = Glitch.api.Microtransactions;
224
+ const game = { playerId: null, inventory: [], paused: false };
225
+ let playerToken = '', tokenExpiresAt = 0, overlay = null;
226
+ let opening = false, spending = false, pendingUse = null;
227
+ const shop = document.createElement('section');
228
+ const status = document.createElement('p'); status.setAttribute('role', 'status');
229
+ const timber = document.createElement('output');
230
+ const history = document.createElement('pre');
231
+ shop.append(timber, status, history); document.body.append(shop);
232
+ const say = text => { status.textContent = text; };
233
+
234
+ function replaceInventoryInYourGame(entitlements) {
235
+ game.inventory = entitlements; // REPLACE the snapshot. Never add 100 here.
236
+ timber.textContent = 'Timber: ' + (entitlements.find(x => x.key === 'timber')?.balance ?? 0);
237
+ }
238
+ function setGamePaused(paused) { game.paused = paused; }
239
+ function onVerified(result) {
240
+ game.playerId = result.player_id; // Associate the game's profile with this player.
241
+ playerToken = result.player_token; // Memory only; never a URL or global auth token.
242
+ tokenExpiresAt = Date.parse(result.expires_at);
243
+ replaceInventoryInYourGame(result.entitlements); // Already verified by Glitch.
244
+ say('Account connected. Your current inventory is ready.');
245
+ }
246
+ function playerOptions() {
247
+ if (!playerToken || Date.now() >= tokenExpiresAt) {
248
+ throw new Error('Player authentication is required; restore needs an active purchase.');
249
+ }
250
+ return { playerToken }; // Per-request token; never choose a user_id.
251
+ }
252
+ function showError(error) {
253
+ const code = error?.response?.data?.code ?? error?.code ?? error?.response?.data?.message;
254
+ const noRestore = code === 'no_purchases_to_restore'
255
+ || error?.response?.data?.message === 'No active purchases to restore for this game/account/environment.';
256
+ say(noRestore
257
+ ? 'No active purchases to restore. Refunded receipt history still belongs to this account, but no new game token or items can be granted here.'
258
+ : 'Not completed. Retry the same action. If sign-in expired, authenticate again; restore requires an active purchase.');
259
+ }
260
+ async function openShop(restore) {
261
+ if (opening || overlay) return;
262
+ opening = true;
263
+ try {
264
+ const context = { return_origin: window.location.origin,
265
+ nonce: createMicrotransactionNonce(), environment };
266
+ const response = restore
267
+ ? await api.createRestoreSession(titleId, context)
268
+ : await api.createCheckoutSession(titleId, { ...context, product_id: productId,
269
+ quantity: 1, country: 'US', currency: 'USD', channel: 'web' });
270
+ const open = restore ? openMicrotransactionRestoreOverlay : openMicrotransactionOverlay;
271
+ overlay = open({ titleId, checkoutOrigin, session: response.data.data,
272
+ allowLocalDevelopment, onVerified,
273
+ onOpen: () => setGamePaused(true),
274
+ onClose: () => { overlay = null; setGamePaused(false); },
275
+ onOrderUpdate: order => say(order ? 'Receipt: ' + order.payment_status : 'No completed receipt yet.'),
276
+ onError: showError });
277
+ } finally { opening = false; }
278
+ }
279
+ async function showMyPurchases(page = 1) {
280
+ const response = await api.listMyPurchases(titleId,
281
+ { environment, page, per_page: 20 }, playerOptions());
282
+ const data = response.data.data;
283
+ history.textContent = data.purchases.map(purchase => {
284
+ const name = purchase.product.name ?? purchase.product.sku ?? ('Purchase ' + purchase.id);
285
+ return name + '\n' + purchase.grant_usage.map(row => row.key + ': ' + row.usage_status
286
+ + '; promised ' + row.purchased_quantity + ', granted ' + row.granted_quantity
287
+ + ', consumed ' + row.consumed_quantity + ', usable ' + row.usable_quantity).join('\n');
288
+ }).join('\n\n') || 'No captured purchases yet.';
289
+ say('History page ' + data.pagination.page + ' of ' + data.pagination.last_page);
290
+ }
291
+ async function useTenTimber() {
292
+ if (spending || game.paused) return;
293
+ const options = playerOptions();
294
+ if (pendingUse && pendingUse.playerId !== game.playerId) {
295
+ throw new Error('Restore the original account before retrying its pending action.');
296
+ }
297
+ // Create ONCE for this gameplay intent. A failed retry keeps this same object.
298
+ pendingUse ??= { playerId: game.playerId, action_id: createMicrotransactionNonce(),
299
+ key: 'timber', quantity: 10 };
300
+ spending = true;
301
+ try {
302
+ await api.consume(titleId, { key: pendingUse.key, quantity: pendingUse.quantity,
303
+ action_id: pendingUse.action_id, environment }, options);
304
+ const current = await api.listEntitlements(titleId, { environment }, options);
305
+ replaceInventoryInYourGame(current.data.data.entitlements);
306
+ pendingUse = null; // Clear only after successful acknowledgement AND refresh.
307
+ say('10 Timber spent once. Inventory refreshed from Glitch.');
308
+ } finally { spending = false; } // Do not clear pendingUse on failure.
309
+ }
310
+ function button(label, action) {
311
+ const element = document.createElement('button');
312
+ element.type = 'button'; element.textContent = label;
313
+ element.onclick = () => void action().catch(showError);
314
+ shop.append(element);
315
+ }
316
+ replaceInventoryInYourGame([]);
317
+ button('Buy 100 Timber', () => openShop(false));
318
+ button('Restore purchases', () => openShop(true));
319
+ button('My purchases', () => showMyPurchases());
320
+ button('Use 10 Timber', useTenTimber); // Explicit gameplay action, NOT a purchase callback.
321
+ return game;
322
+ }
323
+ ```
324
+
325
+ Call `installTimberShop({titleId: YOUR_TITLE_ID, productId: YOUR_TIMBER_PRODUCT_ID,
326
+ apiBaseUrl: YOUR_API_BASE, checkoutOrigin: YOUR_CHECKOUT_ORIGIN})` from your existing
327
+ game initialization. These four capitalized names describe your configuration;
328
+ replace them with the actual values, not credentials. `environment` is optional
329
+ and defaults to sandbox. The example assumes an eligible US/USD price; choose a
330
+ server-supported country/currency for your real player instead of guessing from IP.
331
+
332
+ **`replaceInventoryInYourGame` and `setGamePaused` are example game functions, not
333
+ SDK APIs.** Their working demo bodies update the displayed Timber and `game`
334
+ object. In your game, replace those bodies with your engine's inventory assignment
335
+ and pause/input/audio controls. Do not replace assignment with `+= productQuantity`:
336
+ callbacks, restores and refreshes can occur more than once. The callback must never
337
+ call `consume`; that state-changing API belongs to an explicit gameplay action.
338
+
339
+ The sample retains `pendingUse` across failed button retries. For reload recovery,
340
+ persist the nonsecret action intent and its account binding in your game's durable
341
+ command queue, then retry the same action ID after sign-in. Never save the player
342
+ token with it, invent a new ID for an uncertain retry, or infer a particular action's
343
+ success from aggregate purchase-history totals. Coordinate actual building creation
344
+ idempotently with that same gameplay action; the demo only spends the resource.
345
+
346
+ The verified result is the actual claim DTO. The helper validates matching
347
+ title/session/order and, on refresh, the same player. It ignores duplicate codes
348
+ even after a network timeout because the server may already have consumed them.
349
+ It does not grant items from window data or continuously poll. A close message
350
+ uses `{type:'glitch.microtransaction.close',version:1,title_id,
351
+ checkout_session_id,nonce}` and must match the same exact origin/source/session.
352
+ The dialog remains mounted until an in-flight claim and inventory callback finish.
353
+ Close before a successful claim can refresh limited receipt status only; show
354
+ pending/restore guidance, never claim that a checkout capability grants inventory.
355
+
356
+ `player_token` lasts 15 minutes and is restricted to one title/player/environment.
357
+ Keep it in memory and pass `{playerToken}` per request to `getOrder`,
358
+ `listEntitlements` and `consume`. Never install it in global `Glitch.util.Session`
359
+ or `Requests.setAuthToken`, log it, or put it in URLs. The SDK's per-request
360
+ headers prevent simultaneous account/checkout requests from overwriting auth.
361
+
362
+ ### Inventory, purchase history and usage are different
363
+
364
+ Use `listEntitlements` to replace the current player inventory. Use the optional
365
+ `listMyPurchases(titleId, {environment,page,per_page}, {playerToken})` to show the
366
+ signed-in player's captured purchases and their lot-level usage; it does not grant
367
+ items or let a game pick another `user_id` or `player_id`. A normal user JWT selects
368
+ its own user; a `gl_player` token selects its bound title/player/environment and
369
+ requires the exact approved Origin. MCP and install tokens cannot use this route.
370
+ Admin `listOrders` remains a separate developer reporting API.
371
+
372
+ Read `response.data.data`:
373
+
374
+ - `title_id`, `player_id`, `environment`, `purchases`, and
375
+ `pagination:{page,per_page,total,last_page,has_more_pages}`.
376
+ - `page` defaults to 1 (1–10000), `per_page` to 20 (1–100). Results are ordered by
377
+ `created_at DESC, id DESC`. Pass the next page number when `has_more_pages` is true.
378
+ No cursor or product filter exists. The HTTP API rejects unknown selectors with 422;
379
+ the SDK rejects invalid filter names/ranges before transport.
380
+ - Each purchase includes the immutable product snapshot, payment/fulfillment state,
381
+ `grant_usage`, `has_consumed_grants`, and `has_usable_grants`. Captured purchases
382
+ remain visible after refund, dispute or quarantine; unpaid attempts are excluded.
383
+ Historical snapshots may have null product `sku`, `name`, `type`, or `version`;
384
+ show a receipt-ID fallback rather than inventing a current catalog value.
385
+ - Each grant usage row has `grant_id` (nullable), `key`, `kind`,
386
+ `purchased_quantity`, `granted_quantity`, `acquired_quantity`, `remaining_quantity`,
387
+ `consumed_quantity`, `revoked_quantity`, `refunded_quantity`,
388
+ `unrecoverable_quantity`, `expires_at`, `expired`, `usable_quantity`, `is_used`,
389
+ and `usage_status`.
390
+
391
+ `purchased_quantity` is what the frozen product promised (grant quantity × order
392
+ quantity). `granted_quantity` and its alias `acquired_quantity` are what was actually
393
+ granted. Without a lot, `grant_id` is null and actual granted/remaining/consumed
394
+ quantities are zero even though promised quantity can be positive. Do not grant
395
+ missing items just because purchase history lists the promised amount.
396
+
397
+ For consumables, `consumed_quantity = acquired_quantity - remaining_quantity -
398
+ revoked_quantity`. Refunded quantity is bounded `revoked_quantity +
399
+ unrecoverable_quantity`; **unrecoverable overlaps consumed**, so never subtract it
400
+ twice. Refund recovery is not gameplay use. Durable/pass `is_used` is **null** because
401
+ ownership does not prove gameplay usage; inspect `usable_quantity` and `expired`
402
+ instead. Statuses are `unused`, `partially_used`, `used_up`, `owned`, `expired`,
403
+ `revoked`, `not_delivered`, or `unavailable`. Use the server's fields, not a locally
404
+ invented “used” checkbox or a raw remaining count that ignores expiry/restrictions.
405
+
406
+ After expiry/reload or a lost claim response, call the anonymous-safe
407
+ `createRestoreSession(titleId,{return_origin,nonce,environment})` and pass its
408
+ returned `intent:'restore'` session to `openMicrotransactionRestoreOverlay` with
409
+ the same hooks shown above. The account signs in inside the modal, then Glitch
410
+ selects an owned paid purchase and sends its verified handoff. No original receipt
411
+ ID or account JWT is required from the game, and restore never calls `/checkout`.
412
+ The new session ID is known before authentication and remains strictly pinned.
413
+ Restoring cannot duplicate ownership or recreate spent consumables; fully refunded,
414
+ unpaid or failed purchases cannot issue a paid handoff.
415
+
416
+ Restore can issue a new game token only when this account still has an eligible
417
+ active purchase. An account with only fully refunded items may receive
418
+ `no_purchases_to_restore`; the hosted sign-in/restore UI or the example's error
419
+ handler should explain that clearly. An expired scoped token still requires
420
+ authentication. The owner JWT in a Glitch-authenticated context can read all
421
+ captured history, and an existing valid scoped token can read the same history,
422
+ but this example does not promise a new game token after every refund/expiry.
423
+ Do not bypass that boundary, expose the account JWT to the game, repurchase just
424
+ to obtain history access, or invent a read-only-authentication endpoint.
425
+
426
+ Lower-level `createMicrotransactionBridge` remains available for existing in-game
427
+ iframe implementations; pass the actual iframe's `contentWindow`. Its `refresh()`
428
+ works only after a successful claim. The older JWT-only `restoreHandoff` and
429
+ receipt-pinned `createMicrotransactionRestoreBridge` are specialized trusted
430
+ hosted-account flows, not the default anonymous-game recovery path.
431
+
432
+ ## Gameplay, refunds and failures
433
+
434
+ - Use server entitlements, not mutable cloud saves. `consume` atomically spends
435
+ consumable units with a unique gameplay `action_id`; reuse that ID for retries.
436
+ Free local gameplay cannot mint paid balances. Pass expiry is server-authoritative.
437
+ - Keep payment, fulfillment and settlement independent. Paid can coexist with
438
+ pending server delivery. Replaying delivery reuses immutable IDs and never
439
+ grants twice. Provider and game messages are at-least-once delivery.
440
+ - Earnings `transferred_minor` means money transferred to a provider balance,
441
+ not a confirmed bank deposit. Preserve `bank_payout_status` and reserve/reconciliation
442
+ fields; never relabel pending or transferred balances as paid bank payouts.
443
+ - `requestRefund` is an owning account's support request. `refundOrder` executes
444
+ directly for an authorized finance caller with a stable key; pending/unknown is not completed.
445
+ Preserve historical orders and reverse commission proportionately. Refunds
446
+ use the original provider/account, not the currently preferred payment route.
447
+ - Handle HTTP 401/403 for account/scope, 404 for unavailable or cross-title IDs,
448
+ 409 for idempotency/state/invariant conflicts, 410 for expired sessions/claims,
449
+ 422 for invalid inputs/revenue policy, 429 for rate limits and 503 for provider
450
+ coverage. Do not retry a hard decline/fraud block through another provider.
451
+ - Ads-off is an actual per-title delivery policy. The backend rejects removing
452
+ the final working revenue model. Provider outages leave ads off and existing
453
+ ownership intact. Sandbox products do not constitute production monetization.
454
+
455
+ ## MCP
456
+
457
+ Use `mcpCapabilities` to discover exact schemas, abilities, mutation metadata and
458
+ examples. `mcpOperation` always targets the authenticated MCP facade; it never
459
+ uses a game's runtime token. `mcpUploadMedia` uses the same authorized Media
460
+ pipeline. The companion `glitch-mcp` package supplies explicit tools, a
461
+ `glitch://microtransactions/setup` resource, dynamic title schema resources and
462
+ the `glitch_setup_microtransactions` prompt. Authorized title-scoped MCP management
463
+ executes directly without confirmation/proposal/approval workflows. Permissions,
464
+ actual provider facts and the last-revenue-model/financial invariants remain enforced.
465
+ Developer MCP read tools do not impersonate players. The self-only runtime purchase
466
+ history API is documented for game code, not exposed as an arbitrary-player MCP tool.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glitch-javascript-sdk",
3
- "version": "3.10.8",
3
+ "version": "4.0.0",
4
4
  "description": "Javascript SDK for Glitch",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -9,13 +9,15 @@
9
9
  "module": "dist/esm/index.js",
10
10
  "files": [
11
11
  "dist",
12
- "src"
12
+ "src",
13
+ "guides"
13
14
  ],
14
15
  "types": "dist/index.d.ts",
15
16
  "scripts": {
16
- "test": "node scripts/test-hosting-routes.cjs && node scripts/test-game-advertising-routes.cjs && node scripts/test-game-design-routes.cjs && node scripts/test-discord-media-routes.cjs && node scripts/test-user-email-delivery-routes.cjs && node scripts/test-game-show-ticket-routes.cjs",
17
+ "test": "node scripts/test-hosting-routes.cjs && node scripts/test-game-advertising-routes.cjs && node scripts/test-game-design-routes.cjs && node scripts/test-discord-media-routes.cjs && node scripts/test-user-email-delivery-routes.cjs && node scripts/test-game-show-ticket-routes.cjs && node scripts/test-festival-networking-routes.cjs && node scripts/test-microtransaction-routes.cjs && node scripts/test-microtransaction-overlay.cjs && node scripts/test-microtransaction-tutorial.cjs",
17
18
  "build": "rm -Rf dist && rollup -c --bundleConfigAsCjs",
18
- "build-docs": "rm -rf docs && typedoc --tsconfig tsconfig.json src/"
19
+ "build-docs": "rm -rf docs && typedoc --tsconfig tsconfig.json src/",
20
+ "test:package": "node scripts/test-package-exports.cjs"
19
21
  },
20
22
  "author": "",
21
23
  "license": "ISC",
@@ -27,6 +29,7 @@
27
29
  "@types/isomorphic-form-data": "^2.0.4",
28
30
  "@types/node": "^20.10.5",
29
31
  "get-file-object-from-local-path": "^1.0.2",
32
+ "jsdom": "^26.1.0",
30
33
  "rollup": "^4.9.1",
31
34
  "rollup-plugin-dts": "^6.1.0",
32
35
  "rollup-plugin-node-builtins": "^2.1.2",