@mpgd/game-services 0.16.0 → 0.17.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/dist/admob-client-reward.d.ts +3 -0
- package/dist/admob-client-reward.js +21 -0
- package/dist/admob-ssv.d.ts +2 -0
- package/dist/admob-ssv.js +2 -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 +14 -16
- 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 +3 -0
- package/dist/index.js +3 -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.js +94 -60
- package/dist/types.js +13 -0
- package/package.json +19 -6
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
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=';
|
|
@@ -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
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createORPCClient } from '@orpc/client';
|
|
2
2
|
import { RPCLink } from '@orpc/client/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
|
+
import { isAdMobClientRewardEvidence } from './admob-client-reward.js';
|
|
5
|
+
import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
|
|
4
6
|
import { observeGameServicesOperation } from './operation-progress.js';
|
|
5
7
|
export const gameServicesBackendEndpoints = {
|
|
6
8
|
verifyPurchase: '/game-services/purchases/verify',
|
|
@@ -54,6 +56,7 @@ export async function resolveGameServicesRequestHeaders(input) {
|
|
|
54
56
|
}
|
|
55
57
|
export function createGameServicesClient(input) {
|
|
56
58
|
const now = input.now ?? (() => new Date().toISOString());
|
|
59
|
+
const requestNow = input.requestNow ?? now;
|
|
57
60
|
const analytics = createAnalyticsReporter({
|
|
58
61
|
target: input.target,
|
|
59
62
|
sessionId: input.analyticsSessionId ?? input.playerId,
|
|
@@ -88,7 +91,7 @@ export function createGameServicesClient(input) {
|
|
|
88
91
|
if (target === 'verse8') {
|
|
89
92
|
const status = purchase.status === 'completed' ? 'rejected' : purchase.status;
|
|
90
93
|
await analytics.track({
|
|
91
|
-
name: 'purchase_rejected',
|
|
94
|
+
name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
92
95
|
properties: {
|
|
93
96
|
productId: purchaseInput.productId,
|
|
94
97
|
status,
|
|
@@ -123,7 +126,7 @@ export function createGameServicesClient(input) {
|
|
|
123
126
|
}
|
|
124
127
|
if (purchase.status !== 'completed' || purchase.transactionId === undefined) {
|
|
125
128
|
await analytics.track({
|
|
126
|
-
name: 'purchase_rejected',
|
|
129
|
+
name: purchase.status === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
127
130
|
properties: {
|
|
128
131
|
productId: purchaseInput.productId,
|
|
129
132
|
status: purchase.status,
|
|
@@ -144,14 +147,14 @@ export function createGameServicesClient(input) {
|
|
|
144
147
|
productId: purchaseInput.productId,
|
|
145
148
|
platformTransactionId: purchase.transactionId,
|
|
146
149
|
idempotencyKey: purchaseInput.idempotencyKey,
|
|
147
|
-
purchasedAt:
|
|
150
|
+
purchasedAt: requestNow(),
|
|
148
151
|
...(purchase.evidence === undefined ? {} : { evidence: purchase.evidence }),
|
|
149
152
|
};
|
|
150
153
|
progress.serverRequested();
|
|
151
154
|
const verification = await input.backend.purchases.verifyPurchase(verificationRequest);
|
|
152
155
|
progress.serverResult(verification.verified);
|
|
153
156
|
const result = {
|
|
154
|
-
status: verification.verified ? 'granted' : 'rejected',
|
|
157
|
+
status: verification.verified ? 'granted' : (verification.disposition ?? 'rejected'),
|
|
155
158
|
purchase,
|
|
156
159
|
verification,
|
|
157
160
|
...(verification.ledgerEntryId === undefined
|
|
@@ -159,7 +162,7 @@ export function createGameServicesClient(input) {
|
|
|
159
162
|
: { ledgerEntryId: verification.ledgerEntryId }),
|
|
160
163
|
};
|
|
161
164
|
await analytics.track({
|
|
162
|
-
name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
|
|
165
|
+
name: verification.verified ? 'purchase_granted' : verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
163
166
|
properties: {
|
|
164
167
|
productId: purchaseInput.productId,
|
|
165
168
|
status: result.status,
|
|
@@ -196,9 +199,10 @@ export function createGameServicesClient(input) {
|
|
|
196
199
|
progress.platformRequested();
|
|
197
200
|
const reward = await input.gateway.ads.showRewarded(rewardInput);
|
|
198
201
|
progress.platformResult(reward.status);
|
|
199
|
-
if (reward.status !== 'completed' || !reward.rewardGranted)
|
|
202
|
+
if ((reward.status !== 'completed' || !reward.rewardGranted)
|
|
203
|
+
&& !isAdMobClientRewardEvidence(reward)) {
|
|
200
204
|
await analytics.track({
|
|
201
|
-
name: 'rewarded_ad_rejected',
|
|
205
|
+
name: reward.status === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
|
|
202
206
|
properties: {
|
|
203
207
|
placementId: rewardInput.placementId,
|
|
204
208
|
status: reward.status,
|
|
@@ -221,20 +225,20 @@ export function createGameServicesClient(input) {
|
|
|
221
225
|
? {}
|
|
222
226
|
: { platformImpressionId: reward.ledgerEntryId }),
|
|
223
227
|
idempotencyKey: rewardInput.idempotencyKey,
|
|
224
|
-
completedAt:
|
|
228
|
+
completedAt: requestNow(),
|
|
225
229
|
...(reward.evidence === undefined ? {} : { evidence: reward.evidence }),
|
|
226
230
|
};
|
|
227
231
|
progress.serverRequested();
|
|
228
232
|
const claim = await input.backend.adRewards.claimAdReward(claimRequest);
|
|
229
233
|
progress.serverResult(claim.granted);
|
|
230
234
|
const result = {
|
|
231
|
-
status: claim.granted ? 'granted' : 'rejected',
|
|
235
|
+
status: claim.granted ? 'granted' : (claim.disposition ?? 'rejected'),
|
|
232
236
|
reward,
|
|
233
237
|
claim,
|
|
234
238
|
...(claim.ledgerEntryId === undefined ? {} : { ledgerEntryId: claim.ledgerEntryId }),
|
|
235
239
|
};
|
|
236
240
|
await analytics.track({
|
|
237
|
-
name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
|
|
241
|
+
name: claim.granted ? 'rewarded_ad_granted' : claim.disposition === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
|
|
238
242
|
properties: {
|
|
239
243
|
placementId: rewardInput.placementId,
|
|
240
244
|
status: result.status,
|
|
@@ -402,12 +406,6 @@ function purchaseRejectionReason(purchase) {
|
|
|
402
406
|
}
|
|
403
407
|
return undefined;
|
|
404
408
|
}
|
|
405
|
-
function isAuthoritativeMicrosoftStoreCompletion(target, purchase) {
|
|
406
|
-
return target === 'microsoft-store'
|
|
407
|
-
&& purchase.status === 'completed'
|
|
408
|
-
&& purchase.transactionId !== undefined
|
|
409
|
-
&& purchase.authoritativeGrant?.ledgerEntryId === purchase.transactionId;
|
|
410
|
-
}
|
|
411
409
|
function isGameServicesCommerceTarget(target) {
|
|
412
410
|
return target === 'microsoft-store'
|
|
413
411
|
|| target === 'android'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GooglePlayProductPurchaseClient } from './google-play-purchase.js';
|
|
2
|
+
export interface GooglePlayPublisherClientOptions {
|
|
3
|
+
/** Supplies an androidpublisher-scoped OAuth access token for each request. */
|
|
4
|
+
readonly getAccessToken: (signal: AbortSignal) => Promise<string> | string;
|
|
5
|
+
readonly fetch?: typeof fetch;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Google Play purchase verification and post-ledger finalization transport.
|
|
9
|
+
* The caller owns OAuth credential rotation and must never expose this client to the game.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient;
|