@mpgd/game-services 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admob-client-reward.d.ts +27 -0
- package/dist/admob-client-reward.js +84 -0
- package/dist/admob-ssv.d.ts +2 -0
- package/dist/admob-ssv.js +5 -0
- package/dist/app-store-recovery.d.ts +38 -0
- package/dist/app-store-recovery.js +271 -0
- package/dist/app-store-verifier.d.ts +7 -0
- package/dist/app-store-verifier.js +41 -3
- package/dist/authoritative-purchase.d.ts +9 -0
- package/dist/authoritative-purchase.js +10 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +17 -18
- package/dist/google-play-publisher-client.d.ts +11 -0
- package/dist/google-play-publisher-client.js +113 -0
- package/dist/google-play-purchase.d.ts +2 -0
- package/dist/google-play-purchase.js +21 -7
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/monetization-recovery.d.ts +63 -0
- package/dist/monetization-recovery.js +632 -0
- package/dist/operation-progress.d.ts +7 -1
- package/dist/operation-progress.js +23 -0
- package/dist/operation-results.d.ts +4 -0
- package/dist/operations.d.ts +1 -1
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +29 -14
- package/dist/server.d.ts +20 -2
- package/dist/server.js +246 -86
- package/dist/types.d.ts +12 -0
- package/dist/types.js +81 -27
- package/dist/validation.d.ts +18 -0
- package/dist/validation.js +26 -0
- package/package.json +19 -6
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RewardedAdResult } from '@mpgd/platform';
|
|
2
|
+
/** This schema records SDK callback evidence, never an authoritative grant. */
|
|
3
|
+
export declare const admobClientRewardEvidenceSchema = "mpgd.admob.client-reward.v1";
|
|
4
|
+
export declare function isAdMobClientRewardEvidence(value: unknown): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Verse8 Ads SDK callback evidence. Mirrors `verse8AdsRewardEvidenceSchema`
|
|
7
|
+
* from `@mpgd/adapter-verse8`; the adapter owns the schema and the backend
|
|
8
|
+
* verifier consumes it, so the value must stay byte-identical on both sides.
|
|
9
|
+
*/
|
|
10
|
+
export declare const verse8ClientRewardEvidenceSchema = "verse8.ads.reward.v1";
|
|
11
|
+
/**
|
|
12
|
+
* Evidence schemas that a client adapter may return with
|
|
13
|
+
* `status: 'completed' | 'pending'`, `rewardGranted: false`, and no
|
|
14
|
+
* `ledgerEntryId`. They are claim candidates only: the backend ledger decides
|
|
15
|
+
* whether a reward is granted.
|
|
16
|
+
*/
|
|
17
|
+
export declare const clientRewardEvidenceSchemas: readonly ["mpgd.admob.client-reward.v1", "verse8.ads.reward.v1"];
|
|
18
|
+
export type ClientRewardEvidenceSchema = (typeof clientRewardEvidenceSchemas)[number];
|
|
19
|
+
/** True when a rewarded-ad result carries ungranted evidence from an allow-listed schema. */
|
|
20
|
+
export declare function isClientRewardEvidence(value: unknown): boolean;
|
|
21
|
+
export declare function isVerse8ClientRewardEvidence(value: unknown): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the platform impression identifier to forward with a backend claim.
|
|
24
|
+
* Authoritative adapters return `ledgerEntryId`; client-evidence adapters
|
|
25
|
+
* expose the impression identifier inside the evidence payload instead.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveRewardPlatformImpressionId(reward: RewardedAdResult): string | undefined;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** This schema records SDK callback evidence, never an authoritative grant. */
|
|
2
|
+
export const admobClientRewardEvidenceSchema = 'mpgd.admob.client-reward.v1';
|
|
3
|
+
export function isAdMobClientRewardEvidence(value) {
|
|
4
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
const reward = value;
|
|
8
|
+
const evidence = reward.evidence;
|
|
9
|
+
if ((reward.status !== 'completed' && reward.status !== 'pending')
|
|
10
|
+
|| reward.rewardGranted !== false
|
|
11
|
+
|| reward.ledgerEntryId !== undefined
|
|
12
|
+
|| typeof evidence !== 'object' || evidence === null || Array.isArray(evidence)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const envelope = evidence;
|
|
16
|
+
const payload = envelope.payload;
|
|
17
|
+
return envelope.schema === admobClientRewardEvidenceSchema
|
|
18
|
+
&& typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
|
19
|
+
&& typeof payload.adUnitId === 'string'
|
|
20
|
+
&& payload.adUnitId.length > 0;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Verse8 Ads SDK callback evidence. Mirrors `verse8AdsRewardEvidenceSchema`
|
|
24
|
+
* from `@mpgd/adapter-verse8`; the adapter owns the schema and the backend
|
|
25
|
+
* verifier consumes it, so the value must stay byte-identical on both sides.
|
|
26
|
+
*/
|
|
27
|
+
export const verse8ClientRewardEvidenceSchema = 'verse8.ads.reward.v1';
|
|
28
|
+
/**
|
|
29
|
+
* Evidence schemas that a client adapter may return with
|
|
30
|
+
* `status: 'completed' | 'pending'`, `rewardGranted: false`, and no
|
|
31
|
+
* `ledgerEntryId`. They are claim candidates only: the backend ledger decides
|
|
32
|
+
* whether a reward is granted.
|
|
33
|
+
*/
|
|
34
|
+
export const clientRewardEvidenceSchemas = Object.freeze([
|
|
35
|
+
admobClientRewardEvidenceSchema,
|
|
36
|
+
verse8ClientRewardEvidenceSchema,
|
|
37
|
+
]);
|
|
38
|
+
/** True when a rewarded-ad result carries ungranted evidence from an allow-listed schema. */
|
|
39
|
+
export function isClientRewardEvidence(value) {
|
|
40
|
+
return isAdMobClientRewardEvidence(value) || isVerse8ClientRewardEvidence(value);
|
|
41
|
+
}
|
|
42
|
+
export function isVerse8ClientRewardEvidence(value) {
|
|
43
|
+
const payload = readUngrantedEvidencePayload(value, verse8ClientRewardEvidenceSchema);
|
|
44
|
+
return payload !== undefined
|
|
45
|
+
&& isNonEmptyString(payload.requestId)
|
|
46
|
+
&& isNonEmptyString(payload.placementId);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the platform impression identifier to forward with a backend claim.
|
|
50
|
+
* Authoritative adapters return `ledgerEntryId`; client-evidence adapters
|
|
51
|
+
* expose the impression identifier inside the evidence payload instead.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveRewardPlatformImpressionId(reward) {
|
|
54
|
+
if (reward.ledgerEntryId !== undefined) {
|
|
55
|
+
return reward.ledgerEntryId;
|
|
56
|
+
}
|
|
57
|
+
if (isVerse8ClientRewardEvidence(reward)) {
|
|
58
|
+
return reward.evidence?.payload.requestId;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function readUngrantedEvidencePayload(value, schema) {
|
|
63
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const reward = value;
|
|
67
|
+
const evidence = reward.evidence;
|
|
68
|
+
if ((reward.status !== 'completed' && reward.status !== 'pending')
|
|
69
|
+
|| reward.rewardGranted !== false
|
|
70
|
+
|| reward.ledgerEntryId !== undefined
|
|
71
|
+
|| typeof evidence !== 'object' || evidence === null || Array.isArray(evidence)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const envelope = evidence;
|
|
75
|
+
const payload = envelope.payload;
|
|
76
|
+
if (envelope.schema !== schema
|
|
77
|
+
|| typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return payload;
|
|
81
|
+
}
|
|
82
|
+
function isNonEmptyString(value) {
|
|
83
|
+
return typeof value === 'string' && value.length > 0;
|
|
84
|
+
}
|
package/dist/admob-ssv.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { AdPlacementEntry } from '@mpgd/catalog';
|
|
|
2
2
|
import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
|
|
3
3
|
import type { ClaimAdRewardRequest } from './types.js';
|
|
4
4
|
export declare const admobSsvCustomDataSchema = "mpgd.admob.ssv.binding.v1";
|
|
5
|
+
/** Maximum field length accepted by the shipped game-services SSV receiver. */
|
|
6
|
+
export declare const admobSsvMaximumBindingFieldLength = 256;
|
|
5
7
|
export declare const defaultAdMobSsvMaximumCallbackAgeMs = 86400000;
|
|
6
8
|
export declare const defaultAdMobSsvMaximumFutureSkewMs = 300000;
|
|
7
9
|
export interface AdMobSsvCustomDataBinding {
|
package/dist/admob-ssv.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Point, verify as verifySecp256k1 } from '@noble/secp256k1';
|
|
2
2
|
export const admobSsvCustomDataSchema = 'mpgd.admob.ssv.binding.v1';
|
|
3
|
+
/** Maximum field length accepted by the shipped game-services SSV receiver. */
|
|
4
|
+
export const admobSsvMaximumBindingFieldLength = 256;
|
|
3
5
|
export const defaultAdMobSsvMaximumCallbackAgeMs = 86_400_000;
|
|
4
6
|
export const defaultAdMobSsvMaximumFutureSkewMs = 300_000;
|
|
5
7
|
const signatureMarker = '&signature=';
|
|
@@ -214,10 +216,13 @@ async function verifyAdMobSsvReward(context, input) {
|
|
|
214
216
|
if (callback.timestampMs < nowMs - context.maximumCallbackAgeMs) {
|
|
215
217
|
return rejected('ADMOB_SSV_CALLBACK_EXPIRED');
|
|
216
218
|
}
|
|
219
|
+
// SSV never validates the client-reported `platformImpressionId`, so the signed
|
|
220
|
+
// transaction becomes the platform evidence identity instead of the client value.
|
|
217
221
|
return {
|
|
218
222
|
status: 'verified',
|
|
219
223
|
verificationId: `admob:ssv:${callback.transactionId}`,
|
|
220
224
|
verifiedAt: new Date(callback.timestampMs).toISOString(),
|
|
225
|
+
platformEvidenceId: callback.transactionId,
|
|
221
226
|
payload: {
|
|
222
227
|
admobSsvTransactionId: callback.transactionId,
|
|
223
228
|
admobSsvAdNetwork: callback.adNetwork,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ProductCatalog } from '@mpgd/catalog';
|
|
2
|
+
import { type AppStoreEnvironment } from './app-store-verifier.js';
|
|
3
|
+
import type { PurchaseVerificationApi } from './client.js';
|
|
4
|
+
import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
|
|
5
|
+
import type { GameServicesStore } from './server.js';
|
|
6
|
+
import type { VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
|
|
7
|
+
export type AppStoreRecoveryRequest = Omit<VerifyPurchaseRequest, 'target' | 'idempotencyKey' | 'evidence'> & {
|
|
8
|
+
readonly target: 'ios';
|
|
9
|
+
};
|
|
10
|
+
export interface AppStoreRestoredTransactionIdentity {
|
|
11
|
+
readonly originalTransactionId?: string;
|
|
12
|
+
readonly productType?: 'consumable' | 'non_consumable';
|
|
13
|
+
}
|
|
14
|
+
export interface CreateAppStoreRecoveryBackendOptions {
|
|
15
|
+
/** Bind this instance to a player authenticated by the game server. */
|
|
16
|
+
readonly playerId: string;
|
|
17
|
+
readonly deploymentTarget?: string;
|
|
18
|
+
readonly purchases: PurchaseVerificationApi;
|
|
19
|
+
readonly store: GameServicesStore;
|
|
20
|
+
/** Required to re-verify restored non-consumables whose current transaction ID changed. */
|
|
21
|
+
readonly restoredNonConsumables?: {
|
|
22
|
+
readonly catalog: ProductCatalog;
|
|
23
|
+
readonly evidenceVerifier: GameServicesEvidenceVerifier;
|
|
24
|
+
readonly bundleId: string;
|
|
25
|
+
readonly environment: AppStoreEnvironment;
|
|
26
|
+
readonly timeoutMs?: number;
|
|
27
|
+
};
|
|
28
|
+
/** Look up the original, durable checkout key; never derive one from the transaction ID. */
|
|
29
|
+
readonly resolveOriginalIdempotencyKey?: (input: AppStoreRecoveryRequest & AppStoreRestoredTransactionIdentity) => Promise<string | undefined>;
|
|
30
|
+
}
|
|
31
|
+
export interface AppStoreRecoveryBackend {
|
|
32
|
+
recoverPurchase(input: AppStoreRecoveryRequest & AppStoreRestoredTransactionIdentity): Promise<VerifyPurchaseResponse>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Connect StoreKit recovery to the existing authoritative purchase ledger.
|
|
36
|
+
* This is a server-side helper, not an authentication or Apple verifier.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createAppStoreRecoveryBackend(options: CreateAppStoreRecoveryBackendOptions): AppStoreRecoveryBackend;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { resolveProductPlatformId } from '@mpgd/catalog';
|
|
2
|
+
import { createAppStoreVerificationId, isAppStoreVerificationId, } from './app-store-verifier.js';
|
|
3
|
+
/**
|
|
4
|
+
* Connect StoreKit recovery to the existing authoritative purchase ledger.
|
|
5
|
+
* This is a server-side helper, not an authentication or Apple verifier.
|
|
6
|
+
*/
|
|
7
|
+
export function createAppStoreRecoveryBackend(options) {
|
|
8
|
+
if (options.playerId.trim() === '') {
|
|
9
|
+
throw new TypeError('App Store recovery requires an authenticated player ID.');
|
|
10
|
+
}
|
|
11
|
+
const restoreTimeoutMs = options.restoredNonConsumables?.timeoutMs;
|
|
12
|
+
if (restoreTimeoutMs !== undefined
|
|
13
|
+
&& (!Number.isSafeInteger(restoreTimeoutMs) || restoreTimeoutMs <= 0)) {
|
|
14
|
+
throw new TypeError('Restored App Store verification timeout must be positive.');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
async recoverPurchase(input) {
|
|
18
|
+
if (input?.target !== 'ios' || input.playerId !== options.playerId
|
|
19
|
+
|| typeof input.productId !== 'string' || input.productId.trim() === ''
|
|
20
|
+
|| typeof input.platformTransactionId !== 'string'
|
|
21
|
+
|| input.platformTransactionId.trim() === ''
|
|
22
|
+
|| typeof input.purchasedAt !== 'string' || input.purchasedAt.trim() === ''
|
|
23
|
+
|| (options.deploymentTarget !== undefined
|
|
24
|
+
&& input.deploymentTarget !== undefined
|
|
25
|
+
&& input.deploymentTarget !== options.deploymentTarget)) {
|
|
26
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
27
|
+
}
|
|
28
|
+
let request = {
|
|
29
|
+
...input,
|
|
30
|
+
...(options.deploymentTarget === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { deploymentTarget: options.deploymentTarget }),
|
|
33
|
+
};
|
|
34
|
+
try {
|
|
35
|
+
const restoreConfig = options.restoredNonConsumables;
|
|
36
|
+
if (input.productType === 'non_consumable'
|
|
37
|
+
&& input.originalTransactionId !== undefined
|
|
38
|
+
&& input.originalTransactionId !== input.platformTransactionId
|
|
39
|
+
&& restoreConfig !== undefined) {
|
|
40
|
+
const candidateId = createAppStoreVerificationId({
|
|
41
|
+
environment: restoreConfig.environment,
|
|
42
|
+
bundleId: restoreConfig.bundleId,
|
|
43
|
+
transactionId: input.originalTransactionId,
|
|
44
|
+
});
|
|
45
|
+
const candidate = await findByVerificationId(options.store, candidateId);
|
|
46
|
+
if (candidate !== undefined) {
|
|
47
|
+
if (candidate.source !== 'purchase'
|
|
48
|
+
|| candidate.playerId !== request.playerId
|
|
49
|
+
|| candidate.grantId !== request.productId
|
|
50
|
+
|| candidate.payload.target !== 'ios'
|
|
51
|
+
|| candidate.payload.productType !== 'non_consumable'
|
|
52
|
+
|| candidate.payload.appStoreOriginalTransactionId
|
|
53
|
+
!== input.originalTransactionId) {
|
|
54
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
55
|
+
}
|
|
56
|
+
if (typeof candidate.payload.deploymentTarget === 'string') {
|
|
57
|
+
if (request.deploymentTarget !== undefined
|
|
58
|
+
&& request.deploymentTarget !== candidate.payload.deploymentTarget) {
|
|
59
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
60
|
+
}
|
|
61
|
+
request = { ...request, deploymentTarget: candidate.payload.deploymentTarget };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (options.deploymentTarget === undefined) {
|
|
65
|
+
// A request-supplied target is not a server-bound deployment identity.
|
|
66
|
+
return pending('APP_STORE_RECOVERY_DEPLOYMENT_TARGET_REQUIRED');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const restoredVerificationId = await verifyRestoredNonConsumable(input, request, options);
|
|
70
|
+
if (typeof restoredVerificationId !== 'string') {
|
|
71
|
+
return restoredVerificationId;
|
|
72
|
+
}
|
|
73
|
+
const platformGrant = await findByPlatformEvidence(options.store, request.platformTransactionId);
|
|
74
|
+
const originalGrant = restoredVerificationId === ''
|
|
75
|
+
? undefined
|
|
76
|
+
: await findByVerificationId(options.store, restoredVerificationId);
|
|
77
|
+
if (platformGrant !== undefined && originalGrant !== undefined
|
|
78
|
+
&& platformGrant.ledgerEntryId !== originalGrant.ledgerEntryId) {
|
|
79
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
80
|
+
}
|
|
81
|
+
const existing = platformGrant ?? originalGrant;
|
|
82
|
+
if (existing !== undefined
|
|
83
|
+
&& !matchesRecovery(existing, request, restoredVerificationId)) {
|
|
84
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
85
|
+
}
|
|
86
|
+
let key = existing?.idempotencyKey;
|
|
87
|
+
if (key === undefined) {
|
|
88
|
+
key = await options.resolveOriginalIdempotencyKey?.(request);
|
|
89
|
+
}
|
|
90
|
+
if (key === undefined || key.trim() === '') {
|
|
91
|
+
return pending('APP_STORE_ORIGINAL_PURCHASE_NOT_FOUND');
|
|
92
|
+
}
|
|
93
|
+
const keyedGrant = await findByIdempotency(options.store, options.playerId, key);
|
|
94
|
+
if (keyedGrant !== undefined
|
|
95
|
+
&& !matchesRecovery(keyedGrant, request, restoredVerificationId)) {
|
|
96
|
+
return rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
97
|
+
}
|
|
98
|
+
const priorGrant = existing ?? keyedGrant;
|
|
99
|
+
if (request.deploymentTarget === undefined
|
|
100
|
+
&& typeof priorGrant?.payload.deploymentTarget === 'string') {
|
|
101
|
+
request = { ...request, deploymentTarget: priorGrant.payload.deploymentTarget };
|
|
102
|
+
}
|
|
103
|
+
// A stored grant lets the backend's normal retry path return its original entry.
|
|
104
|
+
const response = await options.purchases.verifyPurchase({
|
|
105
|
+
...request,
|
|
106
|
+
idempotencyKey: key,
|
|
107
|
+
});
|
|
108
|
+
if (!response.verified) {
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
if (response.ledgerEntryId === undefined) {
|
|
112
|
+
return pending('APP_STORE_RECOVERY_LEDGER_UNAVAILABLE');
|
|
113
|
+
}
|
|
114
|
+
const recorded = await options.store.getEntitlementTransaction(response.ledgerEntryId);
|
|
115
|
+
if (recorded === undefined) {
|
|
116
|
+
return pending('APP_STORE_RECOVERY_LEDGER_UNAVAILABLE');
|
|
117
|
+
}
|
|
118
|
+
return matchesRecovery(recorded, request, restoredVerificationId)
|
|
119
|
+
? response
|
|
120
|
+
: rejected('APP_STORE_RECOVERY_IDENTITY_MISMATCH');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return pending('APP_STORE_RECOVERY_DEPENDENCY_UNAVAILABLE');
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function verifyRestoredNonConsumable(input, request, options) {
|
|
129
|
+
if (input.productType !== 'non_consumable'
|
|
130
|
+
|| input.originalTransactionId === undefined
|
|
131
|
+
|| input.originalTransactionId === input.platformTransactionId) {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
const config = options.restoredNonConsumables;
|
|
135
|
+
if (config === undefined) {
|
|
136
|
+
return pending('APP_STORE_RESTORE_VERIFIER_REQUIRED');
|
|
137
|
+
}
|
|
138
|
+
const product = config.catalog.products.find((item) => item.id === request.productId);
|
|
139
|
+
if (product?.type !== 'non_consumable') {
|
|
140
|
+
return rejected('APP_STORE_RECOVERY_PRODUCT_TYPE_MISMATCH');
|
|
141
|
+
}
|
|
142
|
+
const platformProductId = resolveProductPlatformId(product, request.deploymentTarget ?? request.target);
|
|
143
|
+
if (platformProductId === undefined) {
|
|
144
|
+
return rejected('APP_STORE_RECOVERY_PRODUCT_UNAVAILABLE');
|
|
145
|
+
}
|
|
146
|
+
const timeoutMs = config.timeoutMs ?? 10_000;
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
let timeoutHandle;
|
|
149
|
+
const timeout = new Promise((resolve) => {
|
|
150
|
+
timeoutHandle = setTimeout(() => {
|
|
151
|
+
resolve({ status: 'pending', reason: 'APP_STORE_RESTORE_VERIFICATION_TIMEOUT' });
|
|
152
|
+
controller.abort();
|
|
153
|
+
}, timeoutMs);
|
|
154
|
+
});
|
|
155
|
+
// This probe calls only the evidence verifier; it never writes a ledger grant.
|
|
156
|
+
let decision;
|
|
157
|
+
try {
|
|
158
|
+
decision = await Promise.race([
|
|
159
|
+
config.evidenceVerifier.verifyPurchase({
|
|
160
|
+
request: { ...request, idempotencyKey: 'app-store-recovery-evidence-probe' },
|
|
161
|
+
product,
|
|
162
|
+
platformProductId,
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
timeoutMs,
|
|
165
|
+
}),
|
|
166
|
+
timeout,
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (timeoutHandle !== undefined) {
|
|
171
|
+
clearTimeout(timeoutHandle);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (decision.status === 'pending') {
|
|
175
|
+
return pending(decision.reason ?? 'APP_STORE_RESTORE_VERIFICATION_PENDING');
|
|
176
|
+
}
|
|
177
|
+
if (decision.status === 'rejected') {
|
|
178
|
+
return rejected(decision.reason);
|
|
179
|
+
}
|
|
180
|
+
const originalTransactionId = decision.payload?.appStoreOriginalTransactionId;
|
|
181
|
+
const environment = decision.payload?.appStoreEnvironment;
|
|
182
|
+
const bundleId = decision.payload?.appStoreBundleId;
|
|
183
|
+
if (decision.payload?.appStoreTransactionType !== 'Non-Consumable'
|
|
184
|
+
|| originalTransactionId !== input.originalTransactionId
|
|
185
|
+
|| (environment !== 'Production' && environment !== 'Sandbox')
|
|
186
|
+
|| environment !== config.environment
|
|
187
|
+
|| bundleId !== config.bundleId
|
|
188
|
+
|| decision.verificationId !== createAppStoreVerificationId({
|
|
189
|
+
environment,
|
|
190
|
+
bundleId,
|
|
191
|
+
transactionId: originalTransactionId,
|
|
192
|
+
})) {
|
|
193
|
+
return rejected('APP_STORE_RESTORE_EVIDENCE_MISMATCH');
|
|
194
|
+
}
|
|
195
|
+
return decision.verificationId;
|
|
196
|
+
}
|
|
197
|
+
async function findByPlatformEvidence(store, platformTransactionId) {
|
|
198
|
+
const identity = {
|
|
199
|
+
source: 'purchase',
|
|
200
|
+
target: 'ios',
|
|
201
|
+
platformEvidenceId: platformTransactionId,
|
|
202
|
+
};
|
|
203
|
+
if (store.findEntitlementTransactionByPlatformEvidence !== undefined) {
|
|
204
|
+
return store.findEntitlementTransactionByPlatformEvidence(identity);
|
|
205
|
+
}
|
|
206
|
+
return (await store.listEntitlementTransactions()).find((transaction) => {
|
|
207
|
+
return transaction.source === identity.source
|
|
208
|
+
&& transaction.payload.target === identity.target
|
|
209
|
+
&& transaction.payload.platformTransactionId === identity.platformEvidenceId;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async function findByIdempotency(store, playerId, idempotencyKey) {
|
|
213
|
+
const identity = { source: 'purchase', playerId, idempotencyKey };
|
|
214
|
+
if (store.findEntitlementTransactionByIdempotency !== undefined) {
|
|
215
|
+
return store.findEntitlementTransactionByIdempotency(identity);
|
|
216
|
+
}
|
|
217
|
+
return (await store.listEntitlementTransactions()).find((transaction) => {
|
|
218
|
+
return transaction.source === identity.source
|
|
219
|
+
&& transaction.playerId === identity.playerId
|
|
220
|
+
&& transaction.idempotencyKey === identity.idempotencyKey;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async function findByVerificationId(store, evidenceVerificationId) {
|
|
224
|
+
const identity = { source: 'purchase', evidenceVerificationId };
|
|
225
|
+
if (store.findEntitlementTransactionByEvidenceVerificationId !== undefined) {
|
|
226
|
+
return store.findEntitlementTransactionByEvidenceVerificationId(identity);
|
|
227
|
+
}
|
|
228
|
+
return (await store.listEntitlementTransactions()).find((transaction) => {
|
|
229
|
+
return transaction.source === identity.source
|
|
230
|
+
&& (transaction.evidenceVerificationId
|
|
231
|
+
?? transaction.payload.evidenceVerificationId) === identity.evidenceVerificationId;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function matchesRecovery(transaction, request, restoredVerificationId = '') {
|
|
235
|
+
const verificationId = transaction.evidenceVerificationId
|
|
236
|
+
?? transaction.payload.evidenceVerificationId;
|
|
237
|
+
const environment = transaction.payload.appStoreEnvironment;
|
|
238
|
+
const bundleId = transaction.payload.appStoreBundleId;
|
|
239
|
+
const originalTransactionId = transaction.payload.appStoreOriginalTransactionId;
|
|
240
|
+
const grantTransactionId = restoredVerificationId === ''
|
|
241
|
+
? request.platformTransactionId
|
|
242
|
+
: originalTransactionId;
|
|
243
|
+
return transaction.source === 'purchase'
|
|
244
|
+
&& transaction.playerId === request.playerId
|
|
245
|
+
&& transaction.grantId === request.productId
|
|
246
|
+
&& transaction.payload.target === 'ios'
|
|
247
|
+
&& (request.deploymentTarget === undefined
|
|
248
|
+
|| transaction.payload.deploymentTarget === request.deploymentTarget)
|
|
249
|
+
&& (restoredVerificationId === ''
|
|
250
|
+
? transaction.payload.platformTransactionId === request.platformTransactionId
|
|
251
|
+
: transaction.payload.productType === 'non_consumable'
|
|
252
|
+
&& transaction.payload.appStoreOriginalTransactionId
|
|
253
|
+
=== request.originalTransactionId
|
|
254
|
+
&& verificationId === restoredVerificationId)
|
|
255
|
+
&& typeof verificationId === 'string'
|
|
256
|
+
&& isAppStoreVerificationId(verificationId)
|
|
257
|
+
&& (environment === 'Production' || environment === 'Sandbox')
|
|
258
|
+
&& typeof bundleId === 'string'
|
|
259
|
+
&& typeof grantTransactionId === 'string'
|
|
260
|
+
&& verificationId === createAppStoreVerificationId({
|
|
261
|
+
environment,
|
|
262
|
+
bundleId,
|
|
263
|
+
transactionId: grantTransactionId,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function pending(reason) {
|
|
267
|
+
return { verified: false, disposition: 'pending', alreadyProcessed: false, reason };
|
|
268
|
+
}
|
|
269
|
+
function rejected(reason) {
|
|
270
|
+
return { verified: false, disposition: 'rejected', alreadyProcessed: false, reason };
|
|
271
|
+
}
|
|
@@ -84,4 +84,11 @@ export type AppStoreFetch = (url: string, init: {
|
|
|
84
84
|
}) => Promise<AppStoreFetchResponse>;
|
|
85
85
|
export declare function createAppStoreServerApiClient(options: CreateAppStoreServerApiClientOptions): AppStoreServerApiClient;
|
|
86
86
|
export declare function createAppStoreGameServicesEvidenceVerifier(options: CreateAppStoreEvidenceVerifierOptions): GameServicesEvidenceVerifier;
|
|
87
|
+
export declare function createAppStoreVerificationId(input: {
|
|
88
|
+
readonly environment: AppStoreEnvironment;
|
|
89
|
+
readonly bundleId: string;
|
|
90
|
+
readonly transactionId: string;
|
|
91
|
+
}): string;
|
|
92
|
+
/** Identify IDs emitted by this verifier without duplicating its encoding format. */
|
|
93
|
+
export declare function isAppStoreVerificationId(verificationId: string): boolean;
|
|
87
94
|
export declare function assertAppStoreTransactionPayload(input: unknown): AppStoreTransactionPayload;
|
|
@@ -248,13 +248,51 @@ function createVerificationId(transaction) {
|
|
|
248
248
|
const grantTransactionId = transaction.type === 'Non-Consumable'
|
|
249
249
|
? transaction.originalTransactionId
|
|
250
250
|
: transaction.transactionId;
|
|
251
|
+
return createAppStoreVerificationId({
|
|
252
|
+
environment: transaction.environment,
|
|
253
|
+
bundleId: transaction.bundleId,
|
|
254
|
+
transactionId: grantTransactionId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
export function createAppStoreVerificationId(input) {
|
|
251
258
|
return [
|
|
252
259
|
'app-store',
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
input.environment,
|
|
261
|
+
input.bundleId,
|
|
262
|
+
input.transactionId,
|
|
256
263
|
].map(encodeVerificationIdSegment).join(':');
|
|
257
264
|
}
|
|
265
|
+
/** Identify IDs emitted by this verifier without duplicating its encoding format. */
|
|
266
|
+
export function isAppStoreVerificationId(verificationId) {
|
|
267
|
+
const parts = verificationId.split(':');
|
|
268
|
+
if (parts.length !== 8) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
const values = [];
|
|
272
|
+
for (let index = 0; index < parts.length; index += 2) {
|
|
273
|
+
const rawLength = parts[index];
|
|
274
|
+
const encoded = parts[index + 1];
|
|
275
|
+
if (rawLength === undefined || encoded === undefined
|
|
276
|
+
|| !/^(0|[1-9][0-9]*)$/u.test(rawLength)) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const value = decodeURIComponent(encoded);
|
|
281
|
+
if (value.length !== Number(rawLength)
|
|
282
|
+
|| encodeVerificationIdSegment(value) !== `${rawLength}:${encoded}`) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
values.push(value);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return values[0] === 'app-store'
|
|
292
|
+
&& (values[1] === 'Production' || values[1] === 'Sandbox')
|
|
293
|
+
&& typeof values[2] === 'string' && values[2].length > 0
|
|
294
|
+
&& typeof values[3] === 'string' && /^[1-9][0-9]{0,19}$/u.test(values[3]);
|
|
295
|
+
}
|
|
258
296
|
function encodeVerificationIdSegment(value) {
|
|
259
297
|
return `${value.length}:${encodeURIComponent(value)}`;
|
|
260
298
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PurchaseResult } from '@mpgd/platform';
|
|
2
|
+
/**
|
|
3
|
+
* Internal: the Microsoft Store adapter completes the ledger grant before it returns this
|
|
4
|
+
* checkout result, so no further backend verification is requested for it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isAuthoritativeMicrosoftStoreCompletion(target: string, purchase: PurchaseResult): purchase is PurchaseResult & {
|
|
7
|
+
readonly status: 'completed';
|
|
8
|
+
readonly transactionId: string;
|
|
9
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal: the Microsoft Store adapter completes the ledger grant before it returns this
|
|
3
|
+
* checkout result, so no further backend verification is requested for it.
|
|
4
|
+
*/
|
|
5
|
+
export function isAuthoritativeMicrosoftStoreCompletion(target, purchase) {
|
|
6
|
+
return target === 'microsoft-store'
|
|
7
|
+
&& purchase.status === 'completed'
|
|
8
|
+
&& purchase.transactionId !== undefined
|
|
9
|
+
&& purchase.authoritativeGrant?.ledgerEntryId === purchase.transactionId;
|
|
10
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface CreateGameServicesClientInput {
|
|
|
98
98
|
readonly analytics?: AnalyticsSink;
|
|
99
99
|
readonly analyticsSessionId?: string;
|
|
100
100
|
readonly now?: () => string;
|
|
101
|
+
/** Override backend evidence timestamps without backdating analytics events. */
|
|
102
|
+
readonly requestNow?: () => string;
|
|
101
103
|
}
|
|
102
104
|
export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
|
|
103
105
|
}
|