@mpgd/game-services 0.31.0 → 0.33.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-server-notifications.d.ts +15 -2
- package/dist/app-store-server-notifications.js +43 -9
- package/dist/app-store-verification-id.d.ts +25 -0
- package/dist/app-store-verification-id.js +32 -0
- package/dist/app-store-verifier.d.ts +99 -9
- package/dist/app-store-verifier.js +127 -32
- package/dist/client.js +8 -3
- package/dist/commerce-reconciler.d.ts +55 -1
- package/dist/commerce-reconciler.js +456 -25
- 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
|
@@ -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.33.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/catalog": "0.14.
|
|
236
|
-
"@mpgd/platform": "0.
|
|
234
|
+
"@mpgd/analytics": "0.5.8",
|
|
235
|
+
"@mpgd/catalog": "0.14.2",
|
|
236
|
+
"@mpgd/platform": "0.35.0"
|
|
237
237
|
},
|
|
238
238
|
"peerDependencies": {
|
|
239
239
|
"@apple/app-store-server-library": "^3.1.0"
|