@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
package/dist/server.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { implement } from '@orpc/server';
|
|
2
|
-
import { RPCHandler } from '@orpc/server/fetch';
|
|
1
|
+
import { implement, ORPCError } from '@orpc/server';
|
|
2
|
+
import { BodyLimitHandlerPlugin, RPCHandler } from '@orpc/server/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
4
|
import { resolveAdPlacementPlatformId, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
5
5
|
import { gameServicesBackendEndpoints, } from './client.js';
|
|
6
6
|
import { gameServicesContract } from './contract.js';
|
|
7
7
|
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
|
|
8
8
|
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
|
|
9
|
+
import { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
|
|
10
|
+
export { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
|
|
9
11
|
export class InMemoryGameServicesStore {
|
|
10
12
|
entitlementTransactionsByKey = new Map();
|
|
11
13
|
entitlementTransactionsByEvidence = new Map();
|
|
@@ -142,7 +144,7 @@ export function createGameServicesBackend(input) {
|
|
|
142
144
|
version,
|
|
143
145
|
purchases: {
|
|
144
146
|
async verifyPurchase(request) {
|
|
145
|
-
|
|
147
|
+
return verifyPurchaseWithStore(request, {
|
|
146
148
|
catalog: input.catalog,
|
|
147
149
|
store,
|
|
148
150
|
now,
|
|
@@ -153,47 +155,49 @@ export function createGameServicesBackend(input) {
|
|
|
153
155
|
...(input.purchaseGrantFinalizer === undefined
|
|
154
156
|
? {}
|
|
155
157
|
: { purchaseGrantFinalizer: input.purchaseGrantFinalizer }),
|
|
158
|
+
}, async (verification) => {
|
|
159
|
+
await analytics.track({
|
|
160
|
+
name: verification.verified ? 'purchase_granted'
|
|
161
|
+
: verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
162
|
+
properties: {
|
|
163
|
+
target: request.target,
|
|
164
|
+
playerId: request.playerId,
|
|
165
|
+
productId: request.productId,
|
|
166
|
+
ledgerEntryId: verification.ledgerEntryId,
|
|
167
|
+
alreadyProcessed: verification.alreadyProcessed,
|
|
168
|
+
reason: verification.reason,
|
|
169
|
+
finalizationStatus: verification.finalization?.status,
|
|
170
|
+
finalizationAction: verification.finalization?.action,
|
|
171
|
+
finalizationAlreadyCompleted: verification.finalization?.alreadyCompleted,
|
|
172
|
+
finalizationReason: verification.finalization?.reason,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
156
175
|
});
|
|
157
|
-
await analytics.track({
|
|
158
|
-
name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
|
|
159
|
-
properties: {
|
|
160
|
-
target: request.target,
|
|
161
|
-
playerId: request.playerId,
|
|
162
|
-
productId: request.productId,
|
|
163
|
-
ledgerEntryId: verification.ledgerEntryId,
|
|
164
|
-
alreadyProcessed: verification.alreadyProcessed,
|
|
165
|
-
reason: verification.reason,
|
|
166
|
-
finalizationStatus: verification.finalization?.status,
|
|
167
|
-
finalizationAction: verification.finalization?.action,
|
|
168
|
-
finalizationAlreadyCompleted: verification.finalization?.alreadyCompleted,
|
|
169
|
-
finalizationReason: verification.finalization?.reason,
|
|
170
|
-
},
|
|
171
|
-
});
|
|
172
|
-
return verification;
|
|
173
176
|
},
|
|
174
177
|
},
|
|
175
178
|
adRewards: {
|
|
176
179
|
async claimAdReward(request) {
|
|
177
|
-
|
|
180
|
+
return claimAdRewardWithStore(request, {
|
|
178
181
|
placements: input.placements,
|
|
179
182
|
store,
|
|
180
183
|
now,
|
|
181
184
|
evidenceVerifier,
|
|
182
185
|
evidenceVerificationTimeoutMs,
|
|
183
186
|
deploymentTargetBindings,
|
|
187
|
+
}, async (claim) => {
|
|
188
|
+
await analytics.track({
|
|
189
|
+
name: claim.granted ? 'rewarded_ad_granted'
|
|
190
|
+
: claim.disposition === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
|
|
191
|
+
properties: {
|
|
192
|
+
target: request.target,
|
|
193
|
+
playerId: request.playerId,
|
|
194
|
+
placementId: request.placementId,
|
|
195
|
+
ledgerEntryId: claim.ledgerEntryId,
|
|
196
|
+
alreadyProcessed: claim.alreadyProcessed,
|
|
197
|
+
reason: claim.reason,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
184
200
|
});
|
|
185
|
-
await analytics.track({
|
|
186
|
-
name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
|
|
187
|
-
properties: {
|
|
188
|
-
target: request.target,
|
|
189
|
-
playerId: request.playerId,
|
|
190
|
-
placementId: request.placementId,
|
|
191
|
-
ledgerEntryId: claim.ledgerEntryId,
|
|
192
|
-
alreadyProcessed: claim.alreadyProcessed,
|
|
193
|
-
reason: claim.reason,
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
return claim;
|
|
197
201
|
},
|
|
198
202
|
},
|
|
199
203
|
leaderboard: {
|
|
@@ -231,11 +235,42 @@ export function createGameServicesBackendApiHandler(input) {
|
|
|
231
235
|
return await routeGameServicesRequest(request.endpoint, request.body, backend);
|
|
232
236
|
}
|
|
233
237
|
catch (error) {
|
|
234
|
-
|
|
238
|
+
if (error instanceof GameServicesRequestValidationError) {
|
|
239
|
+
return errorResponse(400, 'INVALID_REQUEST', error.message);
|
|
240
|
+
}
|
|
241
|
+
reportInternalError(input.onInternalError, error);
|
|
242
|
+
return errorResponse(500, 'INTERNAL_ERROR');
|
|
235
243
|
}
|
|
236
244
|
},
|
|
237
245
|
};
|
|
238
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Invokes the diagnostics hook in isolation: a throwing hook, or one that returns a
|
|
249
|
+
* rejecting promise (assignable to the `void`-returning handler type), must never replace
|
|
250
|
+
* the generic `INTERNAL_ERROR` response or escape as an unhandled rejection.
|
|
251
|
+
*/
|
|
252
|
+
function reportInternalError(handler, error) {
|
|
253
|
+
if (handler === undefined) {
|
|
254
|
+
console.error('GameServices backend internal error:', error);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const result = handler(error);
|
|
259
|
+
if (isPromiseLike(result)) {
|
|
260
|
+
result.then(undefined, (hookError) => {
|
|
261
|
+
console.error('GameServices backend onInternalError handler rejected:', hookError);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (hookError) {
|
|
266
|
+
console.error('GameServices backend onInternalError handler threw:', hookError);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function isPromiseLike(value) {
|
|
270
|
+
return (typeof value === 'object'
|
|
271
|
+
&& value !== null
|
|
272
|
+
&& typeof value.then === 'function');
|
|
273
|
+
}
|
|
239
274
|
export function createInProcessGameServicesBackendTransport(handler) {
|
|
240
275
|
return {
|
|
241
276
|
async send(request) {
|
|
@@ -243,29 +278,49 @@ export function createInProcessGameServicesBackendTransport(handler) {
|
|
|
243
278
|
},
|
|
244
279
|
};
|
|
245
280
|
}
|
|
246
|
-
export function createGameServicesRouter(backend) {
|
|
281
|
+
export function createGameServicesRouter(backend, options = {}) {
|
|
247
282
|
const contract = implement(gameServicesContract).$context();
|
|
283
|
+
// oRPC already hides non-ORPCError messages behind INTERNAL_SERVER_ERROR; validation
|
|
284
|
+
// failures are surfaced as BAD_REQUEST so clients get a stable 400 instead of a 500.
|
|
285
|
+
const invoke = async (task) => {
|
|
286
|
+
try {
|
|
287
|
+
return await task();
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error instanceof GameServicesRequestValidationError) {
|
|
291
|
+
throw new ORPCError('BAD_REQUEST', { message: error.message });
|
|
292
|
+
}
|
|
293
|
+
reportInternalError(options.onInternalError, error);
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
248
297
|
return contract.router({
|
|
249
298
|
health: contract.health.handler(() => createHealthResponse(backend.version)),
|
|
250
299
|
commerce: contract.commerce.router({
|
|
251
300
|
verifyPurchase: contract.commerce.verifyPurchase.handler(({ input }) => {
|
|
252
|
-
return backend.purchases.verifyPurchase(input);
|
|
301
|
+
return invoke(() => backend.purchases.verifyPurchase(input));
|
|
253
302
|
}),
|
|
254
303
|
}),
|
|
255
304
|
ads: contract.ads.router({
|
|
256
305
|
claimReward: contract.ads.claimReward.handler(({ input }) => {
|
|
257
|
-
return backend.adRewards.claimAdReward(input);
|
|
306
|
+
return invoke(() => backend.adRewards.claimAdReward(input));
|
|
258
307
|
}),
|
|
259
308
|
}),
|
|
260
309
|
leaderboard: contract.leaderboard.router({
|
|
261
310
|
recordScore: contract.leaderboard.recordScore.handler(({ input }) => {
|
|
262
|
-
return backend.leaderboard.recordScore(input);
|
|
311
|
+
return invoke(() => backend.leaderboard.recordScore(input));
|
|
263
312
|
}),
|
|
264
313
|
}),
|
|
265
314
|
});
|
|
266
315
|
}
|
|
267
316
|
export function createGameServicesRpcFetchHandler(router, options = {}) {
|
|
268
|
-
const rpcHandler = new RPCHandler(router
|
|
317
|
+
const rpcHandler = new RPCHandler(router, {
|
|
318
|
+
plugins: [
|
|
319
|
+
new BodyLimitHandlerPlugin({
|
|
320
|
+
maxBodySize: resolveMaxBodyBytes(options.maxBodyBytes),
|
|
321
|
+
}),
|
|
322
|
+
],
|
|
323
|
+
});
|
|
269
324
|
const prefix = options.prefix ?? '/rpc';
|
|
270
325
|
const healthPath = options.healthPath ?? '/health';
|
|
271
326
|
return async (request) => {
|
|
@@ -290,6 +345,7 @@ export function createGameServicesRpcFetchHandler(router, options = {}) {
|
|
|
290
345
|
}
|
|
291
346
|
export function createGameServicesHttpFetchHandler(handler, options = {}) {
|
|
292
347
|
const healthPath = options.healthPath ?? '/health';
|
|
348
|
+
const maxBodyBytes = resolveMaxBodyBytes(options.maxBodyBytes);
|
|
293
349
|
return async (request) => {
|
|
294
350
|
const requestUrl = new URL(request.url);
|
|
295
351
|
if (requestUrl.pathname === healthPath) {
|
|
@@ -304,19 +360,39 @@ export function createGameServicesHttpFetchHandler(handler, options = {}) {
|
|
|
304
360
|
if (!isGameServicesBackendEndpoint(requestUrl.pathname)) {
|
|
305
361
|
return jsonResponse({ error: 'UNKNOWN_ENDPOINT' }, 404, options.corsHeaders);
|
|
306
362
|
}
|
|
363
|
+
let body;
|
|
364
|
+
try {
|
|
365
|
+
body = await readRequestJson(request, maxBodyBytes);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (error instanceof RequestPayloadTooLargeError) {
|
|
369
|
+
return jsonResponse({ error: 'PAYLOAD_TOO_LARGE' }, 413, options.corsHeaders);
|
|
370
|
+
}
|
|
371
|
+
return jsonResponse({ error: 'INVALID_JSON' }, 400, options.corsHeaders);
|
|
372
|
+
}
|
|
307
373
|
try {
|
|
308
374
|
const response = await handler.handle({
|
|
309
375
|
method: 'POST',
|
|
310
376
|
endpoint: requestUrl.pathname,
|
|
311
|
-
body
|
|
377
|
+
body,
|
|
312
378
|
});
|
|
313
379
|
return jsonResponse(response.body, response.status, options.corsHeaders);
|
|
314
380
|
}
|
|
315
381
|
catch (error) {
|
|
316
|
-
|
|
382
|
+
// The bundled API handler never throws, but a custom handler must not leak its
|
|
383
|
+
// failure details to clients either.
|
|
384
|
+
reportInternalError(undefined, error);
|
|
385
|
+
return jsonResponse({ error: 'INTERNAL_ERROR' }, 500, options.corsHeaders);
|
|
317
386
|
}
|
|
318
387
|
};
|
|
319
388
|
}
|
|
389
|
+
function resolveMaxBodyBytes(maxBodyBytes) {
|
|
390
|
+
const resolved = maxBodyBytes ?? gameServicesRequestLimits.maxBodyBytes;
|
|
391
|
+
if (!Number.isInteger(resolved) || resolved <= 0) {
|
|
392
|
+
throw new Error('maxBodyBytes must be a positive integer.');
|
|
393
|
+
}
|
|
394
|
+
return resolved;
|
|
395
|
+
}
|
|
320
396
|
const gameServicesDeploymentTargetPlatforms = new Set([
|
|
321
397
|
'microsoft-store',
|
|
322
398
|
'android',
|
|
@@ -339,13 +415,13 @@ function bindRequestDeploymentTarget(request, bindings) {
|
|
|
339
415
|
const deploymentTarget = bindings[request.target] ?? request.target;
|
|
340
416
|
if (request.deploymentTarget !== undefined
|
|
341
417
|
&& request.deploymentTarget !== deploymentTarget) {
|
|
342
|
-
throw new
|
|
418
|
+
throw new GameServicesRequestValidationError(`deploymentTarget must match the backend binding for ${request.target}.`);
|
|
343
419
|
}
|
|
344
420
|
return deploymentTarget === request.target || request.deploymentTarget === deploymentTarget
|
|
345
421
|
? request
|
|
346
422
|
: { ...request, deploymentTarget };
|
|
347
423
|
}
|
|
348
|
-
async function verifyPurchaseWithStore(input, context) {
|
|
424
|
+
async function verifyPurchaseWithStore(input, context, onDecision) {
|
|
349
425
|
const request = bindRequestDeploymentTarget(assertVerifyPurchaseRequest(input), context.deploymentTargetBindings);
|
|
350
426
|
const retryIdentity = {
|
|
351
427
|
source: 'purchase',
|
|
@@ -357,6 +433,13 @@ async function verifyPurchaseWithStore(input, context) {
|
|
|
357
433
|
? {}
|
|
358
434
|
: { deploymentTarget: request.deploymentTarget }),
|
|
359
435
|
};
|
|
436
|
+
return withEntitlementRetryLock(retryIdentity, async () => {
|
|
437
|
+
const verification = await verifyPurchaseForRetry(request, retryIdentity, context);
|
|
438
|
+
await onDecision(verification);
|
|
439
|
+
return verification;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
360
443
|
const completeExistingRetry = async (transaction) => {
|
|
361
444
|
if (!matchesEntitlementRetry(transaction, retryIdentity)) {
|
|
362
445
|
return assertVerifyPurchaseResponse({
|
|
@@ -412,12 +495,14 @@ async function verifyPurchaseWithStore(input, context) {
|
|
|
412
495
|
return assertVerifyPurchaseResponse({
|
|
413
496
|
verified: false,
|
|
414
497
|
alreadyProcessed: false,
|
|
498
|
+
disposition: verification.status === 'pending' ? 'pending' : 'rejected',
|
|
415
499
|
reason: verification.status === 'pending'
|
|
416
500
|
? (verification.reason ?? 'EVIDENCE_PENDING')
|
|
417
501
|
: verification.reason,
|
|
418
502
|
});
|
|
419
503
|
}
|
|
420
|
-
const verificationPayload =
|
|
504
|
+
const verificationPayload = createVerificationPayload(verification, 'platformTransactionId');
|
|
505
|
+
const platformTransactionId = resolvePlatformEvidenceId(verification, request.platformTransactionId);
|
|
421
506
|
const grant = await recordEntitlementGrantWithEvidence(context.store, {
|
|
422
507
|
playerId: request.playerId,
|
|
423
508
|
grantId: product.id,
|
|
@@ -435,11 +520,7 @@ async function verifyPurchaseWithStore(input, context) {
|
|
|
435
520
|
productId: request.productId,
|
|
436
521
|
productType: product.type,
|
|
437
522
|
platformProductId,
|
|
438
|
-
...(
|
|
439
|
-
? {}
|
|
440
|
-
: {
|
|
441
|
-
platformTransactionId: verification.platformEvidenceId ?? request.platformTransactionId,
|
|
442
|
-
}),
|
|
523
|
+
...(platformTransactionId === undefined ? {} : { platformTransactionId }),
|
|
443
524
|
purchasedAt: request.purchasedAt,
|
|
444
525
|
evidenceVerificationId: verification.verificationId,
|
|
445
526
|
evidenceVerifiedAt: verification.verifiedAt,
|
|
@@ -633,7 +714,7 @@ function createStoredPurchaseRequest(request, transaction) {
|
|
|
633
714
|
: {}),
|
|
634
715
|
};
|
|
635
716
|
}
|
|
636
|
-
async function claimAdRewardWithStore(input, context) {
|
|
717
|
+
async function claimAdRewardWithStore(input, context, onDecision) {
|
|
637
718
|
const request = bindRequestDeploymentTarget(assertClaimAdRewardRequest(input), context.deploymentTargetBindings);
|
|
638
719
|
const retryIdentity = {
|
|
639
720
|
source: 'ad_reward',
|
|
@@ -645,9 +726,15 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
645
726
|
? {}
|
|
646
727
|
: { deploymentTarget: request.deploymentTarget }),
|
|
647
728
|
};
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
729
|
+
return withEntitlementRetryLock(retryIdentity, async () => {
|
|
730
|
+
const claim = await claimAdRewardForRetry(request, retryIdentity, context);
|
|
731
|
+
await onDecision(claim);
|
|
732
|
+
return claim;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async function claimAdRewardForRetry(request, retryIdentity, context) {
|
|
736
|
+
const completeExistingRetry = (transaction) => {
|
|
737
|
+
if (!matchesEntitlementRetry(transaction, retryIdentity)) {
|
|
651
738
|
return assertClaimAdRewardResponse({
|
|
652
739
|
granted: false,
|
|
653
740
|
alreadyProcessed: false,
|
|
@@ -656,9 +743,13 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
656
743
|
}
|
|
657
744
|
return assertClaimAdRewardResponse({
|
|
658
745
|
granted: true,
|
|
659
|
-
ledgerEntryId:
|
|
746
|
+
ledgerEntryId: transaction.ledgerEntryId,
|
|
660
747
|
alreadyProcessed: true,
|
|
661
748
|
});
|
|
749
|
+
};
|
|
750
|
+
const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
|
|
751
|
+
if (existing !== undefined) {
|
|
752
|
+
return completeExistingRetry(existing);
|
|
662
753
|
}
|
|
663
754
|
const placement = context.placements.placements.find((entry) => entry.id === request.placementId);
|
|
664
755
|
if (placement === undefined || placement.type !== 'rewarded' || placement.reward === undefined) {
|
|
@@ -679,16 +770,28 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
679
770
|
});
|
|
680
771
|
}, context.evidenceVerificationTimeoutMs);
|
|
681
772
|
if (verification.status !== 'verified') {
|
|
773
|
+
// Another backend process can record a matching grant while verification is in flight.
|
|
774
|
+
// Recheck before returning a stale pending or rejected state.
|
|
775
|
+
const racedExisting = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
|
|
776
|
+
if (racedExisting !== undefined) {
|
|
777
|
+
return completeExistingRetry(racedExisting);
|
|
778
|
+
}
|
|
682
779
|
return assertClaimAdRewardResponse({
|
|
683
780
|
granted: false,
|
|
684
781
|
alreadyProcessed: false,
|
|
782
|
+
disposition: verification.status === 'pending' ? 'pending' : 'rejected',
|
|
685
783
|
reason: verification.status === 'pending'
|
|
686
784
|
? (verification.reason ?? 'EVIDENCE_PENDING')
|
|
687
785
|
: verification.reason,
|
|
688
786
|
});
|
|
689
787
|
}
|
|
788
|
+
// The client-reported impression id only becomes part of the globally unique platform
|
|
789
|
+
// evidence identity when the verifier vouches for it (or replaces it with its own
|
|
790
|
+
// authority id). Otherwise an attacker could pre-register another player's impression id
|
|
791
|
+
// and have that player's legitimate claim rejected as EVIDENCE_ALREADY_PROCESSED.
|
|
792
|
+
const platformImpressionId = resolvePlatformEvidenceId(verification, request.platformImpressionId);
|
|
690
793
|
const payload = {
|
|
691
|
-
...verification
|
|
794
|
+
...createVerificationPayload(verification, 'platformImpressionId'),
|
|
692
795
|
target: request.target,
|
|
693
796
|
...(request.deploymentTarget === undefined
|
|
694
797
|
? {}
|
|
@@ -703,8 +806,8 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
703
806
|
if (placement.reward.type === 'currency') {
|
|
704
807
|
payload.currency = placement.reward.currency;
|
|
705
808
|
}
|
|
706
|
-
if (
|
|
707
|
-
payload.platformImpressionId =
|
|
809
|
+
if (platformImpressionId !== undefined) {
|
|
810
|
+
payload.platformImpressionId = platformImpressionId;
|
|
708
811
|
}
|
|
709
812
|
const result = await recordEntitlementGrantWithEvidence(context.store, {
|
|
710
813
|
playerId: request.playerId,
|
|
@@ -739,24 +842,16 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
739
842
|
async function verifyEvidence(verify, timeoutMs) {
|
|
740
843
|
const controller = new AbortController();
|
|
741
844
|
const timeoutDecision = {
|
|
742
|
-
status: '
|
|
845
|
+
status: 'pending',
|
|
743
846
|
reason: 'EVIDENCE_VERIFIER_TIMEOUT',
|
|
744
847
|
};
|
|
745
|
-
let timedOut = false;
|
|
746
848
|
let timeout;
|
|
849
|
+
let decision;
|
|
747
850
|
try {
|
|
748
|
-
|
|
749
|
-
verify(controller.signal)
|
|
750
|
-
.then(assertEvidenceVerificationDecision)
|
|
751
|
-
.catch((error) => {
|
|
752
|
-
if (timedOut) {
|
|
753
|
-
return timeoutDecision;
|
|
754
|
-
}
|
|
755
|
-
throw error;
|
|
756
|
-
}),
|
|
851
|
+
decision = await Promise.race([
|
|
852
|
+
verify(controller.signal),
|
|
757
853
|
new Promise((resolve) => {
|
|
758
854
|
timeout = setTimeout(() => {
|
|
759
|
-
timedOut = true;
|
|
760
855
|
resolve(timeoutDecision);
|
|
761
856
|
controller.abort();
|
|
762
857
|
}, timeoutMs);
|
|
@@ -765,7 +860,7 @@ async function verifyEvidence(verify, timeoutMs) {
|
|
|
765
860
|
}
|
|
766
861
|
catch {
|
|
767
862
|
return {
|
|
768
|
-
status: '
|
|
863
|
+
status: 'pending',
|
|
769
864
|
reason: 'EVIDENCE_VERIFIER_ERROR',
|
|
770
865
|
};
|
|
771
866
|
}
|
|
@@ -774,13 +869,30 @@ async function verifyEvidence(verify, timeoutMs) {
|
|
|
774
869
|
clearTimeout(timeout);
|
|
775
870
|
}
|
|
776
871
|
}
|
|
872
|
+
try {
|
|
873
|
+
return assertEvidenceVerificationDecision(decision);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return { status: 'rejected', reason: 'EVIDENCE_VERIFIER_ERROR' };
|
|
877
|
+
}
|
|
777
878
|
}
|
|
778
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Resolves the platform evidence identity stored in the ledger: the verifier's authority id
|
|
881
|
+
* when it supplies one, the client value when the verifier validated the request without
|
|
882
|
+
* replacing it (`undefined`), and nothing at all when the verifier suppresses it (`null`).
|
|
883
|
+
*/
|
|
884
|
+
function resolvePlatformEvidenceId(verification, clientPlatformEvidenceId) {
|
|
885
|
+
if (verification.platformEvidenceId === null) {
|
|
886
|
+
return undefined;
|
|
887
|
+
}
|
|
888
|
+
return verification.platformEvidenceId ?? clientPlatformEvidenceId;
|
|
889
|
+
}
|
|
890
|
+
function createVerificationPayload(verification, platformEvidenceField) {
|
|
779
891
|
if (verification.platformEvidenceId !== null || verification.payload === undefined) {
|
|
780
892
|
return verification.payload;
|
|
781
893
|
}
|
|
782
|
-
const {
|
|
783
|
-
void
|
|
894
|
+
const { [platformEvidenceField]: suppressedPlatformEvidenceId, ...payload } = verification.payload;
|
|
895
|
+
void suppressedPlatformEvidenceId;
|
|
784
896
|
return payload;
|
|
785
897
|
}
|
|
786
898
|
function assertEvidenceVerificationDecision(decision) {
|
|
@@ -875,34 +987,43 @@ async function recordEntitlementGrantUnchecked(store, grant) {
|
|
|
875
987
|
throw error;
|
|
876
988
|
}
|
|
877
989
|
}
|
|
878
|
-
const
|
|
990
|
+
const entitlementLocks = new WeakMap();
|
|
991
|
+
/** Hosts such as the Worker create a store per request, so retries lock per process instead. */
|
|
992
|
+
const entitlementRetryLockScope = {};
|
|
879
993
|
async function withEntitlementEvidenceLocks(store, lockKeys, task) {
|
|
880
994
|
const orderedLockKeys = [...new Set(lockKeys)].sort((left, right) => left.localeCompare(right));
|
|
881
995
|
async function runWithLock(index) {
|
|
882
996
|
const lockKey = orderedLockKeys[index];
|
|
883
997
|
return lockKey === undefined
|
|
884
998
|
? task()
|
|
885
|
-
:
|
|
999
|
+
: withEntitlementLockKey(store, lockKey, () => runWithLock(index + 1));
|
|
886
1000
|
}
|
|
887
1001
|
return runWithLock(0);
|
|
888
1002
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1003
|
+
/**
|
|
1004
|
+
* Serialize one retry identity in this backend process, from its ledger lookup to its grant
|
|
1005
|
+
* or non-grant decision, so a verifier decision cannot race a same-key grant here.
|
|
1006
|
+
*/
|
|
1007
|
+
async function withEntitlementRetryLock(identity, task) {
|
|
1008
|
+
return withEntitlementLockKey(entitlementRetryLockScope, createEntitlementIdempotencyKey(identity), task);
|
|
1009
|
+
}
|
|
1010
|
+
async function withEntitlementLockKey(scope, lockKey, task) {
|
|
1011
|
+
const scopeLocks = entitlementLocks.get(scope) ?? new Map();
|
|
1012
|
+
entitlementLocks.set(scope, scopeLocks);
|
|
1013
|
+
const previous = scopeLocks.get(lockKey) ?? Promise.resolve();
|
|
893
1014
|
const gate = createPromiseGate();
|
|
894
1015
|
const current = previous.then(() => gate.promise);
|
|
895
|
-
|
|
1016
|
+
scopeLocks.set(lockKey, current);
|
|
896
1017
|
await previous;
|
|
897
1018
|
try {
|
|
898
1019
|
return await task();
|
|
899
1020
|
}
|
|
900
1021
|
finally {
|
|
901
1022
|
gate.release();
|
|
902
|
-
if (
|
|
903
|
-
|
|
904
|
-
if (
|
|
905
|
-
|
|
1023
|
+
if (scopeLocks.get(lockKey) === current) {
|
|
1024
|
+
scopeLocks.delete(lockKey);
|
|
1025
|
+
if (scopeLocks.size === 0) {
|
|
1026
|
+
entitlementLocks.delete(scope);
|
|
906
1027
|
}
|
|
907
1028
|
}
|
|
908
1029
|
}
|
|
@@ -1034,11 +1155,12 @@ function okResponse(body) {
|
|
|
1034
1155
|
body,
|
|
1035
1156
|
};
|
|
1036
1157
|
}
|
|
1037
|
-
function errorResponse(status, error) {
|
|
1158
|
+
function errorResponse(status, error, message) {
|
|
1038
1159
|
return {
|
|
1039
1160
|
status,
|
|
1040
1161
|
body: {
|
|
1041
1162
|
error,
|
|
1163
|
+
...(message === undefined ? {} : { message }),
|
|
1042
1164
|
},
|
|
1043
1165
|
};
|
|
1044
1166
|
}
|
|
@@ -1107,13 +1229,51 @@ function encodeIdSegment(value) {
|
|
|
1107
1229
|
function isGameServicesBackendEndpoint(pathname) {
|
|
1108
1230
|
return Object.values(gameServicesBackendEndpoints).includes(pathname);
|
|
1109
1231
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1232
|
+
class RequestPayloadTooLargeError extends Error {
|
|
1233
|
+
constructor() {
|
|
1234
|
+
super('Request payload exceeds the configured body limit.');
|
|
1235
|
+
this.name = 'RequestPayloadTooLargeError';
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
async function readRequestJson(request, maxBodyBytes) {
|
|
1239
|
+
const text = await readRequestText(request, maxBodyBytes);
|
|
1112
1240
|
if (text.length === 0) {
|
|
1113
1241
|
return {};
|
|
1114
1242
|
}
|
|
1115
1243
|
return JSON.parse(text);
|
|
1116
1244
|
}
|
|
1245
|
+
/** Reads the body incrementally so an oversized payload is rejected before it is buffered. */
|
|
1246
|
+
async function readRequestText(request, maxBodyBytes) {
|
|
1247
|
+
const declaredLength = Number(request.headers.get('content-length') ?? '');
|
|
1248
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
|
1249
|
+
throw new RequestPayloadTooLargeError();
|
|
1250
|
+
}
|
|
1251
|
+
if (request.body === null) {
|
|
1252
|
+
return '';
|
|
1253
|
+
}
|
|
1254
|
+
const reader = request.body.getReader();
|
|
1255
|
+
const chunks = [];
|
|
1256
|
+
let receivedBytes = 0;
|
|
1257
|
+
for (;;) {
|
|
1258
|
+
const { done, value } = await reader.read();
|
|
1259
|
+
if (done) {
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
receivedBytes += value.byteLength;
|
|
1263
|
+
if (receivedBytes > maxBodyBytes) {
|
|
1264
|
+
await reader.cancel();
|
|
1265
|
+
throw new RequestPayloadTooLargeError();
|
|
1266
|
+
}
|
|
1267
|
+
chunks.push(value);
|
|
1268
|
+
}
|
|
1269
|
+
const bytes = new Uint8Array(receivedBytes);
|
|
1270
|
+
let offset = 0;
|
|
1271
|
+
for (const chunk of chunks) {
|
|
1272
|
+
bytes.set(chunk, offset);
|
|
1273
|
+
offset += chunk.byteLength;
|
|
1274
|
+
}
|
|
1275
|
+
return new TextDecoder().decode(bytes);
|
|
1276
|
+
}
|
|
1117
1277
|
function jsonResponse(body, status, corsHeaders) {
|
|
1118
1278
|
const headers = new Headers({
|
|
1119
1279
|
'content-type': 'application/json; charset=utf-8',
|
package/dist/types.d.ts
CHANGED
|
@@ -75,11 +75,23 @@ export interface LeaderboardScoreTransaction extends RecordLeaderboardScoreReque
|
|
|
75
75
|
readonly ledgerEntryId: string;
|
|
76
76
|
readonly recordedAt: string;
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Validates an untrusted purchase verification request. Failures throw
|
|
80
|
+
* `GameServicesRequestValidationError` so transports can answer with a stable 400.
|
|
81
|
+
*/
|
|
78
82
|
export declare function assertVerifyPurchaseRequest(input: VerifyPurchaseRequest): VerifyPurchaseRequest;
|
|
79
83
|
export declare function assertVerifyPurchaseResponse(input: VerifyPurchaseResponse): VerifyPurchaseResponse;
|
|
80
84
|
export declare function assertPurchaseGrantFinalization(input: PurchaseGrantFinalization): PurchaseGrantFinalization;
|
|
85
|
+
/**
|
|
86
|
+
* Validates an untrusted ad reward claim. Failures throw
|
|
87
|
+
* `GameServicesRequestValidationError` so transports can answer with a stable 400.
|
|
88
|
+
*/
|
|
81
89
|
export declare function assertClaimAdRewardRequest(input: ClaimAdRewardRequest): ClaimAdRewardRequest;
|
|
82
90
|
export declare function assertClaimAdRewardResponse(input: ClaimAdRewardResponse): ClaimAdRewardResponse;
|
|
91
|
+
/**
|
|
92
|
+
* Validates an untrusted leaderboard score submission. Failures throw
|
|
93
|
+
* `GameServicesRequestValidationError` so transports can answer with a stable 400.
|
|
94
|
+
*/
|
|
83
95
|
export declare function assertRecordLeaderboardScoreRequest(input: RecordLeaderboardScoreRequest): RecordLeaderboardScoreRequest;
|
|
84
96
|
export declare function assertRecordLeaderboardScoreResponse(input: RecordLeaderboardScoreResponse): RecordLeaderboardScoreResponse;
|
|
85
97
|
export declare function assertEntitlementLedgerGrant(input: EntitlementLedgerGrant): EntitlementLedgerGrant;
|