@mpgd/game-services 0.30.5 → 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/dist/runtime.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
4
4
  import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
5
5
  import type { GameServicesLedgerTarget } from './types.js';
6
6
  import type { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
7
- import type { CommerceReconcilerProduct } from './commerce-reconciler.js';
7
+ import type { CommerceReconcilerProduct, CreateCommerceReconcilerInput } from './commerce-reconciler.js';
8
8
  import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
9
9
  export type GameServicesAuthorityMode = 'production' | 'non-production';
10
10
  export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
@@ -21,7 +21,30 @@ export interface GameServicesRuntime {
21
21
  * with the runtime's headers; present with an authoritative `baseUrl`.
22
22
  */
23
23
  readonly accountDeletion?: AccountDeletionClient;
24
+ /**
25
+ * Closes the runtime for good, then waits until a purchase whose store
26
+ * sheet is open has journaled its answer, a startup or resume recovery
27
+ * pass (`monetizationRecovery.reconcile()`) has finished, and the commerce
28
+ * reconciler has stopped (each wait bounded by `gameServicesDisposeWaitMs`); repeated
29
+ * calls share that wait. Afterwards nothing it holds talks to the backend
30
+ * or the store for its player: a purchase answers `failed` with the
31
+ * retryable diagnostic `gameServicesRuntimeDisposedCode` before any store
32
+ * UI, a rewarded-ad claim answers `unavailable` before any ad, recovery
33
+ * runs nothing, Restore Purchases and leaderboard submissions reject with
34
+ * `GameServicesRuntimeClosedError`, and every backend request (account
35
+ * deletion included) is refused before its headers are resolved. The
36
+ * journal keeps every record; a runtime created later for the same player
37
+ * resumes them. A disabled runtime has nothing to close.
38
+ */
39
+ dispose(): Promise<void>;
24
40
  }
41
+ /**
42
+ * How long `dispose()` waits for a purchase whose store sheet is open to
43
+ * journal its answer, and then for the reconciler to stop: 3 minutes, past
44
+ * Google Play's own wait for a purchase result.
45
+ */
46
+ export declare const gameServicesDisposeWaitMs: number;
47
+ export { gameServicesPlayerChangedCode, gameServicesRuntimeDisposedCode, GameServicesRuntimeClosedError, gameServicesSessionRequiredCode, type GameServicesRuntimeClosedCode, } from './request-headers.js';
25
48
  interface CreateGameServicesRuntimeCommonInput {
26
49
  readonly gateway: PlatformGateway;
27
50
  readonly playerId: string;
@@ -47,6 +70,52 @@ interface CreateGameServicesRuntimeCommonInput {
47
70
  * product catalog's `platformProductIds` for the gateway target.
48
71
  */
49
72
  readonly commerceProducts?: readonly CommerceReconcilerProduct[];
73
+ /**
74
+ * The player the session's credentials belong to right now, read
75
+ * synchronously: pass it whenever `getHeaders` (or the store account
76
+ * binding) follows a session that can change player within the run. It is
77
+ * checked before every backend request and again once its headers are
78
+ * resolved (no request leaves with another player's credentials), and by
79
+ * the commerce reconciler before it journals, verifies, restores, or
80
+ * finishes anything and right before a store UI opens. The store
81
+ * provider resolves its account binding after that last check, so the
82
+ * binding resolver must follow this player too (bind nothing for anyone
83
+ * else) for a purchase never to be journaled here and bound to another
84
+ * account; a binding refused while the session changed stays retryable.
85
+ * Another
86
+ * player closes the runtime for good, as `dispose()` does, with
87
+ * `gameServicesPlayerChangedCode`; no player refuses with
88
+ * `gameServicesSessionRequiredCode` and leaves the runtime open. Refusals
89
+ * are retryable and end no record, so a purchase of this player is never
90
+ * verified with another player's session (which a backend answers with a
91
+ * permanent account-mismatch rejection) and is resumed by a runtime for
92
+ * this player later. A custom `httpTransport` must send only the headers
93
+ * the runtime resolved for each request.
94
+ */
95
+ readonly sessionPlayerId?: () => string | undefined;
96
+ /**
97
+ * This player's store account binding, derived from `playerId` the way
98
+ * the store provider binds the player's new purchases (Google Play's
99
+ * obfuscated account id, StoreKit's app account token). With it the
100
+ * commerce reconciler leaves a store purchase bound to another account
101
+ * alone instead of journaling and verifying it for this player (a backend
102
+ * rejects that for good as an account mismatch): after a restart as
103
+ * another player, Google Play still lists the previous player's
104
+ * unacknowledged purchases. The player's Restore Purchases still takes a
105
+ * durable bound to another account (restore sharing). Derive it from
106
+ * `playerId`, not from the live session. Pass it only for a store whose
107
+ * purchases carry this kind of binding: answering `undefined` leaves every
108
+ * bound purchase alone, which is meant only while the binding is briefly
109
+ * unreadable. Omitted, nothing is compared (for example Microsoft Store,
110
+ * whose purchases carry their own claimed scope).
111
+ */
112
+ readonly commerceAccountBinding?: CreateCommerceReconcilerInput['accountBinding'];
113
+ /**
114
+ * The reconciler's `staleLaunchedAfterMs`: how long a `launched` purchase
115
+ * with no store purchase keeps serializing its product before it ends
116
+ * `failed`. Defaults to 24 hours; `Infinity` never expires it.
117
+ */
118
+ readonly commerceStaleLaunchedAfterMs?: number;
50
119
  }
51
120
  export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
52
121
  readonly transport?: 'http';
@@ -67,4 +136,3 @@ export declare function resolveGameServicesTransport(transport: string | undefin
67
136
  * notifications client's starter wiring uses it too.
68
137
  */
69
138
  export declare function isPublicGameServicesUrl(baseUrl: string): boolean;
70
- export {};
package/dist/runtime.js CHANGED
@@ -1,13 +1,24 @@
1
- import { createAccountDeletionClient } from './account-deletion.js';
2
- import { createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
1
+ import { AccountDeletionError, createAccountDeletionClient, } from './account-deletion.js';
2
+ import { CommerceError } from '@mpgd/platform/commerce';
3
+ import { assertCommerceStaleLaunchedAfterMs, createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
4
+ import { gameServicesPlayerChangedCode, GameServicesRuntimeClosedError, gameServicesRuntimeDisposedCode, gameServicesSessionRequiredCode, } from './request-headers.js';
3
5
  import { createRecoverableMonetizationClient, } from './monetization-recovery.js';
6
+ /**
7
+ * How long `dispose()` waits for a purchase whose store sheet is open to
8
+ * journal its answer, and then for the reconciler to stop: 3 minutes, past
9
+ * Google Play's own wait for a purchase result.
10
+ */
11
+ export const gameServicesDisposeWaitMs = 3 * 60 * 1000;
12
+ export { gameServicesPlayerChangedCode, gameServicesRuntimeDisposedCode, GameServicesRuntimeClosedError, gameServicesSessionRequiredCode, } from './request-headers.js';
4
13
  export function createGameServicesRuntime(input) {
5
14
  assertAuthorityMode(input.authorityMode);
6
15
  assertTransport(input.transport);
16
+ assertCommerceStaleLaunchedAfterMs(input.commerceStaleLaunchedAfterMs);
7
17
  if (input.transport === 'orpc' && input.httpTransport !== undefined) {
8
18
  throw new Error('Game Services httpTransport cannot be used with oRPC.');
9
19
  }
10
20
  const target = resolveGameServicesLedgerTarget(input.target ?? input.gateway.target);
21
+ const gate = createRuntimeGate(input.playerId, input.sessionPlayerId);
11
22
  if (target === null) {
12
23
  return disabledRuntime('unsupported_target');
13
24
  }
@@ -30,10 +41,10 @@ export function createGameServicesRuntime(input) {
30
41
  ? createGameServicesOrpcBackendApi(createGameServicesOrpcClient({
31
42
  url: baseUrl,
32
43
  ...(input.headers === undefined ? {} : { headers: input.headers }),
33
- ...(input.getHeaders === undefined ? {} : { getHeaders: input.getHeaders }),
44
+ getHeaders: guardHeaders(input.getHeaders, gate),
34
45
  }))
35
46
  : createGameServicesHttpBackendApi({
36
- transport: withRuntimeHeaders(input.httpTransport ?? createGameServicesFetchBackendTransport({ baseUrl }), input),
47
+ transport: withRuntimeHeaders(input.httpTransport ?? createGameServicesFetchBackendTransport({ baseUrl }), input, gate),
37
48
  });
38
49
  }
39
50
  else {
@@ -49,8 +60,11 @@ export function createGameServicesRuntime(input) {
49
60
  mode = 'local';
50
61
  backend = input.localBackend;
51
62
  }
63
+ // Every backend call is refused before it starts once the runtime is closed.
64
+ backend = guardBackend(backend, gate);
52
65
  const clientInput = {
53
- gateway: input.gateway,
66
+ // A rewarded ad queued before the runtime closed is not shown after it.
67
+ gateway: guardRewardedAds(input.gateway, gate),
54
68
  backend,
55
69
  playerId: input.playerId,
56
70
  target,
@@ -64,6 +78,23 @@ export function createGameServicesRuntime(input) {
64
78
  ...(input.now === undefined ? {} : { now: input.now }),
65
79
  ...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
66
80
  ...(input.commerceProducts === undefined ? {} : { commerceProducts: input.commerceProducts }),
81
+ ...(input.commerceAccountBinding === undefined ? {} : { commerceAccountBinding: input.commerceAccountBinding }),
82
+ commerceSessionCheck: () => {
83
+ const refusal = gate.check();
84
+ return refusal === undefined
85
+ ? undefined
86
+ : new CommerceError('ACCOUNT_BINDING_REQUIRED', {
87
+ retryable: true,
88
+ storeCode: refusal.code,
89
+ message: refusal.message,
90
+ });
91
+ },
92
+ // Closed for good, the reconciler starts nothing: a recovery pass that
93
+ // reaches `start()` after `dispose()` stopped it subscribes nothing.
94
+ commerceClosed: () => gate.closed,
95
+ ...(input.commerceStaleLaunchedAfterMs === undefined
96
+ ? {}
97
+ : { commerceStaleLaunchedAfterMs: input.commerceStaleLaunchedAfterMs }),
67
98
  };
68
99
  const monetizationRecovery = input.operationStore === undefined
69
100
  ? undefined
@@ -88,34 +119,331 @@ export function createGameServicesRuntime(input) {
88
119
  : { commerceReconciler: monetizationRecovery.commerceReconciler }),
89
120
  };
90
121
  }
122
+ const reconciler = routedClient.commerceReconciler;
123
+ /**
124
+ * Purchases, rewarded-ad claims, and recovery passes running now:
125
+ * `dispose()` lets them journal first.
126
+ */
127
+ const purchases = new Set();
128
+ const track = (running) => {
129
+ purchases.add(running);
130
+ void running.finally(() => purchases.delete(running)).catch(() => undefined);
131
+ return running;
132
+ };
133
+ const exposedReconciler = reconciler === undefined
134
+ ? undefined
135
+ : guardReconciler(reconciler, track);
136
+ gate.onClose(async () => {
137
+ // A purchase whose store sheet is open journals the store answer first,
138
+ // an open rewarded ad its platform result (both stay this player's to
139
+ // recover), and a recovery pass finishes its start, then the reconciler
140
+ // stops.
141
+ await settleWithin(Promise.allSettled([...purchases]), gameServicesDisposeWaitMs);
142
+ await settleWithin(reconciler?.stop() ?? Promise.resolve(), gameServicesDisposeWaitMs);
143
+ });
91
144
  return {
92
145
  mode,
93
146
  ...(baseUrl === undefined ? {} : { baseUrl }),
94
147
  target,
95
- client: routedClient,
96
- ...(monetizationRecovery === undefined ? {} : { monetizationRecovery }),
148
+ client: guardClient(routedClient, gate, track, exposedReconciler),
149
+ ...(monetizationRecovery === undefined
150
+ ? {}
151
+ : { monetizationRecovery: guardRecovery(monetizationRecovery, gate, exposedReconciler, track) }),
97
152
  ...(baseUrl === undefined
98
153
  ? {}
99
154
  : {
100
- accountDeletion: createAccountDeletionClient({
155
+ accountDeletion: guardAccountDeletion(createAccountDeletionClient({
101
156
  // The deletion route is plain HTTP next to the oRPC handler: an
102
157
  // oRPC URL names the handler's `/rpc` prefix, which the route
103
158
  // does not sit under.
104
159
  baseUrl: input.transport === 'orpc' ? baseUrl.replace(/\/rpc\/?$/u, '') : baseUrl,
105
160
  ...(input.headers === undefined ? {} : { headers: input.headers }),
106
- ...(input.getHeaders === undefined ? {} : { getHeaders: input.getHeaders }),
161
+ getHeaders: guardHeaders(input.getHeaders, gate),
162
+ }), gate),
163
+ }),
164
+ dispose: () => gate.close(gameServicesRuntimeDisposedCode),
165
+ };
166
+ }
167
+ function createRuntimeGate(playerId, sessionPlayerId) {
168
+ let closed;
169
+ let closing;
170
+ const teardowns = [];
171
+ const gate = {
172
+ check() {
173
+ if (closed !== undefined) {
174
+ return closed;
175
+ }
176
+ if (sessionPlayerId === undefined) {
177
+ return undefined;
178
+ }
179
+ let current;
180
+ try {
181
+ current = sessionPlayerId()?.trim();
182
+ }
183
+ catch {
184
+ current = undefined;
185
+ }
186
+ if (current === undefined || current === '') {
187
+ return new GameServicesRuntimeClosedError(gameServicesSessionRequiredCode);
188
+ }
189
+ if (current !== playerId.trim()) {
190
+ void gate.close(gameServicesPlayerChangedCode);
191
+ return closed;
192
+ }
193
+ return undefined;
194
+ },
195
+ get closed() {
196
+ return closed !== undefined;
197
+ },
198
+ assert() {
199
+ const refusal = gate.check();
200
+ if (refusal !== undefined) {
201
+ throw refusal;
202
+ }
203
+ },
204
+ close(code) {
205
+ closed ??= new GameServicesRuntimeClosedError(code);
206
+ closing ??= (async () => {
207
+ for (const teardown of teardowns) {
208
+ try {
209
+ await teardown();
210
+ }
211
+ catch {
212
+ // Closed regardless: every operation is refused from now on.
213
+ }
214
+ }
215
+ })();
216
+ return closing;
217
+ },
218
+ onClose(teardown) {
219
+ teardowns.push(teardown);
220
+ },
221
+ };
222
+ return gate;
223
+ }
224
+ /**
225
+ * Account deletion refused before anything resolves or is sent: signed out
226
+ * answers `UNAUTHORIZED` (no session on this device), a closed runtime the
227
+ * retryable `ACCOUNT_DELETION_UNREACHABLE`. Static headers never stand in
228
+ * for a session the runtime refuses.
229
+ */
230
+ function guardAccountDeletion(deletion, gate) {
231
+ return {
232
+ ...deletion,
233
+ async deleteAccount(request) {
234
+ const refusal = gate.check();
235
+ if (refusal !== undefined) {
236
+ throw new AccountDeletionError(refusal.code === gameServicesSessionRequiredCode
237
+ ? 'UNAUTHORIZED'
238
+ : 'ACCOUNT_DELETION_UNREACHABLE', { requestSent: false, cause: refusal });
239
+ }
240
+ return deletion.deleteAccount(request);
241
+ },
242
+ };
243
+ }
244
+ function guardHeaders(getHeaders, gate) {
245
+ return async () => {
246
+ gate.assert();
247
+ const headers = (await getHeaders?.()) ?? {};
248
+ // A session that changed player while its headers were resolved: they
249
+ // may be the new player's, so they are never sent for this one.
250
+ gate.assert();
251
+ return headers;
252
+ };
253
+ }
254
+ function guardBackend(backend, gate) {
255
+ const { purchases, adRewards, leaderboard } = backend;
256
+ const ledgerStatus = purchases.getPurchaseLedgerStatus;
257
+ const capabilities = purchases.getCommerceCapabilities;
258
+ return {
259
+ ...(backend.version === undefined ? {} : { version: backend.version }),
260
+ purchases: {
261
+ async verifyPurchase(request) {
262
+ gate.assert();
263
+ return purchases.verifyPurchase(request);
264
+ },
265
+ ...(ledgerStatus === undefined
266
+ ? {}
267
+ : {
268
+ async getPurchaseLedgerStatus(request) {
269
+ gate.assert();
270
+ return ledgerStatus.call(purchases, request);
271
+ },
272
+ }),
273
+ ...(capabilities === undefined
274
+ ? {}
275
+ : {
276
+ async getCommerceCapabilities() {
277
+ gate.assert();
278
+ return capabilities.call(purchases);
279
+ },
107
280
  }),
281
+ },
282
+ adRewards: {
283
+ async claimAdReward(request) {
284
+ gate.assert();
285
+ return adRewards.claimAdReward(request);
286
+ },
287
+ },
288
+ leaderboard: {
289
+ async recordScore(request) {
290
+ gate.assert();
291
+ return leaderboard.recordScore(request);
292
+ },
293
+ },
294
+ };
295
+ }
296
+ /** Resolves when `work` settles or after `ms`, whichever is first; never rejects. */
297
+ async function settleWithin(work, ms) {
298
+ let timer;
299
+ try {
300
+ await Promise.race([
301
+ work.then(() => undefined, () => undefined),
302
+ new Promise((resolve) => {
303
+ timer = setTimeout(resolve, ms);
108
304
  }),
305
+ ]);
306
+ }
307
+ finally {
308
+ clearTimeout(timer);
309
+ }
310
+ }
311
+ /**
312
+ * The gateway with a rewarded-ad dispatch that throws the refusal once the
313
+ * runtime refuses, so no ad opens. It is not an ad outcome: a journaled
314
+ * operation stays unstarted (not ended), and `claimRewardedAd` answers
315
+ * `unavailable`.
316
+ */
317
+ function guardRewardedAds(gateway, gate) {
318
+ const ads = gateway.ads;
319
+ return {
320
+ ...gateway,
321
+ ads: {
322
+ ...ads,
323
+ async showRewarded(request) {
324
+ gate.assert();
325
+ return ads.showRewarded(request);
326
+ },
327
+ },
328
+ };
329
+ }
330
+ /** A reward claim through an open runtime; a dispatch the runtime refused answers `unavailable`. */
331
+ async function guardedReward(gate, track, run) {
332
+ if (gate.check() !== undefined) {
333
+ return refusedReward();
334
+ }
335
+ try {
336
+ return await track(run());
337
+ }
338
+ catch (error) {
339
+ if (error instanceof GameServicesRuntimeClosedError) {
340
+ return refusedReward();
341
+ }
342
+ throw error;
343
+ }
344
+ }
345
+ function refusedPurchase(refusal) {
346
+ return {
347
+ status: 'failed',
348
+ purchase: {
349
+ status: 'failed',
350
+ entitlementIds: [],
351
+ diagnostic: { code: refusal.code, retryable: true },
352
+ },
353
+ };
354
+ }
355
+ function refusedReward() {
356
+ return { status: 'unavailable', reward: { status: 'unavailable', rewardGranted: false } };
357
+ }
358
+ /**
359
+ * A purchase through an open runtime. A refusal the reconciler gave because
360
+ * the runtime closed meanwhile answers the runtime's diagnostic, as a
361
+ * refusal at the entry does.
362
+ */
363
+ async function guardedPurchase(gate, track, run) {
364
+ const refusal = gate.check();
365
+ if (refusal !== undefined) {
366
+ return refusedPurchase(refusal);
367
+ }
368
+ const result = await track(run());
369
+ const late = result.status === 'failed'
370
+ && result.purchase.diagnostic?.code === 'ACCOUNT_BINDING_REQUIRED'
371
+ ? gate.check()
372
+ : undefined;
373
+ return late === undefined ? result : refusedPurchase(late);
374
+ }
375
+ /**
376
+ * The reconciler a runtime exposes. Once the runtime closed for good its
377
+ * `start()` subscribes nothing (the reconciler's `closed`); while only signed
378
+ * out it starts as usual and its work waits for the session (its
379
+ * `sessionCheck`). Its purchases are tracked like the client's, so
380
+ * `dispose()` waits for an open store sheet's answer.
381
+ */
382
+ function guardReconciler(reconciler, track) {
383
+ // The reconciler's getters and methods are closures: the prototype chain serves them.
384
+ return Object.create(reconciler, {
385
+ requestPurchase: {
386
+ value: (request) => track(reconciler.requestPurchase(request)),
387
+ },
388
+ });
389
+ }
390
+ /** The client of an open runtime; refusals answer before any store UI or ad opens. */
391
+ function guardClient(client, gate, track, reconciler) {
392
+ return {
393
+ ...client,
394
+ ...(reconciler === undefined ? {} : { commerceReconciler: reconciler }),
395
+ purchase: (purchase, options) => guardedPurchase(gate, track, () => client.purchase(purchase, options)),
396
+ claimRewardedAd: (reward, options) => guardedReward(gate, track, () => client.claimRewardedAd(reward, options)),
397
+ async submitLeaderboardScore(score) {
398
+ gate.assert();
399
+ return client.submitLeaderboardScore(score);
400
+ },
401
+ };
402
+ }
403
+ function guardRecovery(recovery, gate, reconciler, track) {
404
+ return {
405
+ ...recovery,
406
+ ...(reconciler === undefined ? {} : { commerceReconciler: reconciler }),
407
+ purchase: (purchase, options) => guardedPurchase(gate, track, () => recovery.purchase(purchase, options)),
408
+ claimRewardedAd: (reward, options) => guardedReward(gate, track, () => recovery.claimRewardedAd(reward, options)),
409
+ // A late ad callback is journaled; its claim is refused until a runtime
410
+ // for the player resends it, so it stays pending, never ended.
411
+ recoverRewardResult: (idempotencyKey, platform) => recovery.recoverRewardResult(idempotencyKey, platform),
412
+ async reconcile() {
413
+ // Startup and resume recovery wait for a runtime the session permits.
414
+ // `dispose()` waits for a pass under way, so the reconciler it starts
415
+ // is stopped after it, not before.
416
+ return gate.check() === undefined ? track(recovery.reconcile()) : [];
417
+ },
418
+ async restorePurchases() {
419
+ gate.assert();
420
+ let restored;
421
+ try {
422
+ restored = await recovery.restorePurchases();
423
+ }
424
+ catch (error) {
425
+ // Closed during the restore: the runtime's refusal says why.
426
+ gate.assert();
427
+ throw error;
428
+ }
429
+ // Closed during the restore: its passes stopped early for this player.
430
+ gate.assert();
431
+ return restored;
432
+ },
109
433
  };
110
434
  }
111
- function withRuntimeHeaders(transport, input) {
435
+ function withRuntimeHeaders(transport, input, gate) {
112
436
  return {
113
437
  async send(request) {
438
+ gate.assert();
114
439
  const headers = await resolveGameServicesRequestHeaders({
115
440
  headers: input.headers,
116
441
  getHeaders: input.getHeaders,
117
442
  requestHeaders: request.headers,
118
443
  });
444
+ // A session that changed player while its headers were resolved: they
445
+ // may be the new player's, so they are never sent for this one.
446
+ gate.assert();
119
447
  return transport.send({
120
448
  ...request,
121
449
  headers,
@@ -156,6 +484,8 @@ function disabledRuntime(reason, target) {
156
484
  mode: 'disabled',
157
485
  reason,
158
486
  ...(target === undefined ? {} : { target }),
487
+ // Nothing to close: a disabled runtime holds no client.
488
+ dispose: async () => undefined,
159
489
  };
160
490
  }
161
491
  function normalizeBaseUrl(baseUrl) {
package/dist/server.d.ts CHANGED
@@ -152,6 +152,15 @@ export interface CreateGameServicesRouterOptions {
152
152
  }
153
153
  export interface CreateGameServicesRpcFetchHandlerOptions extends CreateGameServicesFetchHandlerOptions {
154
154
  readonly prefix?: string;
155
+ /**
156
+ * Writes the pre-beta.34 `inferable: false` flag into oRPC error bodies so game clients
157
+ * from `@mpgd/game-services` 0.30.5 or older (oRPC 2.0.0-beta.14) still decode error
158
+ * codes. A plain oRPC 2.0.0-beta.34+ client rejects that flag: such clients must wrap their
159
+ * fetch with `withLegacyOrpcErrorCompat`, as `createGameServicesOrpcClient` from 0.31.0 or
160
+ * later does. Set this to false once every supported game client comes from 0.31.0 or
161
+ * later. Defaults to true.
162
+ */
163
+ readonly legacyOrpcErrorFlag?: boolean;
155
164
  }
156
165
  export interface GameServicesOrpcContext {
157
166
  readonly request?: Request;
@@ -217,17 +226,17 @@ export declare function createGameServicesBackend(input: CreateGameServicesBacke
217
226
  export declare function createGameServicesBackendApiHandler(input: CreateGameServicesBackendInput): GameServicesBackendApiHandler;
218
227
  export declare function createInProcessGameServicesBackendTransport(handler: GameServicesBackendApiHandler): GameServicesBackendTransport;
219
228
  export declare function createGameServicesRouter(backend: GameServicesBackendApi, options?: CreateGameServicesRouterOptions): {
220
- health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
229
+ health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, Record<never, never>>;
221
230
  commerce: {
222
- verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
223
- getPurchaseLedgerStatus: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<import("./operation-results.js").PurchaseLedgerStatusResponse, import("./operation-results.js").PurchaseLedgerStatusResponse>, object>;
224
- getCapabilities: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<import("@mpgd/platform/commerce").CommerceServiceCapabilities, import("@mpgd/platform/commerce").CommerceServiceCapabilities>, object>;
231
+ verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, Record<never, never>>;
232
+ getPurchaseLedgerStatus: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<import("./operation-results.js").PurchaseLedgerStatusResponse, import("./operation-results.js").PurchaseLedgerStatusResponse>, Record<never, never>>;
233
+ getCapabilities: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<import("@mpgd/platform/commerce").CommerceServiceCapabilities, import("@mpgd/platform/commerce").CommerceServiceCapabilities>, Record<never, never>>;
225
234
  };
226
235
  ads: {
227
- claimReward: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
236
+ claimReward: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, Record<never, never>>;
228
237
  };
229
238
  leaderboard: {
230
- recordScore: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, object>;
239
+ recordScore: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, Record<never, never>>;
231
240
  };
232
241
  };
233
242
  export declare function createGameServicesRpcFetchHandler(router: ReturnType<typeof createGameServicesRouter>, options?: CreateGameServicesRpcFetchHandlerOptions): (request: Request) => Promise<Response>;
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { implement, ORPCError } from '@orpc/server';
2
- import { BodyLimitHandlerPlugin, RPCHandler } from '@orpc/server/fetch';
2
+ import { RPCHandler } from '@orpc/server/fetch';
3
+ import { RequestLimitHandlerPlugin } from '@orpc/server/plugins';
3
4
  import { createAnalyticsReporter } from '@mpgd/analytics';
4
5
  import { resolveAdPlacementPlatformId, resolveCatalogCurrencies, resolveProductPlatformId, } from '@mpgd/catalog';
5
6
  import { gameServicesBackendEndpoints, } from './client.js';
@@ -7,6 +8,7 @@ import { resolveCommerceStoreId, } from './commerce-evidence-registry.js';
7
8
  import { createPurchaseHolderEvidenceVerificationId, findEntitlementRevocationsChunked, findPurchaseWideRevocation, holderRevocationEvidenceId, isEntitlementGrantReversed, isRestoredAcrossPlayers, listPurchaseHolders, readPurchaseLedgerStatuses, readPurchaseVerificationId, RevocationKeyConflictError, revokeEntitlementGrant, } from './commerce-ledger.js';
8
9
  import { assertPlayerDataErasureInput, createRetainedLedgerEntryId, detachRetainedLedgerPayload, isRetainedPlayerId, } from './account-deletion.js';
9
10
  import { gameServicesContract } from './contract.js';
11
+ import { addLegacyOrpcErrorFlag } from './orpc-error-compat.js';
10
12
  import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
11
13
  import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerNote, assertEntitlementLedgerResult, assertEntitlementLedgerRevocation, assertEntitlementRevocationTransaction, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
12
14
  import { resolvePurchaseRestoreSharingPolicy, } from './purchase-sharing.js';
@@ -613,13 +615,14 @@ export function createGameServicesRouter(backend, options = {}) {
613
615
  export function createGameServicesRpcFetchHandler(router, options = {}) {
614
616
  const rpcHandler = new RPCHandler(router, {
615
617
  plugins: [
616
- new BodyLimitHandlerPlugin({
618
+ new RequestLimitHandlerPlugin({
617
619
  maxBodySize: resolveMaxBodyBytes(options.maxBodyBytes),
618
620
  }),
619
621
  ],
620
622
  });
621
623
  const prefix = options.prefix ?? '/rpc';
622
624
  const healthPath = options.healthPath ?? '/health';
625
+ const legacyOrpcErrorFlag = options.legacyOrpcErrorFlag ?? true;
623
626
  return async (request) => {
624
627
  const requestUrl = new URL(request.url);
625
628
  if (requestUrl.pathname === healthPath) {
@@ -637,7 +640,11 @@ export function createGameServicesRpcFetchHandler(router, options = {}) {
637
640
  if (!result.matched) {
638
641
  return textResponse('Not Found', 404, options.corsHeaders);
639
642
  }
640
- return withCors(result.response, options.corsHeaders);
643
+ // Error bodies keep the pre-beta.34 `inferable` flag so older game clients decode them.
644
+ const response = legacyOrpcErrorFlag
645
+ ? await addLegacyOrpcErrorFlag(result.response)
646
+ : result.response;
647
+ return withCors(response, options.corsHeaders);
641
648
  };
642
649
  }
643
650
  export function createGameServicesHttpFetchHandler(handler, options = {}) {
@@ -985,6 +992,22 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
985
992
  finalization,
986
993
  });
987
994
  }
995
+ if (product.type === 'consumable'
996
+ && routedEntry?.status === 'routed'
997
+ && routedEntry.entry.finish === 'client') {
998
+ // This player's own grant of a consumable the client finishes
999
+ // (StoreKit): the store still lists the transaction, so its finish
1000
+ // never happened or never reached the client's journal, and the
1001
+ // client's record of it is gone (journal retention), so it asks
1002
+ // under the derived key. The recorded grant is reported, never a
1003
+ // second one, so the client can finish the transaction instead of
1004
+ // the store re-delivering it on every launch.
1005
+ return assertVerifyPurchaseResponse({
1006
+ verified: true,
1007
+ ledgerEntryId: existingEvidence.ledgerEntryId,
1008
+ alreadyProcessed: true,
1009
+ });
1010
+ }
988
1011
  }
989
1012
  return assertVerifyPurchaseResponse({
990
1013
  verified: false,