@mpgd/game-services 0.30.5 → 0.32.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,76 @@ 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 only gets
318
+ the backend's another-account answer (409 `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`),
319
+ which parks the record without a grant. The reconciler parks it whenever the
320
+ backend is reached anyway (no binding input, an older client's record): the
321
+ record stays `verifying`, is never resent automatically, and leaves its
322
+ derived key to the purchase's own account on the install
323
+ ([another account's purchase](../../docs/GAME_SERVICES_BACKEND.md#another-accounts-purchase)).
324
+ The player's explicit Restore Purchases still takes a durable
325
+ bound to another account (restore sharing). StoreKit's provider already
326
+ filters transactions by app account token the same way.
327
+
328
+ `dispose()` is a required member of `GameServicesRuntime`: a runtime built by
329
+ hand (a test fake, a custom wrapper) adds `dispose: async () => undefined`.
330
+
331
+ A game adopting this creates its runtime from its server session's player
332
+ (never a platform placeholder), passes `sessionPlayerId` and
333
+ `commerceAccountBinding`, keeps its store account binding resolver on the
334
+ same player (bind nothing for anyone else), and disposes the runtime (and
335
+ waits) before it creates one for another player.
336
+
236
337
  ## Guest session and account binding foundation
237
338
 
238
339
  `createGuestSessionCoordinator` from `@mpgd/game-services/guest-session`
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Another account's purchase (RFC "Another account's purchase"): a verify
3
+ * whose store binding (Google Play `obfuscatedAccountId` /
4
+ * `obfuscatedProfileId`, StoreKit `appAccountToken`) names another account
5
+ * than the requesting player's. It is not granted, nothing is recorded, and
6
+ * the store transaction stays recoverable by the account it is bound to, so
7
+ * the backend answers it as a retryable error instead of a `rejected`
8
+ * receipt: HTTP 409 `{ error: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT', reason }`,
9
+ * or the oRPC error code `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` (status 409,
10
+ * `data.reason`). A client from before this answer treats it as a transport
11
+ * failure and keeps the record open; the commerce reconciler parks it.
12
+ *
13
+ * No schema, DOM, or transport dependencies: the server, the client, and a
14
+ * game server wrapping the reference verifiers share these helpers.
15
+ */
16
+ /** The error code of the answer, on HTTP (`error`) and oRPC (`code`) alike. */
17
+ export declare const purchaseBoundToAnotherAccountCode: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT';
18
+ /** The HTTP status of the answer: the purchase conflicts with the requesting player's account. */
19
+ export declare const purchaseBoundToAnotherAccountStatus = 409;
20
+ /**
21
+ * The binding mismatch reasons of the reference verifiers. Each one means
22
+ * the purchase carries another account's binding: a purchase without one
23
+ * answers `GOOGLE_PLAY_ACCOUNT_ID_MISSING`, `GOOGLE_PLAY_PROFILE_ID_MISSING`,
24
+ * `GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED`, or `APP_STORE_ACCOUNT_TOKEN_MISSING`
25
+ * instead, which stay `rejected`. A backend from before this answer, or a game
26
+ * server that maps every refusal to `rejected`, still answers these reasons
27
+ * as a `rejected` receipt; the commerce reconciler reads that receipt as this
28
+ * answer too.
29
+ */
30
+ export declare const storeAccountMismatchReasons: readonly ["GOOGLE_PLAY_ACCOUNT_MISMATCH", "GOOGLE_PLAY_PROFILE_MISMATCH", "APP_STORE_ACCOUNT_TOKEN_MISMATCH"];
31
+ export type StoreAccountMismatchReason = (typeof storeAccountMismatchReasons)[number];
32
+ export declare function isStoreAccountMismatchReason(reason: unknown): reason is StoreAccountMismatchReason;
33
+ /**
34
+ * Thrown by the reference backend's `verifyPurchase` for another account's
35
+ * purchase; the HTTP handler and the oRPC router turn it into the 409
36
+ * answer. A game server that calls `backend.purchases.verifyPurchase` itself
37
+ * answers it the same way (see `docs/GAME_SERVICES_BACKEND.md`). Its
38
+ * `message` starts with the code, so a Workers RPC caller whose runtime
39
+ * keeps only the message still recognizes it.
40
+ */
41
+ export declare class GameServicesPurchaseBoundToAnotherAccountError extends Error {
42
+ readonly code: "PURCHASE_BOUND_TO_ANOTHER_ACCOUNT";
43
+ readonly status = 409;
44
+ /** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
45
+ readonly reason: string;
46
+ constructor(reason: string);
47
+ }
48
+ /** The body of the HTTP 409 answer. */
49
+ export interface PurchaseBoundToAnotherAccountBody {
50
+ readonly error: typeof purchaseBoundToAnotherAccountCode;
51
+ readonly reason: string;
52
+ }
53
+ export declare function createPurchaseBoundToAnotherAccountBody(reason: string): PurchaseBoundToAnotherAccountBody;
54
+ /**
55
+ * The binding mismatch of an error that carries the answer, or undefined for
56
+ * any other error. It reads every shape a client meets: the thrown
57
+ * `GameServicesPurchaseBoundToAnotherAccountError` (an in-process backend),
58
+ * an oRPC error (`code`, `data.reason`), a `GameServicesBackendError` of the
59
+ * HTTP transport (`status` 409 and `body.error`), and an error rebuilt from
60
+ * its message alone (a Workers RPC caller without enhanced error
61
+ * serialization).
62
+ */
63
+ export declare function readPurchaseBoundToAnotherAccount(error: unknown): {
64
+ readonly reason: string;
65
+ } | undefined;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Another account's purchase (RFC "Another account's purchase"): a verify
3
+ * whose store binding (Google Play `obfuscatedAccountId` /
4
+ * `obfuscatedProfileId`, StoreKit `appAccountToken`) names another account
5
+ * than the requesting player's. It is not granted, nothing is recorded, and
6
+ * the store transaction stays recoverable by the account it is bound to, so
7
+ * the backend answers it as a retryable error instead of a `rejected`
8
+ * receipt: HTTP 409 `{ error: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT', reason }`,
9
+ * or the oRPC error code `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` (status 409,
10
+ * `data.reason`). A client from before this answer treats it as a transport
11
+ * failure and keeps the record open; the commerce reconciler parks it.
12
+ *
13
+ * No schema, DOM, or transport dependencies: the server, the client, and a
14
+ * game server wrapping the reference verifiers share these helpers.
15
+ */
16
+ /** The error code of the answer, on HTTP (`error`) and oRPC (`code`) alike. */
17
+ export const purchaseBoundToAnotherAccountCode = 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT';
18
+ /** The HTTP status of the answer: the purchase conflicts with the requesting player's account. */
19
+ export const purchaseBoundToAnotherAccountStatus = 409;
20
+ /**
21
+ * The binding mismatch reasons of the reference verifiers. Each one means
22
+ * the purchase carries another account's binding: a purchase without one
23
+ * answers `GOOGLE_PLAY_ACCOUNT_ID_MISSING`, `GOOGLE_PLAY_PROFILE_ID_MISSING`,
24
+ * `GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED`, or `APP_STORE_ACCOUNT_TOKEN_MISSING`
25
+ * instead, which stay `rejected`. A backend from before this answer, or a game
26
+ * server that maps every refusal to `rejected`, still answers these reasons
27
+ * as a `rejected` receipt; the commerce reconciler reads that receipt as this
28
+ * answer too.
29
+ */
30
+ export const storeAccountMismatchReasons = Object.freeze([
31
+ 'GOOGLE_PLAY_ACCOUNT_MISMATCH',
32
+ 'GOOGLE_PLAY_PROFILE_MISMATCH',
33
+ 'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
34
+ ]);
35
+ export function isStoreAccountMismatchReason(reason) {
36
+ return typeof reason === 'string'
37
+ && storeAccountMismatchReasons.includes(reason);
38
+ }
39
+ const reasonPattern = /^[A-Za-z0-9_.:-]{1,128}$/u;
40
+ function readReason(value) {
41
+ return typeof value === 'string' && reasonPattern.test(value) ? value : undefined;
42
+ }
43
+ /**
44
+ * Thrown by the reference backend's `verifyPurchase` for another account's
45
+ * purchase; the HTTP handler and the oRPC router turn it into the 409
46
+ * answer. A game server that calls `backend.purchases.verifyPurchase` itself
47
+ * answers it the same way (see `docs/GAME_SERVICES_BACKEND.md`). Its
48
+ * `message` starts with the code, so a Workers RPC caller whose runtime
49
+ * keeps only the message still recognizes it.
50
+ */
51
+ export class GameServicesPurchaseBoundToAnotherAccountError extends Error {
52
+ code = purchaseBoundToAnotherAccountCode;
53
+ status = purchaseBoundToAnotherAccountStatus;
54
+ /** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
55
+ reason;
56
+ constructor(reason) {
57
+ const checked = readReason(reason) ?? purchaseBoundToAnotherAccountCode;
58
+ super(`${purchaseBoundToAnotherAccountCode}: ${checked}`);
59
+ this.name = 'GameServicesPurchaseBoundToAnotherAccountError';
60
+ this.reason = checked;
61
+ }
62
+ }
63
+ export function createPurchaseBoundToAnotherAccountBody(reason) {
64
+ return {
65
+ error: purchaseBoundToAnotherAccountCode,
66
+ reason: readReason(reason) ?? purchaseBoundToAnotherAccountCode,
67
+ };
68
+ }
69
+ /**
70
+ * The binding mismatch of an error that carries the answer, or undefined for
71
+ * any other error. It reads every shape a client meets: the thrown
72
+ * `GameServicesPurchaseBoundToAnotherAccountError` (an in-process backend),
73
+ * an oRPC error (`code`, `data.reason`), a `GameServicesBackendError` of the
74
+ * HTTP transport (`status` 409 and `body.error`), and an error rebuilt from
75
+ * its message alone (a Workers RPC caller without enhanced error
76
+ * serialization).
77
+ */
78
+ export function readPurchaseBoundToAnotherAccount(error) {
79
+ if (typeof error !== 'object' || error === null) {
80
+ return undefined;
81
+ }
82
+ const candidate = error;
83
+ const fallback = { reason: purchaseBoundToAnotherAccountCode };
84
+ if (candidate.code === purchaseBoundToAnotherAccountCode) {
85
+ const data = candidate.data;
86
+ const dataReason = typeof data === 'object' && data !== null
87
+ ? readReason(data.reason)
88
+ : undefined;
89
+ const reason = readReason(candidate.reason) ?? dataReason;
90
+ return reason === undefined ? fallback : { reason };
91
+ }
92
+ const body = candidate.body;
93
+ if (candidate.status === purchaseBoundToAnotherAccountStatus
94
+ && typeof body === 'object'
95
+ && body !== null
96
+ && body.error === purchaseBoundToAnotherAccountCode) {
97
+ const reason = readReason(body.reason);
98
+ return reason === undefined ? fallback : { reason };
99
+ }
100
+ const message = candidate.message;
101
+ const prefix = `${purchaseBoundToAnotherAccountCode}: `;
102
+ if (typeof message === 'string' && message.startsWith(prefix)) {
103
+ const reason = readReason(message.slice(prefix.length));
104
+ return reason === undefined ? fallback : { reason };
105
+ }
106
+ return undefined;
107
+ }
@@ -250,19 +250,38 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
250
250
  // when every other check passes: the backend's restore sharing policy
251
251
  // decides (RFC "Restore sharing"). The client's StoreKit-verified JWS is
252
252
  // required, so a transaction id alone never shares a purchase.
253
- const foreignBinding = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH'
253
+ const tokenMismatch = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH';
254
+ if (tokenMismatch) {
255
+ // Every other check still decides: only a purchase that passes them all
256
+ // is reported as restorable or as another account's.
257
+ rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
258
+ }
259
+ const foreignBinding = tokenMismatch
254
260
  && input.request.intent === 'restore'
255
261
  && input.product.type === 'non_consumable'
256
262
  && offlineTransaction !== undefined;
257
- if (foreignBinding) {
258
- rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
259
- }
260
263
  if (rejection !== undefined) {
261
264
  return { status: 'rejected', reason: rejection };
262
265
  }
266
+ if (tokenMismatch && !foreignBinding) {
267
+ // A transaction without an appAccountToken (bought outside the app, or
268
+ // by a build that set none) is no account's: refused for every player.
269
+ // One with another token is that account's, which can still recover it
270
+ // (RFC "Another account's purchase").
271
+ return transaction.appAccountToken === undefined
272
+ ? { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISSING' }
273
+ : { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH', anotherAccount: true };
274
+ }
263
275
  return {
264
276
  ...(foreignBinding
265
- ? { status: 'restorable', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH' }
277
+ ? {
278
+ status: 'restorable',
279
+ // Bound to no account, or to another one: the backend refuses the
280
+ // former for good when no share covers it.
281
+ reason: transaction.appAccountToken === undefined
282
+ ? 'APP_STORE_ACCOUNT_TOKEN_MISSING'
283
+ : 'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
284
+ }
266
285
  : { status: 'verified' }),
267
286
  verificationId: createVerificationId(transaction),
268
287
  verifiedAt: new Date(nowMs).toISOString(),
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) {
@@ -173,7 +191,8 @@ export function createGameServicesClient(input) {
173
191
  }
174
192
  else if (!serverResulted) {
175
193
  serverResulted = true;
176
- progress.serverResult(event.response.verified);
194
+ // Another account's purchase is a server answer with no grant.
195
+ progress.serverResult(event.type === 'verify-result' && event.response.verified);
177
196
  }
178
197
  });
179
198
  try {
@@ -217,7 +236,9 @@ export function createGameServicesClient(input) {
217
236
  alreadyProcessed: result.verification?.alreadyProcessed,
218
237
  reason: result.status === 'pending'
219
238
  ? 'purchase_pending'
220
- : (result.verification?.reason ?? record?.error?.code),
239
+ : (result.verification?.reason
240
+ ?? record?.anotherAccount?.reason
241
+ ?? record?.error?.code),
221
242
  },
222
243
  });
223
244
  }
@@ -424,9 +445,16 @@ export function createGameServicesFetchBackendTransport(input) {
424
445
  };
425
446
  }
426
447
  export function createGameServicesOrpcClient(input) {
448
+ const fetcher = input.fetch;
427
449
  const link = new RPCLink({
428
450
  origin: input.url,
429
- ...(input.fetch === undefined ? {} : { fetch: input.fetch }),
451
+ // Every response passes the oRPC error-body compatibility shim so errors from a Worker on
452
+ // an older oRPC beta keep their codes; the default fetch is resolved per call, as oRPC does.
453
+ fetch: withLegacyOrpcErrorCompat((url, init, ...rest) => {
454
+ return fetcher === undefined
455
+ ? globalThis.fetch(url, init)
456
+ : fetcher(url, init, ...rest);
457
+ }),
430
458
  ...(input.headers === undefined && input.getHeaders === undefined
431
459
  ? {}
432
460
  : {
@@ -490,7 +518,7 @@ async function sendGameServicesBackendRequest(transport, endpoint, body) {
490
518
  });
491
519
  }
492
520
  catch (error) {
493
- if (error instanceof GameServicesHeaderResolutionError) {
521
+ if (error instanceof GameServicesHeaderResolutionError || error instanceof GameServicesRuntimeClosedError) {
494
522
  throw error;
495
523
  }
496
524
  // 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';
@@ -55,6 +55,23 @@ export interface CommercePurchaseOperationRecord {
55
55
  /** A retryable error on `requested` means the provider proved checkout never started. */
56
56
  readonly error?: CommerceErrorInfo;
57
57
  readonly revokedBy?: string;
58
+ /**
59
+ * The backend answered that the store binds this purchase to another
60
+ * account than this record's player (RFC "Another account's purchase"):
61
+ * `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`, or a `rejected` receipt with a
62
+ * store binding mismatch reason from a backend before that answer. The
63
+ * record is parked in `verifying`: never sent again automatically, not
64
+ * listed among the player's recoverable records, summarized as `rejected`
65
+ * for this player, and its shared key no longer refuses another player
66
+ * of the install, who journals the purchase under its own player-scoped
67
+ * key. Only this player's Restore Purchases, when the store reports the
68
+ * purchase again, sends it once more.
69
+ */
70
+ readonly anotherAccount?: {
71
+ /** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
72
+ readonly reason: string;
73
+ readonly reportedAt: string;
74
+ };
58
75
  }
59
76
  /**
60
77
  * The store key of a commerce v2 purchase record: one namespace per ledger
@@ -65,15 +82,23 @@ export interface CommercePurchaseOperationRecord {
65
82
  */
66
83
  export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string, playerId?: string): string;
67
84
  export declare function isCommercePurchaseOperationRecord(record: unknown): record is CommercePurchaseOperationRecord;
68
- /** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
85
+ /**
86
+ * Summary status of a record: the RFC mapping, with `revoked` kept distinct.
87
+ * A record parked as another account's purchase is `rejected` for its
88
+ * player: nothing was granted to it, and nothing will be.
89
+ */
69
90
  export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
91
+ /** A `verifying` record the backend answered as another account's purchase. */
92
+ export declare function isParkedForAnotherAccount(record: CommercePurchaseOperationRecord): boolean;
70
93
  /**
71
94
  * Only `granted` and `finishing` records that hold the backend's ledger entry
72
95
  * id and a store transaction may be finished. The StoreKit SDK keeps no
73
96
  * finish allow-list of its own (the removed v1 driver paired transactions in
74
97
  * `finishGrantedTransaction`), so this is the finish gate for every `client`
75
98
  * and `host` owner: the ledger entry id comes from the journal revision the
76
- * backend grant wrote.
99
+ * backend grant wrote. The one other finish call is a `finished` consumable a
100
+ * `client` store reports again, finished with the same journaled ledger entry
101
+ * (see `finishReportedAgain` in `createCommerceReconciler`).
77
102
  */
78
103
  export declare function assertCommerceFinishAllowed(record: CommercePurchaseOperationRecord): {
79
104
  readonly transactionId: string;
@@ -87,7 +112,9 @@ export interface CommerceReconcilerProduct {
87
112
  readonly kind: PurchaseKind;
88
113
  readonly storeSku?: string;
89
114
  }
90
- export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore';
115
+ export type CommerceReconcilerFailureStage = 'capabilities' | 'available' | 'catalog' | 'match' | 'journal' | 'verify' | 'finish' | 'ledger-status' | 'restore'
116
+ /** `sessionCheck` refused: the reconciler's player is not the session's now. */
117
+ | 'session';
91
118
  export type CommerceReconcilerEvent = {
92
119
  readonly type: 'transition';
93
120
  readonly record: CommercePurchaseOperationRecord;
@@ -100,6 +127,18 @@ export type CommerceReconcilerEvent = {
100
127
  readonly type: 'verify-result';
101
128
  readonly record: CommercePurchaseOperationRecord;
102
129
  readonly response: VerifyPurchaseResponse;
130
+ } | {
131
+ /**
132
+ * The backend answered the record's verify as another account's
133
+ * purchase (RFC "Another account's purchase"): the record is parked,
134
+ * nothing was granted, and it is not a failure. `response` is the
135
+ * receipt of a backend before that answer (`rejected` with a store
136
+ * binding mismatch reason); absent for the 409 answer.
137
+ */
138
+ readonly type: 'another-account';
139
+ readonly record: CommercePurchaseOperationRecord;
140
+ readonly reason: string;
141
+ readonly response?: VerifyPurchaseResponse;
103
142
  } | {
104
143
  readonly type: 'failure';
105
144
  readonly stage: CommerceReconcilerFailureStage;
@@ -153,6 +192,52 @@ export interface CreateCommerceReconcilerInput {
153
192
  * ten seconds.
154
193
  */
155
194
  readonly serviceCapabilitiesTimeoutMs?: number;
195
+ /**
196
+ * Whether the reconciler may act for `playerId` right now; an error says
197
+ * why not (`createGameServicesRuntime` passes its session player check).
198
+ * It is read synchronously before every pass, before each queued event and
199
+ * each record a pass handles, before a store read, restore, or finish, and
200
+ * for a purchase at its start, right before its record is reserved, and
201
+ * right before the store UI opens. A refusal never ends a record: a pass stops with the
202
+ * journal as it is and the events still queued (a `failure` event with
203
+ * stage `session` reports it), a purchase answers the error with no store
204
+ * UI (a reserved attempt stays retryable), and `restorePurchases` rejects
205
+ * with it. So one player's journaled purchases are never verified,
206
+ * restored, or finished while another player's session headers or account
207
+ * binding are current. Return a retryable `CommerceError`.
208
+ */
209
+ readonly sessionCheck?: () => CommerceError | undefined;
210
+ /**
211
+ * Whether the reconciler's owner closed for good (`createGameServicesRuntime`
212
+ * passes its disposed or player-changed state). Read synchronously when
213
+ * `start()` is called and again right before it subscribes (after the
214
+ * journal read): while it answers true, `start()` subscribes nothing and
215
+ * starts nothing, and resolves without an error. So a caller that reaches
216
+ * `start()` after `stop()` (a recovery pass that outlived its owner's
217
+ * dispose), or a start that was still reading the journal when its owner
218
+ * stopped waiting for it, leaves no store subscription.
219
+ * Unlike `sessionCheck`, a signed-out session is not closed: the reconciler
220
+ * still subscribes then and its work waits for the session. Omitted, a
221
+ * stopped reconciler can be started again.
222
+ */
223
+ readonly closed?: () => boolean;
224
+ /**
225
+ * This player's store account binding: the value the store provider binds
226
+ * the player's new purchases to (Google Play's obfuscated account id,
227
+ * StoreKit's app account token). A store purchase the journal does not
228
+ * hold yet whose binding names another account is another player's: it is
229
+ * reported (stage `match`) and left alone, never journaled or verified for
230
+ * this player (a backend would reject it for good as an account mismatch,
231
+ * and its derived key would then block that player's own recovery). The
232
+ * player's explicit Restore Purchases pass still takes a durable bound to
233
+ * another account (restore sharing). A purchase without a binding, or with
234
+ * a binding of another kind, is not compared. Answer `undefined` (or
235
+ * throw) only transiently, while this player's binding cannot be read
236
+ * yet: a bound purchase reported then is kept and asked about again on
237
+ * the next pass (a resume, an event, an explicit trigger). A runtime for a store or target whose purchases this player's
238
+ * binding never covers must omit the input; omitted, nothing is compared.
239
+ */
240
+ readonly accountBinding?: () => StorePurchaseAccountBinding | undefined | Promise<StorePurchaseAccountBinding | undefined>;
156
241
  }
157
242
  export declare const defaultStaleLaunchedAfterMs: number;
158
243
  /** How long one backend capabilities read may take before the last comparison is kept. */