@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.
@@ -0,0 +1,3 @@
1
+ /** This schema records SDK callback evidence, never an authoritative grant. */
2
+ export declare const admobClientRewardEvidenceSchema = "mpgd.admob.client-reward.v1";
3
+ export declare function isAdMobClientRewardEvidence(value: unknown): boolean;
@@ -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
+ }
@@ -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
- transaction.environment,
254
- transaction.bundleId,
255
- grantTransactionId,
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: now(),
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: now(),
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;