@mpgd/game-services 0.30.4 → 0.31.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 CHANGED
@@ -183,6 +183,37 @@ and retry policy are not redesigned by this observer API.
183
183
  The root `pnpm test` pipeline includes compiled client JS and public declaration
184
184
  consumption checks alongside the client and target conformance suites.
185
185
 
186
+ ## oRPC version and error compatibility
187
+
188
+ The oRPC transport (`createGameServicesOrpcClient`, `createGameServicesRouter`,
189
+ `createGameServicesRpcFetchHandler`) is built on oRPC `2.0.0-beta.46`, pinned
190
+ exactly. Pin the same exact version if a game or Worker also uses oRPC directly.
191
+
192
+ oRPC 2.0.0-beta.34 removed the `inferable` flag from RPC error bodies, and each
193
+ side rejects the other's shape: an older client would see a malformed response
194
+ instead of `BAD_REQUEST` or `NOT_FOUND`, and so would a newer client talking to
195
+ an older Worker. Games and Workers deploy separately, so this package bridges
196
+ the change in `src/orpc-error-compat.ts`:
197
+
198
+ - `createGameServicesRpcFetchHandler` adds `inferable: false` to oRPC error
199
+ bodies, so clients from `@mpgd/game-services` 0.30.5 or older (oRPC
200
+ 2.0.0-beta.14) still decode error codes;
201
+ - `createGameServicesOrpcClient` (0.31.0 or later) removes a boolean `inferable`
202
+ from error bodies before oRPC validates them, so it still decodes errors from
203
+ Workers on 0.30.5 or older.
204
+
205
+ Success responses are unchanged. Any other oRPC 2.0.0-beta.34+ client that calls
206
+ a game-services Worker's `/rpc` route (for example a plain `RPCLink` in a game)
207
+ must wrap its fetch with `withLegacyOrpcErrorCompat` from
208
+ `@mpgd/game-services/client`, or it sees the legacy flag as a malformed error. A
209
+ Worker that no longer serves clients from 0.30.5 or older can pass
210
+ `legacyOrpcErrorFlag: false` to `createGameServicesRpcFetchHandler`. The shim is
211
+ removed once the oldest supported game client and every supported Worker come
212
+ from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46). Other oRPC changes picked up with it: RPC routes accept
213
+ only POST, PUT, PATCH, and DELETE (a GET answers 404, and the kit clients always
214
+ POST), and `Infinity` inputs are encoded with their own meta marker, which a Worker
215
+ on 0.30.5 or older (oRPC 2.0.0-beta.14) cannot decode.
216
+
186
217
  ## Runtime backend transport
187
218
 
188
219
  `createGameServicesRuntime` accepts `httpTransport` for its HTTP JSON backend
@@ -233,6 +264,71 @@ them into analytics or logs.
233
264
  All published entrypoints use explicit internal ESM module paths and are smoke-tested
234
265
  with native Node imports, without a bundler or TypeScript runtime loader.
235
266
 
