@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 +101 -0
- package/dist/another-account.d.ts +65 -0
- package/dist/another-account.js +107 -0
- package/dist/app-store-verifier.js +24 -5
- package/dist/client.d.ts +16 -2
- package/dist/client.js +33 -5
- package/dist/commerce-reconciler.d.ts +89 -4
- package/dist/commerce-reconciler.js +467 -35
- package/dist/contract.d.ts +6 -6
- package/dist/evidence-verification.d.ts +14 -2
- package/dist/google-play-purchase.js +22 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/monetization-recovery.js +1 -1
- package/dist/orpc-error-compat.d.ts +42 -0
- package/dist/orpc-error-compat.js +102 -0
- package/dist/request-headers.d.ts +22 -0
- package/dist/request-headers.js +34 -1
- package/dist/runtime.d.ts +82 -2
- package/dist/runtime.js +363 -10
- package/dist/server.d.ts +31 -7
- package/dist/server.js +125 -15
- package/package.json +9 -9
package/dist/contract.d.ts
CHANGED
|
@@ -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>,
|
|
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>,
|
|
13
|
-
getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>,
|
|
14
|
-
getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>,
|
|
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>,
|
|
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>,
|
|
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 `
|
|
38
|
-
* policy refuses
|
|
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
|
-
|
|
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
|
|
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('
|
|
380
|
+
return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED');
|
|
365
381
|
}
|
|
366
382
|
return {
|
|
367
383
|
status: 'verified',
|
|
368
|
-
|
|
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
package/dist/index.js
CHANGED
|
@@ -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();
|
package/dist/request-headers.js
CHANGED
|
@@ -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 {};
|