@mpgd/game-services 0.31.0 → 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 +8 -3
- 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.js +5 -2
- package/dist/commerce-reconciler.d.ts +36 -1
- package/dist/commerce-reconciler.js +227 -21
- 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/runtime.d.ts +12 -0
- package/dist/runtime.js +23 -0
- package/dist/server.d.ts +16 -1
- package/dist/server.js +99 -12
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -314,9 +314,14 @@ purchases (Google Play's obfuscated account id, StoreKit's app account
|
|
|
314
314
|
token). The reconciler then leaves a store purchase bound to another account
|
|
315
315
|
alone (reported, never journaled or verified for this player). After a
|
|
316
316
|
restart as another player, Google Play still lists the previous player's
|
|
317
|
-
unacknowledged purchases; sending one with this player's session
|
|
318
|
-
|
|
319
|
-
|
|
317
|
+
unacknowledged purchases; sending one with this player's session only gets
|
|
318
|
+
the backend's another-account answer (409 `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`),
|
|
319
|
+
which parks the record without a grant. The reconciler parks it whenever the
|
|
320
|
+
backend is reached anyway (no binding input, an older client's record): the
|
|
321
|
+
record stays `verifying`, is never resent automatically, and leaves its
|
|
322
|
+
derived key to the purchase's own account on the install
|
|
323
|
+
([another account's purchase](../../docs/GAME_SERVICES_BACKEND.md#another-accounts-purchase)).
|
|
324
|
+
The player's explicit Restore Purchases still takes a durable
|
|
320
325
|
bound to another account (restore sharing). StoreKit's provider already
|
|
321
326
|
filters transactions by app account token the same way.
|
|
322
327
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Another account's purchase (RFC "Another account's purchase"): a verify
|
|
3
|
+
* whose store binding (Google Play `obfuscatedAccountId` /
|
|
4
|
+
* `obfuscatedProfileId`, StoreKit `appAccountToken`) names another account
|
|
5
|
+
* than the requesting player's. It is not granted, nothing is recorded, and
|
|
6
|
+
* the store transaction stays recoverable by the account it is bound to, so
|
|
7
|
+
* the backend answers it as a retryable error instead of a `rejected`
|
|
8
|
+
* receipt: HTTP 409 `{ error: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT', reason }`,
|
|
9
|
+
* or the oRPC error code `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` (status 409,
|
|
10
|
+
* `data.reason`). A client from before this answer treats it as a transport
|
|
11
|
+
* failure and keeps the record open; the commerce reconciler parks it.
|
|
12
|
+
*
|
|
13
|
+
* No schema, DOM, or transport dependencies: the server, the client, and a
|
|
14
|
+
* game server wrapping the reference verifiers share these helpers.
|
|
15
|
+
*/
|
|
16
|
+
/** The error code of the answer, on HTTP (`error`) and oRPC (`code`) alike. */
|
|
17
|
+
export declare const purchaseBoundToAnotherAccountCode: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT';
|
|
18
|
+
/** The HTTP status of the answer: the purchase conflicts with the requesting player's account. */
|
|
19
|
+
export declare const purchaseBoundToAnotherAccountStatus = 409;
|
|
20
|
+
/**
|
|
21
|
+
* The binding mismatch reasons of the reference verifiers. Each one means
|
|
22
|
+
* the purchase carries another account's binding: a purchase without one
|
|
23
|
+
* answers `GOOGLE_PLAY_ACCOUNT_ID_MISSING`, `GOOGLE_PLAY_PROFILE_ID_MISSING`,
|
|
24
|
+
* `GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED`, or `APP_STORE_ACCOUNT_TOKEN_MISSING`
|
|
25
|
+
* instead, which stay `rejected`. A backend from before this answer, or a game
|
|
26
|
+
* server that maps every refusal to `rejected`, still answers these reasons
|
|
27
|
+
* as a `rejected` receipt; the commerce reconciler reads that receipt as this
|
|
28
|
+
* answer too.
|
|
29
|
+
*/
|
|
30
|
+
export declare const storeAccountMismatchReasons: readonly ["GOOGLE_PLAY_ACCOUNT_MISMATCH", "GOOGLE_PLAY_PROFILE_MISMATCH", "APP_STORE_ACCOUNT_TOKEN_MISMATCH"];
|
|
31
|
+
export type StoreAccountMismatchReason = (typeof storeAccountMismatchReasons)[number];
|
|
32
|
+
export declare function isStoreAccountMismatchReason(reason: unknown): reason is StoreAccountMismatchReason;
|
|
33
|
+
/**
|
|
34
|
+
* Thrown by the reference backend's `verifyPurchase` for another account's
|
|
35
|
+
* purchase; the HTTP handler and the oRPC router turn it into the 409
|
|
36
|
+
* answer. A game server that calls `backend.purchases.verifyPurchase` itself
|
|
37
|
+
* answers it the same way (see `docs/GAME_SERVICES_BACKEND.md`). Its
|
|
38
|
+
* `message` starts with the code, so a Workers RPC caller whose runtime
|
|
39
|
+
* keeps only the message still recognizes it.
|
|
40
|
+
*/
|
|
41
|
+
export declare class GameServicesPurchaseBoundToAnotherAccountError extends Error {
|
|
42
|
+
readonly code: "PURCHASE_BOUND_TO_ANOTHER_ACCOUNT";
|
|
43
|
+
readonly status = 409;
|
|
44
|
+
/** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
constructor(reason: string);
|
|
47
|
+
}
|
|
48
|
+
/** The body of the HTTP 409 answer. */
|
|
49
|
+
export interface PurchaseBoundToAnotherAccountBody {
|
|
50
|
+
readonly error: typeof purchaseBoundToAnotherAccountCode;
|
|
51
|
+
readonly reason: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function createPurchaseBoundToAnotherAccountBody(reason: string): PurchaseBoundToAnotherAccountBody;
|
|
54
|
+
/**
|
|
55
|
+
* The binding mismatch of an error that carries the answer, or undefined for
|
|
56
|
+
* any other error. It reads every shape a client meets: the thrown
|
|
57
|
+
* `GameServicesPurchaseBoundToAnotherAccountError` (an in-process backend),
|
|
58
|
+
* an oRPC error (`code`, `data.reason`), a `GameServicesBackendError` of the
|
|
59
|
+
* HTTP transport (`status` 409 and `body.error`), and an error rebuilt from
|
|
60
|
+
* its message alone (a Workers RPC caller without enhanced error
|
|
61
|
+
* serialization).
|
|
62
|
+
*/
|
|
63
|
+
export declare function readPurchaseBoundToAnotherAccount(error: unknown): {
|
|
64
|
+
readonly reason: string;
|
|
65
|
+
} | undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Another account's purchase (RFC "Another account's purchase"): a verify
|
|
3
|
+
* whose store binding (Google Play `obfuscatedAccountId` /
|
|
4
|
+
* `obfuscatedProfileId`, StoreKit `appAccountToken`) names another account
|
|
5
|
+
* than the requesting player's. It is not granted, nothing is recorded, and
|
|
6
|
+
* the store transaction stays recoverable by the account it is bound to, so
|
|
7
|
+
* the backend answers it as a retryable error instead of a `rejected`
|
|
8
|
+
* receipt: HTTP 409 `{ error: 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT', reason }`,
|
|
9
|
+
* or the oRPC error code `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` (status 409,
|
|
10
|
+
* `data.reason`). A client from before this answer treats it as a transport
|
|
11
|
+
* failure and keeps the record open; the commerce reconciler parks it.
|
|
12
|
+
*
|
|
13
|
+
* No schema, DOM, or transport dependencies: the server, the client, and a
|
|
14
|
+
* game server wrapping the reference verifiers share these helpers.
|
|
15
|
+
*/
|
|
16
|
+
/** The error code of the answer, on HTTP (`error`) and oRPC (`code`) alike. */
|
|
17
|
+
export const purchaseBoundToAnotherAccountCode = 'PURCHASE_BOUND_TO_ANOTHER_ACCOUNT';
|
|
18
|
+
/** The HTTP status of the answer: the purchase conflicts with the requesting player's account. */
|
|
19
|
+
export const purchaseBoundToAnotherAccountStatus = 409;
|
|
20
|
+
/**
|
|
21
|
+
* The binding mismatch reasons of the reference verifiers. Each one means
|
|
22
|
+
* the purchase carries another account's binding: a purchase without one
|
|
23
|
+
* answers `GOOGLE_PLAY_ACCOUNT_ID_MISSING`, `GOOGLE_PLAY_PROFILE_ID_MISSING`,
|
|
24
|
+
* `GOOGLE_PLAY_ACCOUNT_BINDING_UNEXPECTED`, or `APP_STORE_ACCOUNT_TOKEN_MISSING`
|
|
25
|
+
* instead, which stay `rejected`. A backend from before this answer, or a game
|
|
26
|
+
* server that maps every refusal to `rejected`, still answers these reasons
|
|
27
|
+
* as a `rejected` receipt; the commerce reconciler reads that receipt as this
|
|
28
|
+
* answer too.
|
|
29
|
+
*/
|
|
30
|
+
export const storeAccountMismatchReasons = Object.freeze([
|
|
31
|
+
'GOOGLE_PLAY_ACCOUNT_MISMATCH',
|
|
32
|
+
'GOOGLE_PLAY_PROFILE_MISMATCH',
|
|
33
|
+
'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
|
|
34
|
+
]);
|
|
35
|
+
export function isStoreAccountMismatchReason(reason) {
|
|
36
|
+
return typeof reason === 'string'
|
|
37
|
+
&& storeAccountMismatchReasons.includes(reason);
|
|
38
|
+
}
|
|
39
|
+
const reasonPattern = /^[A-Za-z0-9_.:-]{1,128}$/u;
|
|
40
|
+
function readReason(value) {
|
|
41
|
+
return typeof value === 'string' && reasonPattern.test(value) ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Thrown by the reference backend's `verifyPurchase` for another account's
|
|
45
|
+
* purchase; the HTTP handler and the oRPC router turn it into the 409
|
|
46
|
+
* answer. A game server that calls `backend.purchases.verifyPurchase` itself
|
|
47
|
+
* answers it the same way (see `docs/GAME_SERVICES_BACKEND.md`). Its
|
|
48
|
+
* `message` starts with the code, so a Workers RPC caller whose runtime
|
|
49
|
+
* keeps only the message still recognizes it.
|
|
50
|
+
*/
|
|
51
|
+
export class GameServicesPurchaseBoundToAnotherAccountError extends Error {
|
|
52
|
+
code = purchaseBoundToAnotherAccountCode;
|
|
53
|
+
status = purchaseBoundToAnotherAccountStatus;
|
|
54
|
+
/** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
|
|
55
|
+
reason;
|
|
56
|
+
constructor(reason) {
|
|
57
|
+
const checked = readReason(reason) ?? purchaseBoundToAnotherAccountCode;
|
|
58
|
+
super(`${purchaseBoundToAnotherAccountCode}: ${checked}`);
|
|
59
|
+
this.name = 'GameServicesPurchaseBoundToAnotherAccountError';
|
|
60
|
+
this.reason = checked;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createPurchaseBoundToAnotherAccountBody(reason) {
|
|
64
|
+
return {
|
|
65
|
+
error: purchaseBoundToAnotherAccountCode,
|
|
66
|
+
reason: readReason(reason) ?? purchaseBoundToAnotherAccountCode,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The binding mismatch of an error that carries the answer, or undefined for
|
|
71
|
+
* any other error. It reads every shape a client meets: the thrown
|
|
72
|
+
* `GameServicesPurchaseBoundToAnotherAccountError` (an in-process backend),
|
|
73
|
+
* an oRPC error (`code`, `data.reason`), a `GameServicesBackendError` of the
|
|
74
|
+
* HTTP transport (`status` 409 and `body.error`), and an error rebuilt from
|
|
75
|
+
* its message alone (a Workers RPC caller without enhanced error
|
|
76
|
+
* serialization).
|
|
77
|
+
*/
|
|
78
|
+
export function readPurchaseBoundToAnotherAccount(error) {
|
|
79
|
+
if (typeof error !== 'object' || error === null) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const candidate = error;
|
|
83
|
+
const fallback = { reason: purchaseBoundToAnotherAccountCode };
|
|
84
|
+
if (candidate.code === purchaseBoundToAnotherAccountCode) {
|
|
85
|
+
const data = candidate.data;
|
|
86
|
+
const dataReason = typeof data === 'object' && data !== null
|
|
87
|
+
? readReason(data.reason)
|
|
88
|
+
: undefined;
|
|
89
|
+
const reason = readReason(candidate.reason) ?? dataReason;
|
|
90
|
+
return reason === undefined ? fallback : { reason };
|
|
91
|
+
}
|
|
92
|
+
const body = candidate.body;
|
|
93
|
+
if (candidate.status === purchaseBoundToAnotherAccountStatus
|
|
94
|
+
&& typeof body === 'object'
|
|
95
|
+
&& body !== null
|
|
96
|
+
&& body.error === purchaseBoundToAnotherAccountCode) {
|
|
97
|
+
const reason = readReason(body.reason);
|
|
98
|
+
return reason === undefined ? fallback : { reason };
|
|
99
|
+
}
|
|
100
|
+
const message = candidate.message;
|
|
101
|
+
const prefix = `${purchaseBoundToAnotherAccountCode}: `;
|
|
102
|
+
if (typeof message === 'string' && message.startsWith(prefix)) {
|
|
103
|
+
const reason = readReason(message.slice(prefix.length));
|
|
104
|
+
return reason === undefined ? fallback : { reason };
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
@@ -250,19 +250,38 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
|
|
|
250
250
|
// when every other check passes: the backend's restore sharing policy
|
|
251
251
|
// decides (RFC "Restore sharing"). The client's StoreKit-verified JWS is
|
|
252
252
|
// required, so a transaction id alone never shares a purchase.
|
|
253
|
-
const
|
|
253
|
+
const tokenMismatch = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH';
|
|
254
|
+
if (tokenMismatch) {
|
|
255
|
+
// Every other check still decides: only a purchase that passes them all
|
|
256
|
+
// is reported as restorable or as another account's.
|
|
257
|
+
rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
|
|
258
|
+
}
|
|
259
|
+
const foreignBinding = tokenMismatch
|
|
254
260
|
&& input.request.intent === 'restore'
|
|
255
261
|
&& input.product.type === 'non_consumable'
|
|
256
262
|
&& offlineTransaction !== undefined;
|
|
257
|
-
if (foreignBinding) {
|
|
258
|
-
rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
|
|
259
|
-
}
|
|
260
263
|
if (rejection !== undefined) {
|
|
261
264
|
return { status: 'rejected', reason: rejection };
|
|
262
265
|
}
|
|
266
|
+
if (tokenMismatch && !foreignBinding) {
|
|
267
|
+
// A transaction without an appAccountToken (bought outside the app, or
|
|
268
|
+
// by a build that set none) is no account's: refused for every player.
|
|
269
|
+
// One with another token is that account's, which can still recover it
|
|
270
|
+
// (RFC "Another account's purchase").
|
|
271
|
+
return transaction.appAccountToken === undefined
|
|
272
|
+
? { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISSING' }
|
|
273
|
+
: { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH', anotherAccount: true };
|
|
274
|
+
}
|
|
263
275
|
return {
|
|
264
276
|
...(foreignBinding
|
|
265
|
-
? {
|
|
277
|
+
? {
|
|
278
|
+
status: 'restorable',
|
|
279
|
+
// Bound to no account, or to another one: the backend refuses the
|
|
280
|
+
// former for good when no share covers it.
|
|
281
|
+
reason: transaction.appAccountToken === undefined
|
|
282
|
+
? 'APP_STORE_ACCOUNT_TOKEN_MISSING'
|
|
283
|
+
: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
|
|
284
|
+
}
|
|
266
285
|
: { status: 'verified' }),
|
|
267
286
|
verificationId: createVerificationId(transaction),
|
|
268
287
|
verifiedAt: new Date(nowMs).toISOString(),
|
package/dist/client.js
CHANGED
|
@@ -191,7 +191,8 @@ export function createGameServicesClient(input) {
|
|
|
191
191
|
}
|
|
192
192
|
else if (!serverResulted) {
|
|
193
193
|
serverResulted = true;
|
|
194
|
-
|
|
194
|
+
// Another account's purchase is a server answer with no grant.
|
|
195
|
+
progress.serverResult(event.type === 'verify-result' && event.response.verified);
|
|
195
196
|
}
|
|
196
197
|
});
|
|
197
198
|
try {
|
|
@@ -235,7 +236,9 @@ export function createGameServicesClient(input) {
|
|
|
235
236
|
alreadyProcessed: result.verification?.alreadyProcessed,
|
|
236
237
|
reason: result.status === 'pending'
|
|
237
238
|
? 'purchase_pending'
|
|
238
|
-
: (result.verification?.reason
|
|
239
|
+
: (result.verification?.reason
|
|
240
|
+
?? record?.anotherAccount?.reason
|
|
241
|
+
?? record?.error?.code),
|
|
239
242
|
},
|
|
240
243
|
});
|
|
241
244
|
}
|
|
@@ -55,6 +55,23 @@ export interface CommercePurchaseOperationRecord {
|
|
|
55
55
|
/** A retryable error on `requested` means the provider proved checkout never started. */
|
|
56
56
|
readonly error?: CommerceErrorInfo;
|
|
57
57
|
readonly revokedBy?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The backend answered that the store binds this purchase to another
|
|
60
|
+
* account than this record's player (RFC "Another account's purchase"):
|
|
61
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`, or a `rejected` receipt with a
|
|
62
|
+
* store binding mismatch reason from a backend before that answer. The
|
|
63
|
+
* record is parked in `verifying`: never sent again automatically, not
|
|
64
|
+
* listed among the player's recoverable records, summarized as `rejected`
|
|
65
|
+
* for this player, and its shared key no longer refuses another player
|
|
66
|
+
* of the install, who journals the purchase under its own player-scoped
|
|
67
|
+
* key. Only this player's Restore Purchases, when the store reports the
|
|
68
|
+
* purchase again, sends it once more.
|
|
69
|
+
*/
|
|
70
|
+
readonly anotherAccount?: {
|
|
71
|
+
/** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
|
|
72
|
+
readonly reason: string;
|
|
73
|
+
readonly reportedAt: string;
|
|
74
|
+
};
|
|
58
75
|
}
|
|
59
76
|
/**
|
|
60
77
|
* The store key of a commerce v2 purchase record: one namespace per ledger
|
|
@@ -65,8 +82,14 @@ export interface CommercePurchaseOperationRecord {
|
|
|
65
82
|
*/
|
|
66
83
|
export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string, playerId?: string): string;
|
|
67
84
|
export declare function isCommercePurchaseOperationRecord(record: unknown): record is CommercePurchaseOperationRecord;
|
|
68
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Summary status of a record: the RFC mapping, with `revoked` kept distinct.
|
|
87
|
+
* A record parked as another account's purchase is `rejected` for its
|
|
88
|
+
* player: nothing was granted to it, and nothing will be.
|
|
89
|
+
*/
|
|
69
90
|
export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
|
|
91
|
+
/** A `verifying` record the backend answered as another account's purchase. */
|
|
92
|
+
export declare function isParkedForAnotherAccount(record: CommercePurchaseOperationRecord): boolean;
|
|
70
93
|
/**
|
|
71
94
|
* Only `granted` and `finishing` records that hold the backend's ledger entry
|
|
72
95
|
* id and a store transaction may be finished. The StoreKit SDK keeps no
|
|
@@ -104,6 +127,18 @@ export type CommerceReconcilerEvent = {
|
|
|
104
127
|
readonly type: 'verify-result';
|
|
105
128
|
readonly record: CommercePurchaseOperationRecord;
|
|
106
129
|
readonly response: VerifyPurchaseResponse;
|
|
130
|
+
} | {
|
|
131
|
+
/**
|
|
132
|
+
* The backend answered the record's verify as another account's
|
|
133
|
+
* purchase (RFC "Another account's purchase"): the record is parked,
|
|
134
|
+
* nothing was granted, and it is not a failure. `response` is the
|
|
135
|
+
* receipt of a backend before that answer (`rejected` with a store
|
|
136
|
+
* binding mismatch reason); absent for the 409 answer.
|
|
137
|
+
*/
|
|
138
|
+
readonly type: 'another-account';
|
|
139
|
+
readonly record: CommercePurchaseOperationRecord;
|
|
140
|
+
readonly reason: string;
|
|
141
|
+
readonly response?: VerifyPurchaseResponse;
|
|
107
142
|
} | {
|
|
108
143
|
readonly type: 'failure';
|
|
109
144
|
readonly stage: CommerceReconcilerFailureStage;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, summarizeCommercePurchaseState, } from '@mpgd/platform/commerce';
|
|
2
|
+
import { isStoreAccountMismatchReason, readPurchaseBoundToAnotherAccount, } from './another-account.js';
|
|
2
3
|
import { compareCommerceServiceCapabilities, } from './commerce-evidence-registry.js';
|
|
3
4
|
import { chunkLedgerEntryIds } from './commerce-ledger.js';
|
|
4
5
|
/**
|
|
@@ -37,13 +38,37 @@ export function isCommercePurchaseOperationRecord(record) {
|
|
|
37
38
|
&& record !== null
|
|
38
39
|
&& record.kind === commercePurchaseOperationKind;
|
|
39
40
|
}
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Summary status of a record: the RFC mapping, with `revoked` kept distinct.
|
|
43
|
+
* A record parked as another account's purchase is `rejected` for its
|
|
44
|
+
* player: nothing was granted to it, and nothing will be.
|
|
45
|
+
*/
|
|
41
46
|
export function summarizeCommercePurchaseOperation(record) {
|
|
42
47
|
if (isRetryableBeforeLaunch(record)) {
|
|
43
48
|
return 'failed';
|
|
44
49
|
}
|
|
50
|
+
if (isParkedForAnotherAccount(record)) {
|
|
51
|
+
return 'rejected';
|
|
52
|
+
}
|
|
45
53
|
return summarizeCommercePurchaseState(record.state);
|
|
46
54
|
}
|
|
55
|
+
/** A `verifying` record the backend answered as another account's purchase. */
|
|
56
|
+
export function isParkedForAnotherAccount(record) {
|
|
57
|
+
return record.state === 'verifying' && record.anotherAccount !== undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether another player's record under a shared key leaves the key to this
|
|
61
|
+
* player: parked as another account's purchase, or (a journal written before
|
|
62
|
+
* that answer) `rejected` with a store binding mismatch reason. Either way
|
|
63
|
+
* the purchase was never its player's, and nothing on the backend holds it.
|
|
64
|
+
*/
|
|
65
|
+
function releasesSharedKey(record) {
|
|
66
|
+
return isParkedForAnotherAccount(record)
|
|
67
|
+
|| (record.state === 'rejected'
|
|
68
|
+
&& record.response !== undefined
|
|
69
|
+
&& !record.response.verified
|
|
70
|
+
&& isStoreAccountMismatchReason(record.response.reason));
|
|
71
|
+
}
|
|
47
72
|
function isRetryableBeforeLaunch(record) {
|
|
48
73
|
return record.state === 'requested' && record.error?.retryable === true
|
|
49
74
|
&& record.purchase === undefined && record.request === undefined;
|
|
@@ -483,6 +508,27 @@ export function createCommerceReconciler(input) {
|
|
|
483
508
|
retryAt.delete(record.idempotencyKey);
|
|
484
509
|
return next;
|
|
485
510
|
};
|
|
511
|
+
/**
|
|
512
|
+
* Parks a `verifying` record the backend answered as another account's
|
|
513
|
+
* purchase, or (`marker` undefined) lets this player's Restore Purchases
|
|
514
|
+
* send it again. The state stays `verifying`: the record is not ended for
|
|
515
|
+
* the store transaction, which its account can still recover.
|
|
516
|
+
*/
|
|
517
|
+
const reviseAnotherAccount = async (record, marker, patch = {}) => {
|
|
518
|
+
const { anotherAccount: _previous, ...rest } = record;
|
|
519
|
+
const next = {
|
|
520
|
+
...rest,
|
|
521
|
+
...patch,
|
|
522
|
+
...(marker === undefined ? {} : { anotherAccount: marker }),
|
|
523
|
+
state: record.state,
|
|
524
|
+
revision: record.revision + 1,
|
|
525
|
+
updatedAt: now(),
|
|
526
|
+
};
|
|
527
|
+
await journal.replace(record.revision, next);
|
|
528
|
+
remember(next);
|
|
529
|
+
retryAt.delete(record.idempotencyKey);
|
|
530
|
+
return next;
|
|
531
|
+
};
|
|
486
532
|
/**
|
|
487
533
|
* Where this player's record of an idempotency key lives, and what that key
|
|
488
534
|
* holds now. A record is under the shared key, or under this player's
|
|
@@ -496,6 +542,11 @@ export function createCommerceReconciler(input) {
|
|
|
496
542
|
* the slot holds, and `recordIn` refuses it.
|
|
497
543
|
* - `probeFree`: when the shared key is free, a record under the scoped key
|
|
498
544
|
* still answers (retention may have dropped the other player's record).
|
|
545
|
+
*
|
|
546
|
+
* Another player's record that was answered as another account's purchase
|
|
547
|
+
* (`releasesSharedKey`) is never this player's to refuse: the scoped key is
|
|
548
|
+
* the slot for any kind, as with `takeHeld`, so the purchase's own account
|
|
549
|
+
* recovers it on the same install.
|
|
499
550
|
*/
|
|
500
551
|
const resolveSlot = async (idempotencyKey, options = {}) => {
|
|
501
552
|
const sharedKey = createCommercePurchaseOperationKey(target, idempotencyKey);
|
|
@@ -503,6 +554,7 @@ export function createCommerceReconciler(input) {
|
|
|
503
554
|
const heldByAnother = shared !== undefined && heldByAnotherPlayer(shared);
|
|
504
555
|
const probeScoped = heldByAnother
|
|
505
556
|
? options.takeHeld === true
|
|
557
|
+
|| (isCommercePurchaseOperationRecord(shared) && releasesSharedKey(shared))
|
|
506
558
|
: shared === undefined && options.probeFree === true;
|
|
507
559
|
if (!probeScoped) {
|
|
508
560
|
return { key: sharedKey, found: shared, heldByAnother };
|
|
@@ -532,9 +584,15 @@ export function createCommerceReconciler(input) {
|
|
|
532
584
|
*/
|
|
533
585
|
const readRecord = async (idempotencyKey) => recordIn(await resolveSlot(idempotencyKey), idempotencyKey);
|
|
534
586
|
const ownedRecordsOf = (listed) => listed.filter((record) => isCommercePurchaseOperationRecord(record) && ownsRecord(record));
|
|
587
|
+
/**
|
|
588
|
+
* The player's open records. One parked as another account's purchase is
|
|
589
|
+
* kept in memory, so a store report matches it without the journal's
|
|
590
|
+
* transaction index, but is not the player's to recover.
|
|
591
|
+
*/
|
|
535
592
|
const listRecoverable = async () => ownedRecordsOf(await journal.listRecoverable(playerId))
|
|
536
593
|
.filter((record) => !isTerminalCommercePurchaseState(record.state))
|
|
537
|
-
.map(remember)
|
|
594
|
+
.map(remember)
|
|
595
|
+
.filter((record) => !isParkedForAnotherAccount(record));
|
|
538
596
|
const listGranted = async () => {
|
|
539
597
|
const listed = journal.listGranted === undefined
|
|
540
598
|
? []
|
|
@@ -717,6 +775,76 @@ export function createCommerceReconciler(input) {
|
|
|
717
775
|
fail('finish', error, record.idempotencyKey);
|
|
718
776
|
}
|
|
719
777
|
};
|
|
778
|
+
/**
|
|
779
|
+
* A store report of a record the backend answered as another account's
|
|
780
|
+
* purchase. Only this player's Restore Purchases asks again (a restore
|
|
781
|
+
* sharing policy or a binding may have changed since), with the evidence
|
|
782
|
+
* the store reports now, so a stale saved request (an expired signed
|
|
783
|
+
* transaction) is not resent, and as a restore, which the player asked
|
|
784
|
+
* for (restore sharing may cover a durable the game started, too); other
|
|
785
|
+
* reports leave it parked. It stays parked until the backend answers
|
|
786
|
+
* something else: a transport failure (an offline Restore) changes
|
|
787
|
+
* nothing, so the record never becomes an open attempt that serializes the
|
|
788
|
+
* player's purchases or holds the shared key against the purchase's
|
|
789
|
+
* account. Another answer unparks it and settles it as a verify does; a
|
|
790
|
+
* grant is finished by the pass's recoverable records.
|
|
791
|
+
*
|
|
792
|
+
* Returns false when the journal dropped the record before Restore could
|
|
793
|
+
* rewrite it (another context's save applied the journal's retention after
|
|
794
|
+
* `rereadParked` read it): the record is forgotten and the caller matches
|
|
795
|
+
* the report as a purchase the journal does not know.
|
|
796
|
+
*/
|
|
797
|
+
const reportParked = async (record, purchase, restoring) => {
|
|
798
|
+
if (!restoring || purchase.state !== 'purchased') {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
const request = {
|
|
802
|
+
...buildRequest({ ...record, purchase }),
|
|
803
|
+
intent: 'restore',
|
|
804
|
+
};
|
|
805
|
+
let refreshed;
|
|
806
|
+
try {
|
|
807
|
+
refreshed = await reviseAnotherAccount(record, record.anotherAccount, {
|
|
808
|
+
purchase,
|
|
809
|
+
request,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
if (await rereadParked(record) === undefined) {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
const answer = await sendVerify(refreshed, request);
|
|
819
|
+
if (answer === undefined) {
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
if (answer.kind === 'another-account') {
|
|
823
|
+
await parkForAnotherAccount(refreshed, request, answer);
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
await settleVerify(await reviseAnotherAccount(refreshed, undefined), request, answer.response);
|
|
827
|
+
return true;
|
|
828
|
+
};
|
|
829
|
+
/**
|
|
830
|
+
* The journal's current record of a parked record this session holds, or
|
|
831
|
+
* undefined (and forgotten) when the journal no longer holds it for this
|
|
832
|
+
* player: dropped, or the key reserved again for another purchase of the
|
|
833
|
+
* player, which this purchase's report must never be written onto.
|
|
834
|
+
*/
|
|
835
|
+
const rereadParked = async (record) => {
|
|
836
|
+
const current = await journal.read(record.key);
|
|
837
|
+
if (current !== undefined
|
|
838
|
+
&& isCommercePurchaseOperationRecord(current)
|
|
839
|
+
&& ownsRecord(current)
|
|
840
|
+
&& current.purchase !== undefined
|
|
841
|
+
&& record.purchase !== undefined
|
|
842
|
+
&& samePurchase(current.purchase, record.purchase)) {
|
|
843
|
+
return remember(current);
|
|
844
|
+
}
|
|
845
|
+
records.delete(record.idempotencyKey);
|
|
846
|
+
return undefined;
|
|
847
|
+
};
|
|
720
848
|
// Reconciler step 2: match a reported purchase to a journal record.
|
|
721
849
|
const ingestPurchase = async (purchase, restoring) => {
|
|
722
850
|
if (purchase.store !== storeId) {
|
|
@@ -746,6 +874,19 @@ export function createCommerceReconciler(input) {
|
|
|
746
874
|
record = remember(stored);
|
|
747
875
|
}
|
|
748
876
|
}
|
|
877
|
+
if (record !== undefined && isParkedForAnotherAccount(record) && restoring) {
|
|
878
|
+
// Restore Purchases writes the record, so it must still be the journal's:
|
|
879
|
+
// a journal's retention may have dropped a parked record this session
|
|
880
|
+
// still holds (RFC "Another account's purchase"). Then the report is a
|
|
881
|
+
// purchase the journal does not know, and is matched as one.
|
|
882
|
+
record = await rereadParked(record);
|
|
883
|
+
}
|
|
884
|
+
if (record !== undefined && isParkedForAnotherAccount(record)) {
|
|
885
|
+
if (await reportParked(record, purchase, restoring)) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
record = undefined;
|
|
889
|
+
}
|
|
749
890
|
const account = record !== undefined || (restoring && product.kind === 'durable')
|
|
750
891
|
? 'own'
|
|
751
892
|
: await accountOf(purchase);
|
|
@@ -800,6 +941,16 @@ export function createCommerceReconciler(input) {
|
|
|
800
941
|
probeFree: durable,
|
|
801
942
|
});
|
|
802
943
|
record = recordIn(slot, idempotencyKey);
|
|
944
|
+
if (record !== undefined
|
|
945
|
+
&& isParkedForAnotherAccount(record)
|
|
946
|
+
&& record.logicalId === product.logicalId
|
|
947
|
+
&& record.purchase !== undefined
|
|
948
|
+
&& samePurchase(record.purchase, purchase)) {
|
|
949
|
+
// Found under its derived key only (a journal without the
|
|
950
|
+
// transaction index): the same rule as a transaction match.
|
|
951
|
+
await reportParked(record, purchase, restoring);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
803
954
|
if (record !== undefined
|
|
804
955
|
&& (record.logicalId !== product.logicalId
|
|
805
956
|
|| (record.purchase !== undefined && !samePurchase(record.purchase, purchase)))) {
|
|
@@ -863,18 +1014,71 @@ export function createCommerceReconciler(input) {
|
|
|
863
1014
|
retryable: false,
|
|
864
1015
|
message,
|
|
865
1016
|
});
|
|
1017
|
+
/**
|
|
1018
|
+
* The backend's answer: a response, another account's purchase (the
|
|
1019
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error, or a `rejected` receipt with a
|
|
1020
|
+
* store binding mismatch reason from a backend before it), or undefined
|
|
1021
|
+
* for a transport failure.
|
|
1022
|
+
*/
|
|
866
1023
|
const sendVerify = async (record, request) => {
|
|
867
1024
|
emit({ type: 'verify-requested', record });
|
|
1025
|
+
let response;
|
|
868
1026
|
try {
|
|
869
|
-
|
|
870
|
-
emit({ type: 'verify-result', record, response });
|
|
871
|
-
return response;
|
|
1027
|
+
response = await backend.verifyPurchase(request);
|
|
872
1028
|
}
|
|
873
1029
|
catch (error) {
|
|
1030
|
+
const anotherAccount = readPurchaseBoundToAnotherAccount(error);
|
|
1031
|
+
if (anotherAccount !== undefined) {
|
|
1032
|
+
// An answer, not a failure: the caller parks the record and reports it.
|
|
1033
|
+
return { kind: 'another-account', reason: anotherAccount.reason };
|
|
1034
|
+
}
|
|
874
1035
|
// Transport failure: the state is kept and the next pass resends the saved request.
|
|
875
1036
|
fail('verify', error, record.idempotencyKey);
|
|
876
1037
|
return undefined;
|
|
877
1038
|
}
|
|
1039
|
+
emit({ type: 'verify-result', record, response });
|
|
1040
|
+
if (!response.verified
|
|
1041
|
+
&& response.disposition !== 'pending'
|
|
1042
|
+
&& isStoreAccountMismatchReason(response.reason)) {
|
|
1043
|
+
return { kind: 'another-account', reason: response.reason, response };
|
|
1044
|
+
}
|
|
1045
|
+
return { kind: 'response', response };
|
|
1046
|
+
};
|
|
1047
|
+
/**
|
|
1048
|
+
* Parks a `verifying` record the backend answered as another account's
|
|
1049
|
+
* purchase and reports it with an `another-account` event (not a failure).
|
|
1050
|
+
*/
|
|
1051
|
+
const parkForAnotherAccount = async (record, request, answer) => {
|
|
1052
|
+
const parked = await reviseAnotherAccount(record, { reason: answer.reason, reportedAt: now() }, { request, ...(answer.response === undefined ? {} : { response: answer.response }) });
|
|
1053
|
+
emit({
|
|
1054
|
+
type: 'another-account',
|
|
1055
|
+
record: parked,
|
|
1056
|
+
reason: answer.reason,
|
|
1057
|
+
...(answer.response === undefined ? {} : { response: answer.response }),
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
1060
|
+
/**
|
|
1061
|
+
* Journals the backend's answer to a `verifying` record: `pending` or
|
|
1062
|
+
* `rejected`, or `granted` (returned) for a grant that carries its ledger
|
|
1063
|
+
* entry; a malformed grant is reported and the record kept.
|
|
1064
|
+
*/
|
|
1065
|
+
const settleVerify = async (record, request, response) => {
|
|
1066
|
+
if (!response.verified) {
|
|
1067
|
+
if (response.disposition === 'pending') {
|
|
1068
|
+
await write(record, 'pending', { request, response });
|
|
1069
|
+
retryLater(record.idempotencyKey);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
await write(record, 'rejected', { request, response });
|
|
1073
|
+
}
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const ledgerEntryId = response.ledgerEntryId;
|
|
1077
|
+
if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
|
|
1078
|
+
fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
|
|
1079
|
+
return undefined;
|
|
1080
|
+
}
|
|
1081
|
+
return write(record, 'granted', { request, response, ledgerEntryId });
|
|
878
1082
|
};
|
|
879
1083
|
// Reconciler steps 3 and 4: drive one record as far as the backend and the store allow.
|
|
880
1084
|
const drive = async (initial, capabilitiesNow, explicit) => {
|
|
@@ -936,29 +1140,30 @@ export function createCommerceReconciler(input) {
|
|
|
936
1140
|
record = await write(record, 'verifying');
|
|
937
1141
|
continue;
|
|
938
1142
|
case 'verifying': {
|
|
1143
|
+
if (isParkedForAnotherAccount(record)) {
|
|
1144
|
+
// Another account's purchase: only this player's Restore
|
|
1145
|
+
// Purchases sends it again (`ingestPurchase`).
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
939
1148
|
const request = record.request ?? buildRequest(record);
|
|
940
|
-
const
|
|
941
|
-
if (
|
|
1149
|
+
const answer = await sendVerify(record, request);
|
|
1150
|
+
if (answer === undefined) {
|
|
942
1151
|
retryLater(record.idempotencyKey);
|
|
943
1152
|
return;
|
|
944
1153
|
}
|
|
945
|
-
if (
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
else {
|
|
951
|
-
await write(record, 'rejected', { request, response });
|
|
952
|
-
}
|
|
1154
|
+
if (answer.kind === 'another-account') {
|
|
1155
|
+
// Not this player's, and not ended for the store transaction:
|
|
1156
|
+
// never sent again on its own, and the shared key is left to
|
|
1157
|
+
// the purchase's account (RFC "Another account's purchase").
|
|
1158
|
+
await parkForAnotherAccount(record, request, answer);
|
|
953
1159
|
return;
|
|
954
1160
|
}
|
|
955
|
-
const
|
|
956
|
-
if (
|
|
957
|
-
fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
|
|
1161
|
+
const granted = await settleVerify(record, request, answer.response);
|
|
1162
|
+
if (granted === undefined) {
|
|
958
1163
|
return;
|
|
959
1164
|
}
|
|
960
|
-
finalization = response.finalization?.status;
|
|
961
|
-
record =
|
|
1165
|
+
finalization = answer.response.finalization?.status;
|
|
1166
|
+
record = granted;
|
|
962
1167
|
continue;
|
|
963
1168
|
}
|
|
964
1169
|
case 'granted':
|
|
@@ -985,7 +1190,8 @@ export function createCommerceReconciler(input) {
|
|
|
985
1190
|
if (finalization === undefined) {
|
|
986
1191
|
// A pass that did not grant resends the saved request to read
|
|
987
1192
|
// finalization; the backend replays the grant, never a second one.
|
|
988
|
-
|
|
1193
|
+
const answer = await sendVerify(record, record.request ?? buildRequest(record));
|
|
1194
|
+
reread = answer?.kind === 'response' ? answer.response : undefined;
|
|
989
1195
|
finalization = reread?.verified === true
|
|
990
1196
|
? reread.finalization?.status
|
|
991
1197
|
: undefined;
|
|
@@ -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
package/dist/runtime.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
@@ -129,6 +130,17 @@ export declare function createGameServicesRuntime(input: CreateGameServicesRunti
|
|
|
129
130
|
export declare function resolveGameServicesLedgerTarget(target: PlatformTarget | string): GameServicesLedgerTarget | null;
|
|
130
131
|
export declare function resolveGameServicesAuthorityMode(profile: string): GameServicesAuthorityMode;
|
|
131
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;
|
|
132
144
|
/**
|
|
133
145
|
* Whether a game-services URL may carry a session's credentials in a
|
|
134
146
|
* production build: an absolute https URL with no credentials and a public
|
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AccountDeletionError, createAccountDeletionClient, } from './account-deletion.js';
|
|
2
2
|
import { CommerceError } from '@mpgd/platform/commerce';
|
|
3
|
+
import { isHostedGameServicesPath } from '@mpgd/platform/routes';
|
|
3
4
|
import { assertCommerceStaleLaunchedAfterMs, createGameServicesClient, createGameServicesFetchBackendTransport, createGameServicesHttpBackendApi, createGameServicesOrpcBackendApi, createGameServicesOrpcClient, resolveGameServicesRequestHeaders, } from './client.js';
|
|
4
5
|
import { gameServicesPlayerChangedCode, GameServicesRuntimeClosedError, gameServicesRuntimeDisposedCode, gameServicesSessionRequiredCode, } from './request-headers.js';
|
|
5
6
|
import { createRecoverableMonetizationClient, } from './monetization-recovery.js';
|
|
@@ -492,6 +493,28 @@ function normalizeBaseUrl(baseUrl) {
|
|
|
492
493
|
const normalized = baseUrl?.trim();
|
|
493
494
|
return normalized === undefined || normalized.length === 0 ? undefined : normalized;
|
|
494
495
|
}
|
|
496
|
+
export { isHostedGameServicesPath };
|
|
497
|
+
/**
|
|
498
|
+
* Resolve a hosted browser's clean /api ingress before constructing absolute-URL
|
|
499
|
+
* clients. No ambient DOM access: composition roots supply the actual page origin
|
|
500
|
+
* and gateway target. Unresolved/invalid inputs remain invalid for the runtime's
|
|
501
|
+
* fail-closed authority check; this helper never supplies a fallback backend.
|
|
502
|
+
*/
|
|
503
|
+
export function resolveGameServicesBaseUrl(baseUrl, context) {
|
|
504
|
+
const value = normalizeBaseUrl(baseUrl);
|
|
505
|
+
if (value === undefined || (context.target !== 'browser' && context.target !== 'microsoft-store')
|
|
506
|
+
|| value !== baseUrl || !isHostedGameServicesPath(value)) {
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
const origin = context.pageOrigin === undefined
|
|
510
|
+
? undefined
|
|
511
|
+
: parseAbsoluteUrl(context.pageOrigin);
|
|
512
|
+
if (origin === undefined || origin.origin !== context.pageOrigin
|
|
513
|
+
|| (origin.protocol !== 'https:' && origin.protocol !== 'http:')) {
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
return new URL(value, origin).href;
|
|
517
|
+
}
|
|
495
518
|
/**
|
|
496
519
|
* Whether a game-services URL may carry a session's credentials in a
|
|
497
520
|
* production build: an absolute https URL with no credentials and a public
|
package/dist/server.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type AdPlacements, type ProductCatalog } from '@mpgd/catalog';
|
|
|
3
3
|
import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesBackendTransportRequest, type GameServicesBackendTransportResponse } from './client.js';
|
|
4
4
|
import { type CommerceEvidenceRegistry } from './commerce-evidence-registry.js';
|
|
5
5
|
import { type PlayerDataErasureInput, type PlayerDataErasureResult } from './account-deletion.js';
|
|
6
|
+
import { purchaseBoundToAnotherAccountCode } from './another-account.js';
|
|
6
7
|
import { type GameServicesHealthResponse } from './contract.js';
|
|
7
8
|
import { type GameServicesEvidenceVerifier, type GameServicesPurchaseGrantFinalizer } from './evidence-verification.js';
|
|
8
9
|
import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerNote, type EntitlementLedgerResult, type EntitlementLedgerRevocation, type EntitlementRevocationTransaction, type GameServicesAdRewardTarget, type GameServicesStoreTarget, type LeaderboardScoreTransaction, type ProductGrantTransaction, type PurchaseLedgerStatusRequest, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types.js';
|
|
@@ -58,11 +59,15 @@ export interface GameServicesBackendApiHandler {
|
|
|
58
59
|
handle(request: GameServicesBackendTransportRequest): Promise<GameServicesBackendTransportResponse>;
|
|
59
60
|
}
|
|
60
61
|
/** Stable error codes emitted by the backend API handler and the HTTP fetch handler. */
|
|
61
|
-
export type GameServicesBackendErrorCode = 'INVALID_REQUEST' | 'INVALID_JSON' | 'PAYLOAD_TOO_LARGE' | 'METHOD_NOT_ALLOWED' | 'UNKNOWN_ENDPOINT' | 'INTERNAL_ERROR'
|
|
62
|
+
export type GameServicesBackendErrorCode = 'INVALID_REQUEST' | 'INVALID_JSON' | 'PAYLOAD_TOO_LARGE' | 'METHOD_NOT_ALLOWED' | 'UNKNOWN_ENDPOINT' | 'INTERNAL_ERROR'
|
|
63
|
+
/** 409: the store binds the purchase to another account (RFC "Another account's purchase"). */
|
|
64
|
+
| typeof purchaseBoundToAnotherAccountCode;
|
|
62
65
|
export interface GameServicesBackendErrorResponse {
|
|
63
66
|
readonly error: GameServicesBackendErrorCode;
|
|
64
67
|
/** Present only for `INVALID_REQUEST`; never carries internal failure details. */
|
|
65
68
|
readonly message?: string;
|
|
69
|
+
/** Present only for `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`: the store's binding mismatch. */
|
|
70
|
+
readonly reason?: string;
|
|
66
71
|
}
|
|
67
72
|
export interface GameServicesStore {
|
|
68
73
|
recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
|
|
@@ -239,6 +244,16 @@ export declare function createGameServicesRouter(backend: GameServicesBackendApi
|
|
|
239
244
|
recordScore: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, Record<never, never>>;
|
|
240
245
|
};
|
|
241
246
|
};
|
|
247
|
+
/**
|
|
248
|
+
* The RPC handler's error status map: oRPC's common codes and
|
|
249
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` at 409. oRPC 2.0.0-beta.46 derives the
|
|
250
|
+
* HTTP status of an error from its code through this map (an `ORPCError`
|
|
251
|
+
* carries no status of its own), so a host that mounts
|
|
252
|
+
* `createGameServicesRouter` in its own `RPCHandler` passes it as
|
|
253
|
+
* `errorStatusMap` to answer 409 too; without it the code still reaches the
|
|
254
|
+
* client, with status 500.
|
|
255
|
+
*/
|
|
256
|
+
export declare const gameServicesRpcErrorStatusMap: Readonly<Record<string, number>>;
|
|
242
257
|
export declare function createGameServicesRpcFetchHandler(router: ReturnType<typeof createGameServicesRouter>, options?: CreateGameServicesRpcFetchHandlerOptions): (request: Request) => Promise<Response>;
|
|
243
258
|
export declare function createGameServicesHttpFetchHandler(handler: GameServicesBackendApiHandler, options?: CreateGameServicesFetchHandlerOptions): (request: Request) => Promise<Response>;
|
|
244
259
|
export * from './notification-delivery.js';
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMMON_ERROR_STATUS_MAP } from '@orpc/client';
|
|
1
2
|
import { implement, ORPCError } from '@orpc/server';
|
|
2
3
|
import { RPCHandler } from '@orpc/server/fetch';
|
|
3
4
|
import { RequestLimitHandlerPlugin } from '@orpc/server/plugins';
|
|
@@ -7,6 +8,7 @@ import { gameServicesBackendEndpoints, } from './client.js';
|
|
|
7
8
|
import { resolveCommerceStoreId, } from './commerce-evidence-registry.js';
|
|
8
9
|
import { createPurchaseHolderEvidenceVerificationId, findEntitlementRevocationsChunked, findPurchaseWideRevocation, holderRevocationEvidenceId, isEntitlementGrantReversed, isRestoredAcrossPlayers, listPurchaseHolders, readPurchaseLedgerStatuses, readPurchaseVerificationId, RevocationKeyConflictError, revokeEntitlementGrant, } from './commerce-ledger.js';
|
|
9
10
|
import { assertPlayerDataErasureInput, createRetainedLedgerEntryId, detachRetainedLedgerPayload, isRetainedPlayerId, } from './account-deletion.js';
|
|
11
|
+
import { createPurchaseBoundToAnotherAccountBody, GameServicesPurchaseBoundToAnotherAccountError, isStoreAccountMismatchReason, purchaseBoundToAnotherAccountCode, purchaseBoundToAnotherAccountStatus, } from './another-account.js';
|
|
10
12
|
import { gameServicesContract } from './contract.js';
|
|
11
13
|
import { addLegacyOrpcErrorFlag } from './orpc-error-compat.js';
|
|
12
14
|
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
|
|
@@ -407,6 +409,23 @@ export function createGameServicesBackend(input) {
|
|
|
407
409
|
: { purchaseGrantFinalizer }),
|
|
408
410
|
...(registry === undefined ? {} : { registry }),
|
|
409
411
|
}, async (verification) => {
|
|
412
|
+
if (verification instanceof GameServicesPurchaseBoundToAnotherAccountError) {
|
|
413
|
+
// Refused for this player, not decided for its account: tracked
|
|
414
|
+
// as a refusal with the mark, never as a pending purchase.
|
|
415
|
+
await analytics.track({
|
|
416
|
+
name: 'purchase_rejected',
|
|
417
|
+
properties: {
|
|
418
|
+
target: request.target,
|
|
419
|
+
store: resolveCommerceStoreId(request),
|
|
420
|
+
playerId: request.playerId,
|
|
421
|
+
productId: request.productId,
|
|
422
|
+
alreadyProcessed: false,
|
|
423
|
+
reason: verification.code,
|
|
424
|
+
boundToAnotherAccount: verification.reason,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
410
429
|
await analytics.track({
|
|
411
430
|
name: verification.verified ? 'purchase_granted'
|
|
412
431
|
: verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
@@ -521,6 +540,12 @@ export function createGameServicesBackendApiHandler(input) {
|
|
|
521
540
|
if (error instanceof GameServicesRequestValidationError) {
|
|
522
541
|
return errorResponse(400, 'INVALID_REQUEST', error.message);
|
|
523
542
|
}
|
|
543
|
+
if (error instanceof GameServicesPurchaseBoundToAnotherAccountError) {
|
|
544
|
+
return {
|
|
545
|
+
status: purchaseBoundToAnotherAccountStatus,
|
|
546
|
+
body: createPurchaseBoundToAnotherAccountBody(error.reason),
|
|
547
|
+
};
|
|
548
|
+
}
|
|
524
549
|
reportInternalError(input.onInternalError, error);
|
|
525
550
|
return errorResponse(500, 'INTERNAL_ERROR');
|
|
526
551
|
}
|
|
@@ -573,6 +598,15 @@ export function createGameServicesRouter(backend, options = {}) {
|
|
|
573
598
|
if (error instanceof GameServicesRequestValidationError) {
|
|
574
599
|
throw new ORPCError('BAD_REQUEST', { message: error.message });
|
|
575
600
|
}
|
|
601
|
+
if (error instanceof GameServicesPurchaseBoundToAnotherAccountError) {
|
|
602
|
+
// A retryable answer, not an internal error: the code and reason reach the client.
|
|
603
|
+
// oRPC beta.46 errors carry no status: `gameServicesRpcErrorStatusMap`
|
|
604
|
+
// (used by `createGameServicesRpcFetchHandler`) maps the code to 409.
|
|
605
|
+
throw new ORPCError(purchaseBoundToAnotherAccountCode, {
|
|
606
|
+
message: error.message,
|
|
607
|
+
data: { reason: error.reason },
|
|
608
|
+
});
|
|
609
|
+
}
|
|
576
610
|
reportInternalError(options.onInternalError, error);
|
|
577
611
|
throw error;
|
|
578
612
|
}
|
|
@@ -612,8 +646,22 @@ export function createGameServicesRouter(backend, options = {}) {
|
|
|
612
646
|
}),
|
|
613
647
|
});
|
|
614
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* The RPC handler's error status map: oRPC's common codes and
|
|
651
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` at 409. oRPC 2.0.0-beta.46 derives the
|
|
652
|
+
* HTTP status of an error from its code through this map (an `ORPCError`
|
|
653
|
+
* carries no status of its own), so a host that mounts
|
|
654
|
+
* `createGameServicesRouter` in its own `RPCHandler` passes it as
|
|
655
|
+
* `errorStatusMap` to answer 409 too; without it the code still reaches the
|
|
656
|
+
* client, with status 500.
|
|
657
|
+
*/
|
|
658
|
+
export const gameServicesRpcErrorStatusMap = Object.freeze({
|
|
659
|
+
...COMMON_ERROR_STATUS_MAP,
|
|
660
|
+
[purchaseBoundToAnotherAccountCode]: purchaseBoundToAnotherAccountStatus,
|
|
661
|
+
});
|
|
615
662
|
export function createGameServicesRpcFetchHandler(router, options = {}) {
|
|
616
663
|
const rpcHandler = new RPCHandler(router, {
|
|
664
|
+
errorStatusMap: { ...gameServicesRpcErrorStatusMap },
|
|
617
665
|
plugins: [
|
|
618
666
|
new RequestLimitHandlerPlugin({
|
|
619
667
|
maxBodySize: resolveMaxBodyBytes(options.maxBodyBytes),
|
|
@@ -787,7 +835,21 @@ async function verifyPurchaseWithStore(input, context, onDecision) {
|
|
|
787
835
|
: { deploymentTarget: request.deploymentTarget }),
|
|
788
836
|
};
|
|
789
837
|
return withEntitlementRetryLock(retryIdentity, async () => {
|
|
790
|
-
|
|
838
|
+
let verification;
|
|
839
|
+
try {
|
|
840
|
+
verification = await verifyPurchaseForRetry(request, retryIdentity, context);
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
if (error instanceof GameServicesPurchaseBoundToAnotherAccountError) {
|
|
844
|
+
try {
|
|
845
|
+
await onDecision(error);
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
// Analytics is best-effort: a failed report never replaces the answer.
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
791
853
|
await onDecision(verification);
|
|
792
854
|
return verification;
|
|
793
855
|
});
|
|
@@ -862,18 +924,27 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
862
924
|
? routedEntry.entry.store
|
|
863
925
|
: resolveCommerceStoreId(request);
|
|
864
926
|
if (verification.status === 'restorable') {
|
|
865
|
-
// A verified restore of a purchase the store binds to another player.
|
|
927
|
+
// A verified restore of a purchase the store binds to another player, or to none.
|
|
866
928
|
return shareRestoredPurchase(request, {
|
|
867
929
|
product,
|
|
868
930
|
platformProductId,
|
|
869
931
|
verification,
|
|
870
932
|
verifiedStore,
|
|
871
|
-
refusal:
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
933
|
+
refusal: () => {
|
|
934
|
+
if (isStoreAccountMismatchReason(verification.reason)) {
|
|
935
|
+
// No share for this player: the purchase stays its account's,
|
|
936
|
+
// which can still restore it (RFC "Another account's purchase").
|
|
937
|
+
throw new GameServicesPurchaseBoundToAnotherAccountError(verification.reason);
|
|
938
|
+
}
|
|
939
|
+
// Bound to no account (`*_MISSING`) or a custom verifier's reason:
|
|
940
|
+
// no account can claim it, so the refusal is final.
|
|
941
|
+
return assertVerifyPurchaseResponse({
|
|
942
|
+
verified: false,
|
|
943
|
+
alreadyProcessed: false,
|
|
944
|
+
disposition: 'rejected',
|
|
945
|
+
reason: verification.reason,
|
|
946
|
+
});
|
|
947
|
+
},
|
|
877
948
|
}, context);
|
|
878
949
|
}
|
|
879
950
|
if (verification.status !== 'verified') {
|
|
@@ -883,6 +954,12 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
883
954
|
if (racedExisting !== undefined) {
|
|
884
955
|
return completeExistingRetry(racedExisting);
|
|
885
956
|
}
|
|
957
|
+
if (verification.status === 'rejected' && verification.anotherAccount === true) {
|
|
958
|
+
// Another account's valid purchase: nothing is granted or recorded, and
|
|
959
|
+
// the answer is a retryable error, never a `rejected` receipt that would
|
|
960
|
+
// end the client's record and block its account's recovery.
|
|
961
|
+
throw new GameServicesPurchaseBoundToAnotherAccountError(verification.reason);
|
|
962
|
+
}
|
|
886
963
|
return assertVerifyPurchaseResponse({
|
|
887
964
|
verified: false,
|
|
888
965
|
alreadyProcessed: false,
|
|
@@ -1111,6 +1188,15 @@ function createPurchaseGrantPayload(request, input) {
|
|
|
1111
1188
|
evidenceVerifiedAt: input.verification.verifiedAt,
|
|
1112
1189
|
};
|
|
1113
1190
|
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Whether the `share` policy applies: a non-consumable, asked as a restore
|
|
1193
|
+
* unless the store binds it to this player.
|
|
1194
|
+
*/
|
|
1195
|
+
function sharesRestoredPurchase(request, product, context, bindingMatches = false) {
|
|
1196
|
+
return context.restoreSharing.mode === 'share'
|
|
1197
|
+
&& (request.intent === 'restore' || bindingMatches)
|
|
1198
|
+
&& product.type === 'non_consumable';
|
|
1199
|
+
}
|
|
1114
1200
|
/**
|
|
1115
1201
|
* A verified restore of a non-consumable the store binds to another player
|
|
1116
1202
|
* (RFC "Restore sharing"). Under the `share` policy, for a request with
|
|
@@ -1125,10 +1211,8 @@ function createPurchaseGrantPayload(request, input) {
|
|
|
1125
1211
|
async function shareRestoredPurchase(request, input, context) {
|
|
1126
1212
|
const { store, restoreSharing: policy } = context;
|
|
1127
1213
|
const { product, verification } = input;
|
|
1128
|
-
if (
|
|
1129
|
-
|
|
1130
|
-
|| product.type !== 'non_consumable') {
|
|
1131
|
-
return input.refusal;
|
|
1214
|
+
if (!sharesRestoredPurchase(request, product, context, input.bindingMatches === true)) {
|
|
1215
|
+
return typeof input.refusal === 'function' ? input.refusal() : input.refusal;
|
|
1132
1216
|
}
|
|
1133
1217
|
const purchaseVerificationId = verification.verificationId;
|
|
1134
1218
|
const notGranted = (disposition, reason) => ({
|
|
@@ -1740,6 +1824,9 @@ function assertEvidenceVerificationDecision(decision) {
|
|
|
1740
1824
|
}
|
|
1741
1825
|
if (decision.status === 'rejected') {
|
|
1742
1826
|
assertNonEmptyDecisionString(decision.reason, 'reason');
|
|
1827
|
+
if (decision.anotherAccount !== undefined && decision.anotherAccount !== true) {
|
|
1828
|
+
throw new Error('Evidence verification anotherAccount must be true when present.');
|
|
1829
|
+
}
|
|
1743
1830
|
return decision;
|
|
1744
1831
|
}
|
|
1745
1832
|
throw new Error('Unknown evidence verification status.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -231,9 +231,9 @@
|
|
|
231
231
|
"@orpc/client": "2.0.0-beta.46",
|
|
232
232
|
"@orpc/contract": "2.0.0-beta.46",
|
|
233
233
|
"@orpc/server": "2.0.0-beta.46",
|
|
234
|
-
"@mpgd/analytics": "0.5.
|
|
235
|
-
"@mpgd/
|
|
236
|
-
"@mpgd/
|
|
234
|
+
"@mpgd/analytics": "0.5.7",
|
|
235
|
+
"@mpgd/platform": "0.34.0",
|
|
236
|
+
"@mpgd/catalog": "0.14.1"
|
|
237
237
|
},
|
|
238
238
|
"peerDependencies": {
|
|
239
239
|
"@apple/app-store-server-library": "^3.1.0"
|