267
+ ## One player per runtime
268
+
269
+ A runtime, its purchase journal records, and its reconciler belong to
270
+ `playerId` for their whole life, while `getHeaders` and a store provider's
271
+ account binding follow the live session. When the session can change player
272
+ within a run (an account switch), pass `sessionPlayerId: () => session.current()?.playerId`:
273
+
274
+ - It is read before every backend request and again once that request's
275
+ headers are resolved, and by the commerce reconciler before each pass,
276
+ queued event, record step, store read, restore, and finish, and right
277
+ before a store UI opens.
278
+ - Another player closes the runtime for good: nothing more is verified,
279
+ restored, or finished for this player with the other player's credentials
280
+ (a backend answers that with a permanent account-mismatch rejection), and
281
+ no store UI opens for a purchase journaled here once the session names
282
+ someone else. The provider resolves its account binding a few steps after
283
+ that last check, so the binding resolver must follow the runtime's player
284
+ too (bind nothing for anyone else, as the generated game's
285
+ `pinStarterCommercePlayer` does) for a purchase never to be
286
+ journaled here and bound to the other account; a binding refused while the
287
+ session changed keeps the attempt retryable. Purchases
288
+ answer `failed` with the retryable diagnostic `GAME_SERVICES_PLAYER_CHANGED`
289
+ before any store UI, rewarded-ad claims answer `unavailable` before any
290
+ ad, recovery runs nothing, and Restore Purchases rejects with
291
+ `GameServicesRuntimeClosedError`.
292
+ - No player (signed out) refuses the same way with
293
+ `GAME_SERVICES_SESSION_REQUIRED` and leaves the runtime open for its player.
294
+
295
+ Refusals end no journal record: a runtime created for the player later
296
+ resumes them. `runtime.dispose()` closes a runtime the same way
297
+ (`GAME_SERVICES_RUNTIME_DISPOSED`) and resolves once a purchase whose store
298
+ sheet is open has journaled its answer, a recovery pass under way
299
+ (`monetizationRecovery.reconcile()`) has finished, and the reconciler has
300
+ stopped (each wait bounded by `gameServicesDisposeWaitMs`, 3 minutes); call
301
+ it, and wait, before creating the runtime for another player or reloading.
302
+ A recovery pass that outlives that wait starts no store subscription
303
+ afterwards. A custom
304
+ `httpTransport` must send only the headers the runtime resolved for each
305
+ request, or the after-resolution check cannot cover it. A refusal keeps its
306
+ `GameServicesRuntimeClosedError` through header resolution and the HTTP
307
+ transport (it is not turned into `GameServicesHeaderResolutionError` or
308
+ `GameServicesBackendTransportError`). A reconciler started while the session
309
+ is signed out still subscribes to the store; its work waits for the session.
310
+
311
+ Pass `commerceAccountBinding` too: this player's store account binding,
312
+ derived from `playerId` the way the store provider binds the player's new
313
+ purchases (Google Play's obfuscated account id, StoreKit's app account
314
+ token). The reconciler then leaves a store purchase bound to another account
315
+ alone (reported, never journaled or verified for this player). After a
316
+ restart as another player, Google Play still lists the previous player's
317
+ unacknowledged purchases; sending one with this player's session would be
318
+ rejected for good, and its derived key would then block the other player's
319
+ own recovery. The player's explicit Restore Purchases still takes a durable
320
+ bound to another account (restore sharing). StoreKit's provider already
321
+ filters transactions by app account token the same way.
322
+
323
+ `dispose()` is a required member of `GameServicesRuntime`: a runtime built by
324
+ hand (a test fake, a custom wrapper) adds `dispose: async () => undefined`.
325
+
326
+ A game adopting this creates its runtime from its server session's player
327
+ (never a platform placeholder), passes `sessionPlayerId` and
328
+ `commerceAccountBinding`, keeps its store account binding resolver on the
329
+ same player (bind nothing for anyone else), and disposes the runtime (and
330
+ waits) before it creates one for another player.
331
+
236
332
  ## Guest session and account binding foundation
237
333
 
238
334
  `createGuestSessionCoordinator` from `@mpgd/game-services/guest-session`
