@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/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
- const verification = await verifyPurchaseWithStore(request, {
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
- const claim = await claimAdRewardWithStore(request, {
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
- return errorResponse(400, error instanceof Error ? error.message : 'BAD_REQUEST');
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: await readRequestJson(request),
377
+ body,
312
378
  });
313
379
  return jsonResponse(response.body, response.status, options.corsHeaders);
314
380
  }
315
381
  catch (error) {
316
- return jsonResponse({ error: error instanceof Error ? error.message : 'BAD_REQUEST' }, 400, options.corsHeaders);
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 Error(`deploymentTarget must match the backend binding for ${request.target}.`);
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 = createPurchaseVerificationPayload(verification);
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
- ...(verification.platformEvidenceId === null
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
- const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
649
- if (existing !== undefined) {
650
- if (!matchesEntitlementRetry(existing, retryIdentity)) {
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: existing.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.payload,
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 (request.platformImpressionId !== undefined) {
707
- payload.platformImpressionId = request.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: 'rejected',
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
- return await Promise.race([
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: 'rejected',
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
- function createPurchaseVerificationPayload(verification) {
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 { platformTransactionId: suppressedPlatformTransactionId, ...payload } = verification.payload;
783
- void suppressedPlatformTransactionId;
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 entitlementEvidenceLocks = new WeakMap();
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
- : withEntitlementEvidenceLockKey(store, lockKey, () => runWithLock(index + 1));
999
+ : withEntitlementLockKey(store, lockKey, () => runWithLock(index + 1));
886
1000
  }
887
1001
  return runWithLock(0);
888
1002
  }
889
- async function withEntitlementEvidenceLockKey(store, lockKey, task) {
890
- const storeLocks = entitlementEvidenceLocks.get(store) ?? new Map();
891
- entitlementEvidenceLocks.set(store, storeLocks);
892
- const previous = storeLocks.get(lockKey) ?? Promise.resolve();
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
- storeLocks.set(lockKey, current);
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 (storeLocks.get(lockKey) === current) {
903
- storeLocks.delete(lockKey);
904
- if (storeLocks.size === 0) {
905
- entitlementEvidenceLocks.delete(store);
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
- async function readRequestJson(request) {
1111
- const text = await request.text();
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;