@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.
@@ -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
+ }
@@ -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
- 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
  }