package/dist/client.d.ts CHANGED
@@ -2,11 +2,12 @@ import { type AnalyticsSink } from '@mpgd/analytics';
2
2
  import type { LeaderboardScoreInput, PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
3
  import type { CommerceServiceCapabilities } from '@mpgd/platform/commerce';
4
4
  import { type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
5
- import { type CommerceReconciler, type CommerceReconcilerEvent, type CommerceReconcilerProduct } from './commerce-reconciler.js';
5
+ import { type CommerceReconciler, type CommerceReconcilerEvent, type CommerceReconcilerProduct, type CreateCommerceReconcilerInput } from './commerce-reconciler.js';
6
6
  import type { MonetizationOperationStore } from './monetization-recovery.js';
7
7
  import type { GameServicesOperationClient } from './operations.js';
8
8
  export type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult, } from './operations.js';
9
9
  import type { GameServicesContractClient } from './contract.js';
10
+ export { withLegacyOrpcErrorCompat } from './orpc-error-compat.js';
10
11
  import { type GameServicesHeaderResolver } from './request-headers.js';
11
12
  import type { ClaimAdRewardRequest, ClaimAdRewardResponse, GameServicesLedgerTarget, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
12
13
  export type { GameServicesOperationKind, GameServicesOperationLocation, GameServicesOperationOptions, GameServicesOperationProgress, GameServicesPurchaseProgress, GameServicesRewardedAdProgress, } from './operation-progress.js';
@@ -133,8 +134,21 @@ export interface CreateGameServicesClientInput {
133
134
  readonly warn?: (message: string) => void;
134
135
  /** Catalog kinds and SKUs the reconciler resolves without a store read. */
135
136
  readonly commerceProducts?: readonly CommerceReconcilerProduct[];
137
+ /** The reconciler's `staleLaunchedAfterMs` (24 hours by default). */
138
+ readonly commerceStaleLaunchedAfterMs?: number;
136
139
  readonly onCommerceEvent?: (event: CommerceReconcilerEvent) => void;
137
- }
140
+ /** The reconciler's `sessionCheck` (see `CreateCommerceReconcilerInput`). */
141
+ readonly commerceSessionCheck?: CreateCommerceReconcilerInput['sessionCheck'];
142
+ /** The reconciler's `closed` (see `CreateCommerceReconcilerInput`). */
143
+ readonly commerceClosed?: CreateCommerceReconcilerInput['closed'];
144
+ /** The reconciler's `accountBinding` (see `CreateCommerceReconcilerInput`). */
145
+ readonly commerceAccountBinding?: CreateCommerceReconcilerInput['accountBinding'];
146
+ }
147
+ /**
148
+ * `commerceStaleLaunchedAfterMs` is checked where it enters, so a bad value
149
+ * fails even when no reconciler is built (a gateway without `commerce`).
150
+ */
151
+ export declare function assertCommerceStaleLaunchedAfterMs(value: number | undefined): void;
138
152
  export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
139
153
  }
