@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.
@@ -7,17 +7,17 @@ export interface GameServicesHealthResponse {
7
7
  readonly version: string;
8
8
  }
9
9
  export declare const gameServicesContract: {
10
- health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
10
+ health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, Record<never, never>>;
11
11
  commerce: {
12
- verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
13
- getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>, object>;
14
- getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>, object>;
12
+ verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, Record<never, never>>;
13
+ getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>, Record<never, never>>;
14
+ getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>, Record<never, never>>;
15
15
  };
16
16
  ads: {
17
- claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
17
+ claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, Record<never, never>>;
18
18
  };
19
19
  leaderboard: {
20
- recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, object>;
20
+ recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, Record<never, never>>;
21
21
  };
22
22
  };
23
23
  export type GameServicesContract = typeof gameServicesContract;
@@ -34,8 +34,9 @@ export type EvidenceVerificationDecision = {
34
34
  * check passed. The reference verifiers answer it only for a request
35
35
  * with `intent: 'restore'` of a non-consumable; the backend then grants
36
36
  * the player its own share under its `restoreSharing` policy, or
37
- * answers `rejected` with `reason` (the binding mismatch) when the
38
- * policy refuses. Fields are those of `verified`.
37
+ * answers `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` with `reason` (the
38
+ * binding mismatch) when the policy refuses, as for a `rejected`
39
+ * decision with `anotherAccount`. Fields are those of `verified`.
39
40
  */
40
41
  readonly status: 'restorable';
41
42
  readonly reason: string;
@@ -49,6 +50,17 @@ export type EvidenceVerificationDecision = {
49
50
  } | {
50
51
  readonly status: 'rejected';
51
52
  readonly reason: string;
53
+ /**
54
+ * The store binds the purchase to another account than the requesting
55
+ * player's (`reason` is the binding mismatch, for example
56
+ * `GOOGLE_PLAY_ACCOUNT_MISMATCH`), and every other check passed: the
57
+ * purchase is valid, only not this player's (RFC "Another account's
58
+ * purchase"). The backend grants nothing, records nothing, and answers
59
+ * the retryable `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error instead of a
60
+ * `rejected` receipt, so the purchase stays recoverable by its account.
61
+ * A caller that reads only `status` still refuses it.
62
+ */
63
+ readonly anotherAccount?: true;
52
64
  };
53
65
  export interface VerifyPurchaseEvidenceInput {
54
66
  readonly request: VerifyPurchaseRequest;
@@ -326,6 +326,8 @@ async function inspectGooglePlayPurchase(input) {
326
326
  return rejected('GOOGLE_PLAY_PROFILE_INVALID');
327
327
  }
328
328
  let foreignBinding;
329
+ /** The purchase carries no id where the player's binding has one (bought outside the app). */
330
+ let missingBinding;
329
331
  if (input.accountBinding.status === 'bound') {
330
332
  // Defensive invariant: every bound context must carry at least one
331
333
  // identifier, even though the constructors below already enforce it.
@@ -338,13 +340,25 @@ async function inspectGooglePlayPurchase(input) {
338
340
  if (input.accountBinding.accountId !== undefined
339
341
  && obfuscatedExternalAccountId !== input.accountBinding.accountId) {
340
342
  foreignBinding = 'GOOGLE_PLAY_ACCOUNT_MISMATCH';
343
+ missingBinding = obfuscatedExternalAccountId === undefined
344
+ ? 'GOOGLE_PLAY_ACCOUNT_ID_MISSING'
345
+ : undefined;
341
346
  }
342
347
  else if (input.accountBinding.profileId !== undefined
343
348
  && obfuscatedExternalProfileId !== input.accountBinding.profileId) {
344
349
  foreignBinding = 'GOOGLE_PLAY_PROFILE_MISMATCH';
350
+ missingBinding = obfuscatedExternalProfileId === undefined
351
+ ? 'GOOGLE_PLAY_PROFILE_ID_MISSING'
352
+ : undefined;
345
353
  }
346
354
  if (foreignBinding !== undefined && input.reportForeignBinding !== true) {
347
- return rejected(foreignBinding);
355
+ // A purchase with no binding is no account's: refused for every
356
+ // player. One bound to another account is that account's, which can
357
+ // still recover it: it is reported, not refused for good (RFC
358
+ // "Another account's purchase"). Every check before this one passed.
359
+ return missingBinding === undefined
360
+ ? { status: 'rejected', reason: foreignBinding, anotherAccount: true }
361
+ : rejected(missingBinding);
348
362
  }
349
363
  if (foreignBinding === undefined
350
364
  && input.accountBinding.profileId === undefined
@@ -357,15 +371,19 @@ async function inspectGooglePlayPurchase(input) {
357
371
  return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_REQUIRED');
358
372
  }
359
373
  }
360
- // The unbound opt-in is safe only when the provider response is also unbound.
374
+ // The unbound opt-in is safe only when the provider response is also
375
+ // unbound. This backend binds no player, so no player of it can claim a
376
+ // bound purchase: refused for every player, never as another account's.
361
377
  if (input.accountBinding.status === 'unbound'
362
378
  && (obfuscatedExternalAccountId !== undefined
363
379
  || obfuscatedExternalProfileId !== undefined)) {
364
- return rejected('GOOGLE_PLAY_ACCOUNT_MISMATCH');
380
+ return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED');
365
381
  }
366
382
  return {
367
383
  status: 'verified',
368
- ...(foreignBinding === undefined ? {} : { foreignBinding }),
384
+ // A restore reports which: another account's binding, or none at all
385
+ // (the backend refuses the latter for good when no share covers it).
386
+ ...(foreignBinding === undefined ? {} : { foreignBinding: missingBinding ?? foreignBinding }),
369
387
  purchase: {
370
388
  purchaseToken: input.purchaseToken,
371
389
  purchaseTokenDigest: await digestPurchaseToken(input.packageName, input.purchaseToken),
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './account-deletion.js';
2
2
  export * from './admob-ssv.js';
3
+ export * from './another-account.js';
3
4
  export * from './ad-reward-verifier-registry.js';
4
5
  export * from './app-store-verifier.js';
5
6
  export * from './app-store-recovery.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './account-deletion.js';
2
2
  export * from './admob-ssv.js';
3
+ export * from './another-account.js';
3
4
  export * from './ad-reward-verifier-registry.js';
4
5
  export * from './app-store-verifier.js';
5
6
  export * from './app-store-recovery.js';
@@ -29,7 +29,7 @@ export function createRecoverableMonetizationClient(input) {
29
29
  const commerceReconciler = commerceClient.commerceReconciler;
30
30
  // The per-operation rewarded-ad clients see a gateway without the commerce
31
31
  // provider, so they never build a second reconciler over the journal.
32
- const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, ...legacyClientInput } = input;
32
+ const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, commerceStaleLaunchedAfterMs: _commerceStaleLaunchedAfterMs, ...legacyClientInput } = input;
33
33
  const { commerce: _commerce, ...legacyGateway } = input.gateway;
34
34
  async function serializeOperation(key, task) {
35
35
  const previous = inFlight.get(key) ?? Promise.resolve();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * oRPC error-body compatibility across the 2.0.0-beta.34 `inferable` removal.
3
+ *
4
+ * oRPC 2.0.0-beta.14 writes RPC error bodies as
5
+ * `{ json: { defined, inferable, code, message, data? } }` and its client rejects a body
6
+ * without a boolean `inferable`. Since 2.0.0-beta.34 (middleapi/orpc#1987) the server
7
+ * omits `inferable` and the client rejects a body that has it. Either way a client on
8
+ * the other version reports a malformed response instead of the error code, so a game
9
+ * built against one kit release and a Worker built against another would lose
10
+ * `BAD_REQUEST` and `NOT_FOUND` (and with it the commerce reconciler's "v1 backend"
11
+ * detection) whenever they are deployed separately.
12
+ *
13
+ * The game-services oRPC transport therefore bridges both directions:
14
+ * - the server adds `inferable: false` to error bodies, so older clients still decode them;
15
+ * - the client removes a boolean `inferable` before oRPC validates, so it still decodes
16
+ * older servers' errors. Nothing else in the body changes, so a body that is malformed
17
+ * for any other reason is still rejected by oRPC.
18
+ *
19
+ * Success responses are passed through untouched in both directions.
20
+ *
21
+ * Remove this module when the oldest supported game-services client and every supported
22
+ * Worker come from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46), that is, once
23
+ * no client or Worker from 0.30.5 or older (oRPC 2.0.0-beta.14) is supported.
24
+ */
25
+ /**
26
+ * Server side: adds `inferable: false` to an oRPC error body that lacks it.
27
+ * Responses that are not oRPC JSON error bodies are returned unchanged.
28
+ */
29
+ export declare function addLegacyOrpcErrorFlag(response: Response): Promise<Response>;
30
+ /**
31
+ * Client side: removes a boolean `inferable` from an oRPC error body so a current oRPC
32
+ * client accepts errors from a pre-beta.34 server. Only that key is removed; oRPC still
33
+ * validates the rest of the body as usual.
34
+ */
35
+ export declare function stripLegacyOrpcErrorFlag(response: Response): Promise<Response>;
36
+ /**
37
+ * Wraps an oRPC link `fetch` so every response passes through
38
+ * {@link stripLegacyOrpcErrorFlag}. Any oRPC 2.0.0-beta.34+ client that talks to a
39
+ * game-services Worker must use it while the Worker writes the legacy flag. Extra arguments
40
+ * oRPC passes (client options, procedure path) are forwarded unchanged.
41
+ */
42
+ export declare function withLegacyOrpcErrorCompat<Rest extends unknown[]>(fetcher: (url: string, init: RequestInit, ...rest: Rest) => Promise<Response>): (url: string, init: RequestInit, ...rest: Rest) => Promise<Response>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * oRPC error-body compatibility across the 2.0.0-beta.34 `inferable` removal.
3
+ *
4
+ * oRPC 2.0.0-beta.14 writes RPC error bodies as
5
+ * `{ json: { defined, inferable, code, message, data? } }` and its client rejects a body
6
+ * without a boolean `inferable`. Since 2.0.0-beta.34 (middleapi/orpc#1987) the server
7
+ * omits `inferable` and the client rejects a body that has it. Either way a client on
8
+ * the other version reports a malformed response instead of the error code, so a game
9
+ * built against one kit release and a Worker built against another would lose
10
+ * `BAD_REQUEST` and `NOT_FOUND` (and with it the commerce reconciler's "v1 backend"
11
+ * detection) whenever they are deployed separately.
12
+ *
13
+ * The game-services oRPC transport therefore bridges both directions:
14
+ * - the server adds `inferable: false` to error bodies, so older clients still decode them;
15
+ * - the client removes a boolean `inferable` before oRPC validates, so it still decodes
16
+ * older servers' errors. Nothing else in the body changes, so a body that is malformed
17
+ * for any other reason is still rejected by oRPC.
18
+ *
19
+ * Success responses are passed through untouched in both directions.
20
+ *
21
+ * Remove this module when the oldest supported game-services client and every supported
22
+ * Worker come from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46), that is, once
23
+ * no client or Worker from 0.30.5 or older (oRPC 2.0.0-beta.14) is supported.
24
+ */
25
+ const legacyErrorFlag = 'inferable';
26
+ /**
27
+ * Server side: adds `inferable: false` to an oRPC error body that lacks it.
28
+ * Responses that are not oRPC JSON error bodies are returned unchanged.
29
+ */
30
+ export async function addLegacyOrpcErrorFlag(response) {
31
+ if (!isJsonErrorResponse(response)) {
32
+ return response;
33
+ }
34
+ const text = await response.text();
35
+ const envelope = parseErrorEnvelope(text);
36
+ if (envelope === undefined || Object.hasOwn(envelope.json, legacyErrorFlag)) {
37
+ return rebuild(response, text);
38
+ }
39
+ return rebuild(response, JSON.stringify({ ...envelope.root, json: { ...envelope.json, [legacyErrorFlag]: false } }));
40
+ }
41
+ /**
42
+ * Client side: removes a boolean `inferable` from an oRPC error body so a current oRPC
43
+ * client accepts errors from a pre-beta.34 server. Only that key is removed; oRPC still
44
+ * validates the rest of the body as usual.
45
+ */
46
+ export async function stripLegacyOrpcErrorFlag(response) {
47
+ if (!isJsonErrorResponse(response)) {
48
+ return response;
49
+ }
50
+ const text = await response.text();
51
+ const envelope = parseErrorEnvelope(text);
52
+ if (envelope === undefined || typeof envelope.json[legacyErrorFlag] !== 'boolean') {
53
+ return rebuild(response, text);
54
+ }
55
+ const { [legacyErrorFlag]: _legacy, ...json } = envelope.json;
56
+ return rebuild(response, JSON.stringify({ ...envelope.root, json }));
57
+ }
58
+ /**
59
+ * Wraps an oRPC link `fetch` so every response passes through
60
+ * {@link stripLegacyOrpcErrorFlag}. Any oRPC 2.0.0-beta.34+ client that talks to a
61
+ * game-services Worker must use it while the Worker writes the legacy flag. Extra arguments
62
+ * oRPC passes (client options, procedure path) are forwarded unchanged.
63
+ */
64
+ export function withLegacyOrpcErrorCompat(fetcher) {
65
+ return async (url, init, ...rest) => stripLegacyOrpcErrorFlag(await fetcher(url, init, ...rest));
66
+ }
67
+ function isJsonErrorResponse(response) {
68
+ return (response.status >= 400
69
+ && response.body !== null
70
+ && (response.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'));
71
+ }
72
+ function parseErrorEnvelope(text) {
73
+ let root;
74
+ try {
75
+ root = JSON.parse(text);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ if (!isPlainObject(root) || !isPlainObject(root.json)) {
81
+ return undefined;
82
+ }
83
+ return { root, json: root.json };
84
+ }
85
+ function isPlainObject(value) {
86
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
87
+ return false;
88
+ }
89
+ const prototype = Object.getPrototypeOf(value);
90
+ return prototype === Object.prototype || prototype === null;
91
+ }
92
+ function rebuild(response, body) {
93
+ const headers = new Headers(response.headers);
94
+ // The body is re-encoded as plain text: any transfer encoding or length no longer applies.
95
+ headers.delete('content-encoding');
96
+ headers.delete('content-length');
97
+ return new Response(body, {
98
+ status: response.status,
99
+ statusText: response.statusText,
100
+ headers,
101
+ });
102
+ }
@@ -4,6 +4,28 @@
4
4
  * client and the notifications client so a game gives both one resolver.
5
5
  */
6
6
  export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
7
+ /** A runtime refused an operation because it was disposed (see `GameServicesRuntime.dispose`). */
8
+ export declare const gameServicesRuntimeDisposedCode = "GAME_SERVICES_RUNTIME_DISPOSED";
9
+ /**
10
+ * A runtime refused an operation because `sessionPlayerId` names another
11
+ * player: it closed itself, as `dispose()` does, and the host creates a
12
+ * runtime for the signed-in player (retryable there).
13
+ */
14
+ export declare const gameServicesPlayerChangedCode = "GAME_SERVICES_PLAYER_CHANGED";
15
+ /** A runtime refused an operation because `sessionPlayerId` names no player (signed out); it stays open. */
16
+ export declare const gameServicesSessionRequiredCode = "GAME_SERVICES_SESSION_REQUIRED";
17
+ export type GameServicesRuntimeClosedCode = typeof gameServicesRuntimeDisposedCode | typeof gameServicesPlayerChangedCode | typeof gameServicesSessionRequiredCode;
18
+ /**
19
+ * An operation the runtime refused before it reached the backend or the
20
+ * store: it was disposed, its player is no longer the session's, or the
21
+ * session is signed out. Always retryable (with a runtime for the signed-in
22
+ * player); nothing was sent and no record was ended.
23
+ */
24
+ export declare class GameServicesRuntimeClosedError extends Error {
25
+ readonly code: GameServicesRuntimeClosedCode;
26
+ readonly retryable = true;
27
+ constructor(code: GameServicesRuntimeClosedCode);
28
+ }
7
29
  /** Credentials could not be resolved; no backend request was dispatched. */
8
30
  export declare class GameServicesHeaderResolutionError extends Error {
9
31
  constructor();
@@ -3,6 +3,35 @@
3
3
  * for rotating credentials, then per-request headers. Shared by the commerce
4
4
  * client and the notifications client so a game gives both one resolver.
5
5
  */
6
+ /** A runtime refused an operation because it was disposed (see `GameServicesRuntime.dispose`). */
7
+ export const gameServicesRuntimeDisposedCode = 'GAME_SERVICES_RUNTIME_DISPOSED';
8
+ /**
9
+ * A runtime refused an operation because `sessionPlayerId` names another
10
+ * player: it closed itself, as `dispose()` does, and the host creates a
11
+ * runtime for the signed-in player (retryable there).
12
+ */
13
+ export const gameServicesPlayerChangedCode = 'GAME_SERVICES_PLAYER_CHANGED';
14
+ /** A runtime refused an operation because `sessionPlayerId` names no player (signed out); it stays open. */
15
+ export const gameServicesSessionRequiredCode = 'GAME_SERVICES_SESSION_REQUIRED';
16
+ /**
17
+ * An operation the runtime refused before it reached the backend or the
18
+ * store: it was disposed, its player is no longer the session's, or the
19
+ * session is signed out. Always retryable (with a runtime for the signed-in
20
+ * player); nothing was sent and no record was ended.
21
+ */
22
+ export class GameServicesRuntimeClosedError extends Error {
23
+ code;
24
+ retryable = true;
25
+ constructor(code) {
26
+ super(code === gameServicesPlayerChangedCode
27
+ ? 'The session now names another player; this game services runtime is closed.'
28
+ : code === gameServicesSessionRequiredCode
29
+ ? 'The session names no player; sign in before using game services.'
30
+ : 'This game services runtime was disposed.');
31
+ this.name = 'GameServicesRuntimeClosedError';
32
+ this.code = code;
33
+ }
34
+ }
6
35
  /** Credentials could not be resolved; no backend request was dispatched. */
7
36
  export class GameServicesHeaderResolutionError extends Error {
8
37
  constructor() {
@@ -16,7 +45,11 @@ export async function resolveGameServicesRequestHeaders(input) {
16
45
  try {
17
46
  dynamicHeaders = await input.getHeaders?.();
18
47
  }
19
- catch {
48
+ catch (error) {
49
+ if (error instanceof GameServicesRuntimeClosedError) {
50
+ // The runtime's own refusal carries no credentials; its code says why.
51
+ throw error;
52
+ }
20
53
  // Resolver exceptions can contain credentials; expose a stable outcome.
21
54
  throw new GameServicesHeaderResolutionError();
22
55
  }
package/dist/runtime.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { type AccountDeletionClient } from './account-deletion.js';
2
2
  import type { AnalyticsSink } from '@mpgd/analytics';
3
3
  import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
4
+ import { isHostedGameServicesPath } from '@mpgd/platform/routes';
4
5
  import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
5
6
  import type { GameServicesLedgerTarget } from './types.js';
6
7
  import type { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
7
- import type { CommerceReconcilerProduct } from './commerce-reconciler.js';
8
+ import type { CommerceReconcilerProduct, CreateCommerceReconcilerInput } from './commerce-reconciler.js';
8
9
  import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
9
10
  export type GameServicesAuthorityMode = 'production' | 'non-production';
10
11
  export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
@@ -21,7 +22,30 @@ export interface GameServicesRuntime {
21
22
  * with the runtime's headers; present with an authoritative `baseUrl`.
22
23
  */
23
24
  readonly accountDeletion?: AccountDeletionClient;
25
+ /**
26
+ * Closes the runtime for good, then waits until a purchase whose store
27
+ * sheet is open has journaled its answer, a startup or resume recovery
28
+ * pass (`monetizationRecovery.reconcile()`) has finished, and the commerce
29
+ * reconciler has stopped (each wait bounded by `gameServicesDisposeWaitMs`); repeated
30
+ * calls share that wait. Afterwards nothing it holds talks to the backend
31
+ * or the store for its player: a purchase answers `failed` with the
32
+ * retryable diagnostic `gameServicesRuntimeDisposedCode` before any store
33
+ * UI, a rewarded-ad claim answers `unavailable` before any ad, recovery
34
+ * runs nothing, Restore Purchases and leaderboard submissions reject with
35
+ * `GameServicesRuntimeClosedError`, and every backend request (account
36
+ * deletion included) is refused before its headers are resolved. The
37
+ * journal keeps every record; a runtime created later for the same player
38
+ * resumes them. A disabled runtime has nothing to close.
39
+ */
40
+ dispose(): Promise<void>;
24
41
  }
42
+ /**
43
+ * How long `dispose()` waits for a purchase whose store sheet is open to
44
+ * journal its answer, and then for the reconciler to stop: 3 minutes, past
45
+ * Google Play's own wait for a purchase result.
46
+ */
47
+ export declare const gameServicesDisposeWaitMs: number;
48
+ export { gameServicesPlayerChangedCode, gameServicesRuntimeDisposedCode, GameServicesRuntimeClosedError, gameServicesSessionRequiredCode, type GameServicesRuntimeClosedCode, } from './request-headers.js';
25
49
  interface CreateGameServicesRuntimeCommonInput {
26
50
  readonly gateway: PlatformGateway;
27
51
  readonly playerId: string;
@@ -47,6 +71,52 @@ interface CreateGameServicesRuntimeCommonInput {
47
71
  * product catalog's `platformProductIds` for the gateway target.
48
72
  */
49
73
  readonly commerceProducts?: readonly CommerceReconcilerProduct[];
74
+ /**
75
+ * The player the session's credentials belong to right now, read
76
+ * synchronously: pass it whenever `getHeaders` (or the store account
77
+ * binding) follows a session that can change player within the run. It is
78
+ * checked before every backend request and again once its headers are
79
+ * resolved (no request leaves with another player's credentials), and by
80
+ * the commerce reconciler before it journals, verifies, restores, or
81
+ * finishes anything and right before a store UI opens. The store
82
+ * provider resolves its account binding after that last check, so the
83
+ * binding resolver must follow this player too (bind nothing for anyone
84
+ * else) for a purchase never to be journaled here and bound to another
85
+ * account; a binding refused while the session changed stays retryable.
86
+ * Another
87
+ * player closes the runtime for good, as `dispose()` does, with
88
+ * `gameServicesPlayerChangedCode`; no player refuses with
89
+ * `gameServicesSessionRequiredCode` and leaves the runtime open. Refusals
90
+ * are retryable and end no record, so a purchase of this player is never
91
+ * verified with another player's session (which a backend answers with a
92
+ * permanent account-mismatch rejection) and is resumed by a runtime for
93
+ * this player later. A custom `httpTransport` must send only the headers
94
+ * the runtime resolved for each request.
95
+ */
96
+ readonly sessionPlayerId?: () => string | undefined;
97
+ /**
98
+ * This player's store account binding, derived from `playerId` the way
99
+ * the store provider binds the player's new purchases (Google Play's
100
+ * obfuscated account id, StoreKit's app account token). With it the
101
+ * commerce reconciler leaves a store purchase bound to another account
102
+ * alone instead of journaling and verifying it for this player (a backend
103
+ * rejects that for good as an account mismatch): after a restart as
104
+ * another player, Google Play still lists the previous player's
105
+ * unacknowledged purchases. The player's Restore Purchases still takes a
106
+ * durable bound to another account (restore sharing). Derive it from
107
+ * `playerId`, not from the live session. Pass it only for a store whose
108
+ * purchases carry this kind of binding: answering `undefined` leaves every
109
+ * bound purchase alone, which is meant only while the binding is briefly
110
+ * unreadable. Omitted, nothing is compared (for example Microsoft Store,
111
+ * whose purchases carry their own claimed scope).
112
+ */
113
+ readonly commerceAccountBinding?: CreateCommerceReconcilerInput['accountBinding'];
114
+ /**
115
+ * The reconciler's `staleLaunchedAfterMs`: how long a `launched` purchase
116
+ * with no store purchase keeps serializing its product before it ends
117
+ * `failed`. Defaults to 24 hours; `Infinity` never expires it.
118
+ */
119
+ readonly commerceStaleLaunchedAfterMs?: number;
50
120
  }
51
121
  export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
52
122
  readonly transport?: 'http';
@@ -60,6 +130,17 @@ export declare function createGameServicesRuntime(input: CreateGameServicesRunti
60
130
  export declare function resolveGameServicesLedgerTarget(target: PlatformTarget | string): GameServicesLedgerTarget | null;
61
131
  export declare function resolveGameServicesAuthorityMode(profile: string): GameServicesAuthorityMode;
62
132
  export declare function resolveGameServicesTransport(transport: string | undefined): 'http' | 'orpc';
133
+ export { isHostedGameServicesPath };
134
+ /**
135
+ * Resolve a hosted browser's clean /api ingress before constructing absolute-URL
136
+ * clients. No ambient DOM access: composition roots supply the actual page origin
137
+ * and gateway target. Unresolved/invalid inputs remain invalid for the runtime's
138
+ * fail-closed authority check; this helper never supplies a fallback backend.
139
+ */
140
+ export declare function resolveGameServicesBaseUrl(baseUrl: string | undefined, context: {
141
+ readonly target: PlatformTarget | string;
142
+ readonly pageOrigin?: string;
143
+ }): string | undefined;
63
144
  /**
64
145
  * Whether a game-services URL may carry a session's credentials in a
65
146
  * production build: an absolute https URL with no credentials and a public
@@ -67,4 +148,3 @@ export declare function resolveGameServicesTransport(transport: string | undefin
67
148
  * notifications client's starter wiring uses it too.
68
149
  */
69
150
  export declare function isPublicGameServicesUrl(baseUrl: string): boolean;
70
- export {};