140
154
  export interface GameServicesLeaderboardResult {
package/dist/client.js CHANGED
@@ -6,7 +6,9 @@ import { createCommerceReconciler, toGameServicesPurchaseResult, } from './comme
6
6
  import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
7
7
  import { createInMemoryMonetizationOperationStore } from './monetization-operation-store.js';
8
8
  import { observeGameServicesOperation } from './operation-progress.js';
9
- import { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from './request-headers.js';
9
+ import { withLegacyOrpcErrorCompat } from './orpc-error-compat.js';
10
+ export { withLegacyOrpcErrorCompat } from './orpc-error-compat.js';
11
+ import { GameServicesHeaderResolutionError, GameServicesRuntimeClosedError, resolveGameServicesRequestHeaders, } from './request-headers.js';
10
12
  export { GameServicesHeaderResolutionError, resolveGameServicesRequestHeaders, } from './request-headers.js';
11
13
  export const gameServicesBackendEndpoints = {
12
14
  verifyPurchase: '/game-services/purchases/verify',
@@ -35,7 +37,17 @@ export class GameServicesBackendTransportError extends Error {
35
37
  this.endpoint = endpoint;
36
38
  }
37
39
  }
40
+ /**
41
+ * `commerceStaleLaunchedAfterMs` is checked where it enters, so a bad value
42
+ * fails even when no reconciler is built (a gateway without `commerce`).
43
+ */
44
+ export function assertCommerceStaleLaunchedAfterMs(value) {
45
+ if (value !== undefined && !(typeof value === 'number' && value > 0)) {
46
+ throw new RangeError('commerceStaleLaunchedAfterMs must be a positive duration in milliseconds, or Infinity.');
47
+ }
48
+ }
38
49
  export function createGameServicesClient(input) {
50
+ assertCommerceStaleLaunchedAfterMs(input.commerceStaleLaunchedAfterMs);
39
51
  const now = input.now ?? (() => new Date().toISOString());
40
52
  const requestNow = input.requestNow ?? now;
41
53
  const rewardEvidenceRegistry = input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry();
@@ -62,6 +74,12 @@ export function createGameServicesClient(input) {
62
74
  target: input.target,
63
75
  ...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
64
76
  ...(input.commerceProducts === undefined ? {} : { products: input.commerceProducts }),
77
+ ...(input.commerceSessionCheck === undefined ? {} : { sessionCheck: input.commerceSessionCheck }),
78
+ ...(input.commerceClosed === undefined ? {} : { closed: input.commerceClosed }),
79
+ ...(input.commerceAccountBinding === undefined ? {} : { accountBinding: input.commerceAccountBinding }),
80
+ ...(input.commerceStaleLaunchedAfterMs === undefined
81
+ ? {}
82
+ : { staleLaunchedAfterMs: input.commerceStaleLaunchedAfterMs }),
65
83
  now: requestNow,
66
84
  });
67
85
  if (input.onCommerceEvent !== undefined) {
@@ -424,9 +442,16 @@ export function createGameServicesFetchBackendTransport(input) {
424
442
  };
425
443
  }
426
444
  export function createGameServicesOrpcClient(input) {
445
+ const fetcher = input.fetch;
427
446
  const link = new RPCLink({
428
447
  origin: input.url,
429
- ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
448
+ // Every response passes the oRPC error-body compatibility shim so errors from a Worker on
449
+ // an older oRPC beta keep their codes; the default fetch is resolved per call, as oRPC does.
450
+ fetch: withLegacyOrpcErrorCompat((url, init, ...rest) => {
451
+ return fetcher === undefined
452
+ ? globalThis.fetch(url, init)
453
+ : fetcher(url, init, ...rest);
454
+ }),
430
455
  ...(input.headers === undefined && input.getHeaders === undefined
431
456
  ? {}
432
457
  : {
@@ -490,7 +515,7 @@ async function sendGameServicesBackendRequest(transport, endpoint, body) {
490
515
  });
491
516
  }
492
517
  catch (error) {
493
- if (error instanceof GameServicesHeaderResolutionError) {
518
+ if (error instanceof GameServicesHeaderResolutionError || error instanceof GameServicesRuntimeClosedError) {
494
519
  throw error;
495
520
  }
496
521
  // Do not leak SDK or native transport exceptions (which can contain
@@ -1,5 +1,5 @@
1
1
  import type { LogicalProductId } from '@mpgd/platform';
2
- import { type CommerceCapabilities, type CommerceErrorInfo, type CommerceProvider, type CommercePurchaseState, type CommercePurchaseSummaryStatus, type FetchProductsInput, type PurchaseKind, type RequestPurchaseInput, type RequestPurchaseResult, type StoreId, type StoreProduct, type StorePurchase } from '@mpgd/platform/commerce';
2
+ import { CommerceError, type CommerceCapabilities, type CommerceErrorInfo, type CommerceProvider, type CommercePurchaseState, type CommercePurchaseSummaryStatus, type FetchProductsInput, type PurchaseKind, type RequestPurchaseInput, type RequestPurchaseResult, type StoreId, type StoreProduct, type StorePurchase, type StorePurchaseAccountBinding } from '@mpgd/platform/commerce';
3
3
  import type { PurchaseVerificationApi } from './client.js';
4
4
  import { type CommerceServiceAlignment } from './commerce-evidence-registry.js';
5
5
  import type { MonetizationOperationStore } from './monetization-recovery.js';
@@ -73,7 +73,9 @@ export declare function summarizeCommercePurchaseOperation(record: CommercePurch
73
73
  * finish allow-list of its own (the removed v1 driver paired transactions in
74
74
  * `finishGrantedTransaction`), so this is the finish gate for every `client`
75
75
  * and `host` owner: the ledger entry id comes from the journal revision the
76
- * backend grant wrote.
76
+ * backend grant wrote. The one other finish call is a `finished` consumable a
77
+ * `client` store reports again, finished with the same journaled ledger entry
78
+ * (see `finishReportedAgain` in `createCommerceReconciler`).
77
79
  */
78
80
  export declare function assertCommerceFinishAllowed(record: CommercePurchaseOperationRecord): {
79
81
  readonly transactionId: string;
@@ -87,7 +89,9 @@ export interface CommerceReconcilerProduct {
87
89
  readonly kind: PurchaseKind;
88
90
  readonly storeSku?: string;
89
91
  }
90
- export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore';
92
+ export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore'
93
+ /** `sessionCheck` refused: the reconciler's player is not the session's now. */
94
+ | 'session';
91
95
  export type CommerceReconcilerEvent = {
92
96
  readonly type: 'transition';
93
97
  readonly record: CommercePurchaseOperationRecord;
@@ -153,6 +157,52 @@ export interface CreateCommerceReconcilerInput {
153
157
  * ten seconds.
154
158
  */
155
159
  readonly serviceCapabilitiesTimeoutMs?: number;
160
+ /**
161
+ * Whether the reconciler may act for `playerId` right now; an error says
162
+ * why not (`createGameServicesRuntime` passes its session player check).
163
+ * It is read synchronously before every pass, before each queued event and
164
+ * each record a pass handles, before a store read, restore, or finish, and
165
+ * for a purchase at its start, right before its record is reserved, and
166
+ * right before the store UI opens. A refusal never ends a record: a pass stops with the
167
+ * journal as it is and the events still queued (a `failure` event with
168
+ * stage `session` reports it), a purchase answers the error with no store
169
+ * UI (a reserved attempt stays retryable), and `restorePurchases` rejects
170
+ * with it. So one player's journaled purchases are never verified,
171
+ * restored, or finished while another player's session headers or account
172
+ * binding are current. Return a retryable `CommerceError`.
173
+ */
174
+ readonly sessionCheck?: () => CommerceError | undefined;
175
+ /**
176
+ * Whether the reconciler's owner closed for good (`createGameServicesRuntime`
177
+ * passes its disposed or player-changed state). Read synchronously when
178
+ * `start()` is called and again right before it subscribes (after the
179
+ * journal read): while it answers true, `start()` subscribes nothing and
180
+ * starts nothing, and resolves without an error. So a caller that reaches
181
+ * `start()` after `stop()` (a recovery pass that outlived its owner's
182
+ * dispose), or a start that was still reading the journal when its owner
183
+ * stopped waiting for it, leaves no store subscription.
184
+ * Unlike `sessionCheck`, a signed-out session is not closed: the reconciler
185
+ * still subscribes then and its work waits for the session. Omitted, a
186
+ * stopped reconciler can be started again.
187
+ */
188
+ readonly closed?: () => boolean;
189
+ /**
190
+ * This player's store account binding: the value the store provider binds
191
+ * the player's new purchases to (Google Play's obfuscated account id,
192
+ * StoreKit's app account token). A store purchase the journal does not
193
+ * hold yet whose binding names another account is another player's: it is
194
+ * reported (stage `match`) and left alone, never journaled or verified for
195
+ * this player (a backend would reject it for good as an account mismatch,
196
+ * and its derived key would then block that player's own recovery). The
197
+ * player's explicit Restore Purchases pass still takes a durable bound to
198
+ * another account (restore sharing). A purchase without a binding, or with
199
+ * a binding of another kind, is not compared. Answer `undefined` (or
200
+ * throw) only transiently, while this player's binding cannot be read
201
+ * yet: a bound purchase reported then is kept and asked about again on
202
+ * the next pass (a resume, an event, an explicit trigger). A runtime for a store or target whose purchases this player's
203
+ * binding never covers must omit the input; omitted, nothing is compared.
204
+ */
205
+ readonly accountBinding?: () => StorePurchaseAccountBinding | undefined | Promise<StorePurchaseAccountBinding | undefined>;
156
206
  }
157
207
  export declare const defaultStaleLaunchedAfterMs: number;
158
208
  /** How long one backend capabilities read may take before the last comparison is kept. */