@mpgd/game-services 0.17.0 → 0.17.2

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.
@@ -1,3 +1,27 @@
1
+ import type { RewardedAdResult } from '@mpgd/platform';
1
2
  /** This schema records SDK callback evidence, never an authoritative grant. */
2
3
  export declare const admobClientRewardEvidenceSchema = "mpgd.admob.client-reward.v1";
3
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;
@@ -19,3 +19,66 @@ export function isAdMobClientRewardEvidence(value) {
19
19
  && typeof payload.adUnitId === 'string'
20
20
  && payload.adUnitId.length > 0;
21
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
+ }
package/dist/admob-ssv.js CHANGED
@@ -216,10 +216,13 @@ async function verifyAdMobSsvReward(context, input) {
216
216
  if (callback.timestampMs < nowMs - context.maximumCallbackAgeMs) {
217
217
  return rejected('ADMOB_SSV_CALLBACK_EXPIRED');
218
218
  }
219
+ // SSV never validates the client-reported `platformImpressionId`, so the signed
220
+ // transaction becomes the platform evidence identity instead of the client value.
219
221
  return {
220
222
  status: 'verified',
221
223
  verificationId: `admob:ssv:${callback.transactionId}`,
222
224
  verifiedAt: new Date(callback.timestampMs).toISOString(),
225
+ platformEvidenceId: callback.transactionId,
223
226
  payload: {
224
227
  admobSsvTransactionId: callback.transactionId,
225
228
  admobSsvAdNetwork: callback.adNetwork,
@@ -1,6 +1,6 @@
1
1
  import { type AppsInTossPurchaseAuthority, type AppsInTossPurchaseAuthorityInput, type AppsInTossPurchaseAuthorityResult, type AppsInTossRewardAuthority, type AppsInTossRewardAuthorityInput, type AppsInTossRewardAuthorityResult, type CreateAppsInTossProductionEvidenceVerifierInput } from './apps-in-toss-evidence-verification.js';
2
2
  import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
3
- export declare const appsInTossProductionEvidenceConformanceScenarios: readonly ['callback-only-fail-closed', 'purchase-product-grant-callback', 'purchase-authority-retry', 'purchase-pending-order-restore', 'purchase-authority-matching', 'purchase-timestamp-normalization', 'reward-authority-retry-and-replay', 'reward-timestamp-validation', 'authority-errors-and-reward-matching'];
3
+ export declare const appsInTossProductionEvidenceConformanceScenarios: readonly ['callback-only-fail-closed', 'purchase-product-grant-callback', 'purchase-authority-retry', 'purchase-pending-order-restore', 'purchase-authority-matching', 'purchase-subscription-unsupported', 'purchase-timestamp-normalization', 'reward-authority-retry-and-replay', 'reward-timestamp-validation', 'authority-errors-and-reward-matching'];
4
4
  export type AppsInTossProductionEvidenceConformanceScenario = (typeof appsInTossProductionEvidenceConformanceScenarios)[number];
5
5
  export type CreateAppsInTossProductionEvidenceVerifier = (input?: CreateAppsInTossProductionEvidenceVerifierInput) => GameServicesEvidenceVerifier;
6
6
  export interface RunAppsInTossProductionEvidenceConformanceInput {
@@ -6,6 +6,7 @@ export const appsInTossProductionEvidenceConformanceScenarios = [
6
6
  'purchase-authority-retry',
7
7
  'purchase-pending-order-restore',
8
8
  'purchase-authority-matching',
9
+ 'purchase-subscription-unsupported',
9
10
  'purchase-timestamp-normalization',
10
11
  'reward-authority-retry-and-replay',
11
12
  'reward-timestamp-validation',
@@ -26,6 +27,17 @@ const catalog = {
26
27
  ait: 'ait.conformance.coins',
27
28
  },
28
29
  },
30
+ {
31
+ id: 'CONFORMANCE_PASS',
32
+ type: 'subscription',
33
+ grant: {
34
+ type: 'entitlement',
35
+ entitlement: 'conformance_pass',
36
+ },
37
+ platformProductIds: {
38
+ ait: 'ait.conformance.pass',
39
+ },
40
+ },
29
41
  ],
30
42
  };
31
43
  const placements = {
@@ -54,6 +66,7 @@ const scenarioRunners = {
54
66
  'purchase-authority-retry': runPurchaseAuthorityRetryScenario,
55
67
  'purchase-pending-order-restore': runPurchasePendingOrderRestoreScenario,
56
68
  'purchase-authority-matching': runPurchaseAuthorityMatchingScenario,
69
+ 'purchase-subscription-unsupported': runPurchaseSubscriptionUnsupportedScenario,
57
70
  'purchase-timestamp-normalization': runPurchaseTimestampNormalizationScenario,
58
71
  'reward-authority-retry-and-replay': runRewardAuthorityRetryAndReplayScenario,
59
72
  'reward-timestamp-validation': runRewardTimestampValidationScenario,
@@ -309,6 +322,30 @@ async function runPurchaseAuthorityMatchingScenario(createVerifier, now) {
309
322
  assertEqual(progress.reason, 'AIT_PURCHASE_ORDER_ORDER_IN_PROGRESS', 'in-progress purchase');
310
323
  await assertEntitlementCount(context.store, 0, 'purchase mismatches and status');
311
324
  }
325
+ async function runPurchaseSubscriptionUnsupportedScenario(createVerifier, now) {
326
+ const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
327
+ fixture.enqueuePurchaseResult(resolvedOrder({
328
+ orderId: 'ait-order-subscription',
329
+ sku: 'ait.conformance.pass',
330
+ }));
331
+ const context = createScenarioContext(createVerifier, now, {
332
+ purchaseAuthority: fixture.purchaseAuthority,
333
+ });
334
+ const subscription = await context.backend.purchases.verifyPurchase(purchaseRequest({
335
+ productId: 'CONFORMANCE_PASS',
336
+ platformTransactionId: 'ait-order-subscription',
337
+ idempotencyKey: 'purchase-subscription',
338
+ evidence: createAppsInTossPurchaseCallbackEvidence({
339
+ orderId: 'ait-order-subscription',
340
+ platformSku: 'ait.conformance.pass',
341
+ source: 'process-product-grant',
342
+ }),
343
+ }));
344
+ assertEqual(subscription.verified, false, 'subscription purchase must not grant');
345
+ assertEqual(subscription.reason, 'APPS_IN_TOSS_SUBSCRIPTION_UNSUPPORTED', 'subscription purchase must be rejected explicitly');
346
+ assertEqual(fixture.purchaseInputs.length, 0, 'unsupported subscriptions must fail before the purchase authority is called');
347
+ await assertEntitlementCount(context.store, 0, 'unsupported subscription purchase');
348
+ }
312
349
  async function runPurchaseTimestampNormalizationScenario(createVerifier, now) {
313
350
  const fixture = createAppsInTossProductionEvidenceAuthorityFixture();
314
351
  fixture.enqueuePurchaseResult(resolvedOrder({
@@ -150,6 +150,11 @@ async function verifyAppsInTossPurchase(authority, input) {
150
150
  if (request.target !== 'ait') {
151
151
  return rejected('AIT_PURCHASE_TARGET_REQUIRED');
152
152
  }
153
+ // One-time order status cannot express renewal, expiry, or revocation. Reject
154
+ // explicitly instead of relying on the adapter hiding SUBSCRIPTION products.
155
+ if (input.product.type === 'subscription') {
156
+ return rejected('APPS_IN_TOSS_SUBSCRIPTION_UNSUPPORTED');
157
+ }
153
158
  if (request.evidence?.schema !== appsInTossPurchaseCallbackEvidenceSchema) {
154
159
  return rejected('AIT_PURCHASE_EVIDENCE_SCHEMA_INVALID');
155
160
  }
package/dist/client.js CHANGED
@@ -1,8 +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
4
  import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
5
+ import { isClientRewardEvidence, resolveRewardPlatformImpressionId, } from './admob-client-reward.js';
6
6
  import { observeGameServicesOperation } from './operation-progress.js';
7
7
  export const gameServicesBackendEndpoints = {
8
8
  verifyPurchase: '/game-services/purchases/verify',
@@ -200,7 +200,7 @@ export function createGameServicesClient(input) {
200
200
  const reward = await input.gateway.ads.showRewarded(rewardInput);
201
201
  progress.platformResult(reward.status);
202
202
  if ((reward.status !== 'completed' || !reward.rewardGranted)
203
- && !isAdMobClientRewardEvidence(reward)) {
203
+ && !isClientRewardEvidence(reward)) {
204
204
  await analytics.track({
205
205
  name: reward.status === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
206
206
  properties: {
@@ -214,6 +214,7 @@ export function createGameServicesClient(input) {
214
214
  reward,
215
215
  };
216
216
  }
217
+ const platformImpressionId = resolveRewardPlatformImpressionId(reward);
217
218
  const claimRequest = {
218
219
  target,
219
220
  ...(input.deploymentTarget === undefined || input.deploymentTarget === target
@@ -221,9 +222,9 @@ export function createGameServicesClient(input) {
221
222
  : { deploymentTarget: input.deploymentTarget }),
222
223
  playerId: input.playerId,
223
224
  placementId: rewardInput.placementId,
224
- ...(reward.ledgerEntryId === undefined
225
+ ...(platformImpressionId === undefined
225
226
  ? {}
226
- : { platformImpressionId: reward.ledgerEntryId }),
227
+ : { platformImpressionId }),
227
228
  idempotencyKey: rewardInput.idempotencyKey,
228
229
  completedAt: requestNow(),
229
230
  ...(reward.evidence === undefined ? {} : { evidence: reward.evidence }),
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './app-store-recovery.js';
4
4
  export * from './apps-in-toss-evidence-verification.js';
5
5
  export * from './apps-in-toss-partner-api.js';
6
6
  export * from './client.js';
7
+ export * from './admob-client-reward.js';
7
8
  export * from './contract.js';
8
9
  export * from './evidence-verification.js';
9
10
  export * from './guest-session.js';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export * from './app-store-recovery.js';
4
4
  export * from './apps-in-toss-evidence-verification.js';
5
5
  export * from './apps-in-toss-partner-api.js';
6
6
  export * from './client.js';
7
+ export * from './admob-client-reward.js';
7
8
  export * from './contract.js';
8
9
  export * from './evidence-verification.js';
9
10
  export * from './guest-session.js';
@@ -1,6 +1,6 @@
1
- import { isAdMobClientRewardEvidence } from './admob-client-reward.js';
2
1
  import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
3
2
  import { createGameServicesClient } from './client.js';
3
+ import { isClientRewardEvidence, resolveRewardPlatformImpressionId, } from './admob-client-reward.js';
4
4
  import { forwardGameServicesProgress, observeGameServicesOperation, } from './operation-progress.js';
5
5
  /**
6
6
  * Client-side recovery coordinates UI callbacks and authoritative backend calls.
@@ -620,11 +620,11 @@ function assertRewardRequest(record, input) {
620
620
  const request = record.request;
621
621
  if (request === undefined || record.platform === undefined
622
622
  || ((record.platform.status !== 'completed' || !record.platform.rewardGranted)
623
- && !isAdMobClientRewardEvidence(record.platform))
623
+ && !isClientRewardEvidence(record.platform))
624
624
  || request.playerId !== record.playerId || request.playerId !== input.playerId
625
625
  || request.target !== record.target || request.placementId !== record.input.placementId
626
626
  || request.idempotencyKey !== record.input.idempotencyKey
627
- || request.platformImpressionId !== record.platform.ledgerEntryId
627
+ || request.platformImpressionId !== resolveRewardPlatformImpressionId(record.platform)
628
628
  || request.deploymentTarget !== (input.deploymentTarget === input.target
629
629
  ? undefined : input.deploymentTarget)) {
630
630
  throw new Error('Journaled ad claim is not bound to its platform result.');
package/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ import { type GameServicesBackendApi, type GameServicesBackendTransport, type Ga
4
4
  import { type GameServicesHealthResponse } from './contract.js';
5
5
  import { type GameServicesEvidenceVerifier, type GameServicesPurchaseGrantFinalizer } from './evidence-verification.js';
6
6
  import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerResult, type GameServicesAdRewardTarget, type GameServicesStoreTarget, type LeaderboardScoreTransaction, type ProductGrantTransaction, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types.js';
7
+ export { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
7
8
  type CorsHeaders = Record<string, string>;
8
9
  export interface CreateGameServicesBackendInput {
9
10
  readonly catalog: ProductCatalog;
@@ -19,14 +20,26 @@ export interface CreateGameServicesBackendInput {
19
20
  readonly evidenceVerificationTimeoutMs?: number;
20
21
  readonly purchaseGrantFinalizationTimeoutMs?: number;
21
22
  readonly purchaseGrantFinalizer?: GameServicesPurchaseGrantFinalizer;
23
+ /**
24
+ * Receives unexpected (non-validation) failures such as store errors before the
25
+ * transport answers with the generic `INTERNAL_ERROR` code. Defaults to `console.error`.
26
+ * The hook is diagnostics only: if it throws or returns a rejecting promise, the failure
27
+ * is logged and the generic error response is still returned.
28
+ */
29
+ readonly onInternalError?: GameServicesInternalErrorHandler;
22
30
  }
31
+ export type GameServicesInternalErrorHandler = (error: unknown) => void;
23
32
  export type GameServicesDeploymentTargetBindings = Readonly<Partial<Record<GameServicesAdRewardTarget | GameServicesStoreTarget, string>>>;
24
33
  export interface GameServicesBackendApiHandler {
25
34
  readonly version?: string;
26
35
  handle(request: GameServicesBackendTransportRequest): Promise<GameServicesBackendTransportResponse>;
27
36
  }
37
+ /** Stable error codes emitted by the backend API handler and the HTTP fetch handler. */
38
+ export type GameServicesBackendErrorCode = 'INVALID_REQUEST' | 'INVALID_JSON' | 'PAYLOAD_TOO_LARGE' | 'METHOD_NOT_ALLOWED' | 'UNKNOWN_ENDPOINT' | 'INTERNAL_ERROR';
28
39
  export interface GameServicesBackendErrorResponse {
29
- readonly error: string;
40
+ readonly error: GameServicesBackendErrorCode;
41
+ /** Present only for `INVALID_REQUEST`; never carries internal failure details. */
42
+ readonly message?: string;
30
43
  }
31
44
  export interface GameServicesStore {
32
45
  recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
@@ -57,6 +70,11 @@ export interface CreateGameServicesFetchHandlerOptions {
57
70
  readonly corsHeaders?: CorsHeaders;
58
71
  readonly healthPath?: string;
59
72
  readonly version?: string;
73
+ /** Request bodies above this byte size are rejected with 413. */
74
+ readonly maxBodyBytes?: number;
75
+ }
76
+ export interface CreateGameServicesRouterOptions {
77
+ readonly onInternalError?: GameServicesInternalErrorHandler;
60
78
  }
61
79
  export interface CreateGameServicesRpcFetchHandlerOptions extends CreateGameServicesFetchHandlerOptions {
62
80
  readonly prefix?: string;
@@ -99,7 +117,7 @@ export declare function createInMemoryGameServicesStore(): InMemoryGameServicesS
99
117
  export declare function createGameServicesBackend(input: CreateGameServicesBackendInput): GameServicesBackendApi;
100
118
  export declare function createGameServicesBackendApiHandler(input: CreateGameServicesBackendInput): GameServicesBackendApiHandler;
101
119
  export declare function createInProcessGameServicesBackendTransport(handler: GameServicesBackendApiHandler): GameServicesBackendTransport;
102
- export declare function createGameServicesRouter(backend: GameServicesBackendApi): {
120
+ export declare function createGameServicesRouter(backend: GameServicesBackendApi, options?: CreateGameServicesRouterOptions): {
103
121
  health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
104
122
  commerce: {
105
123
  verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
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();
@@ -233,11 +235,42 @@ export function createGameServicesBackendApiHandler(input) {
233
235
  return await routeGameServicesRequest(request.endpoint, request.body, backend);
234
236
  }
235
237
  catch (error) {
236
- 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');
237
243
  }
238
244
  },
239
245
  };
240
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
+ }
241
274
  export function createInProcessGameServicesBackendTransport(handler) {
242
275
  return {
243
276
  async send(request) {
@@ -245,29 +278,49 @@ export function createInProcessGameServicesBackendTransport(handler) {
245
278
  },
246
279
  };
247
280
  }
248
- export function createGameServicesRouter(backend) {
281
+ export function createGameServicesRouter(backend, options = {}) {
249
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
+ };
250
297
  return contract.router({
251
298
  health: contract.health.handler(() => createHealthResponse(backend.version)),
252
299
  commerce: contract.commerce.router({
253
300
  verifyPurchase: contract.commerce.verifyPurchase.handler(({ input }) => {
254
- return backend.purchases.verifyPurchase(input);
301
+ return invoke(() => backend.purchases.verifyPurchase(input));
255
302
  }),
256
303
  }),
257
304
  ads: contract.ads.router({
258
305
  claimReward: contract.ads.claimReward.handler(({ input }) => {
259
- return backend.adRewards.claimAdReward(input);
306
+ return invoke(() => backend.adRewards.claimAdReward(input));
260
307
  }),
261
308
  }),
262
309
  leaderboard: contract.leaderboard.router({
263
310
  recordScore: contract.leaderboard.recordScore.handler(({ input }) => {
264
- return backend.leaderboard.recordScore(input);
311
+ return invoke(() => backend.leaderboard.recordScore(input));
265
312
  }),
266
313
  }),
267
314
  });
268
315
  }
269
316
  export function createGameServicesRpcFetchHandler(router, options = {}) {
270
- const rpcHandler = new RPCHandler(router);
317
+ const rpcHandler = new RPCHandler(router, {
318
+ plugins: [
319
+ new BodyLimitHandlerPlugin({
320
+ maxBodySize: resolveMaxBodyBytes(options.maxBodyBytes),
321
+ }),
322
+ ],
323
+ });
271
324
  const prefix = options.prefix ?? '/rpc';
272
325
  const healthPath = options.healthPath ?? '/health';
273
326
  return async (request) => {
@@ -292,6 +345,7 @@ export function createGameServicesRpcFetchHandler(router, options = {}) {
292
345
  }
293
346
  export function createGameServicesHttpFetchHandler(handler, options = {}) {
294
347
  const healthPath = options.healthPath ?? '/health';
348
+ const maxBodyBytes = resolveMaxBodyBytes(options.maxBodyBytes);
295
349
  return async (request) => {
296
350
  const requestUrl = new URL(request.url);
297
351
  if (requestUrl.pathname === healthPath) {
@@ -306,19 +360,39 @@ export function createGameServicesHttpFetchHandler(handler, options = {}) {
306
360
  if (!isGameServicesBackendEndpoint(requestUrl.pathname)) {
307
361
  return jsonResponse({ error: 'UNKNOWN_ENDPOINT' }, 404, options.corsHeaders);
308
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
+ }
309
373
  try {
310
374
  const response = await handler.handle({
311
375
  method: 'POST',
312
376
  endpoint: requestUrl.pathname,
313
- body: await readRequestJson(request),
377
+ body,
314
378
  });
315
379
  return jsonResponse(response.body, response.status, options.corsHeaders);
316
380
  }
317
381
  catch (error) {
318
- 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);
319
386
  }
320
387
  };
321
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
+ }
322
396
  const gameServicesDeploymentTargetPlatforms = new Set([
323
397
  'microsoft-store',
324
398
  'android',
@@ -341,7 +415,7 @@ function bindRequestDeploymentTarget(request, bindings) {
341
415
  const deploymentTarget = bindings[request.target] ?? request.target;
342
416
  if (request.deploymentTarget !== undefined
343
417
  && request.deploymentTarget !== deploymentTarget) {
344
- 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}.`);
345
419
  }
346
420
  return deploymentTarget === request.target || request.deploymentTarget === deploymentTarget
347
421
  ? request
@@ -427,7 +501,8 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
427
501
  : verification.reason,
428
502
  });
429
503
  }
430
- const verificationPayload = createPurchaseVerificationPayload(verification);
504
+ const verificationPayload = createVerificationPayload(verification, 'platformTransactionId');
505
+ const platformTransactionId = resolvePlatformEvidenceId(verification, request.platformTransactionId);
431
506
  const grant = await recordEntitlementGrantWithEvidence(context.store, {
432
507
  playerId: request.playerId,
433
508
  grantId: product.id,
@@ -445,11 +520,7 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
445
520
  productId: request.productId,
446
521
  productType: product.type,
447
522
  platformProductId,
448
- ...(verification.platformEvidenceId === null
449
- ? {}
450
- : {
451
- platformTransactionId: verification.platformEvidenceId ?? request.platformTransactionId,
452
- }),
523
+ ...(platformTransactionId === undefined ? {} : { platformTransactionId }),
453
524
  purchasedAt: request.purchasedAt,
454
525
  evidenceVerificationId: verification.verificationId,
455
526
  evidenceVerifiedAt: verification.verifiedAt,
@@ -714,8 +785,13 @@ async function claimAdRewardForRetry(request, retryIdentity, context) {
714
785
  : verification.reason,
715
786
  });
716
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);
717
793
  const payload = {
718
- ...verification.payload,
794
+ ...createVerificationPayload(verification, 'platformImpressionId'),
719
795
  target: request.target,
720
796
  ...(request.deploymentTarget === undefined
721
797
  ? {}
@@ -730,8 +806,8 @@ async function claimAdRewardForRetry(request, retryIdentity, context) {
730
806
  if (placement.reward.type === 'currency') {
731
807
  payload.currency = placement.reward.currency;
732
808
  }
733
- if (request.platformImpressionId !== undefined) {
734
- payload.platformImpressionId = request.platformImpressionId;
809
+ if (platformImpressionId !== undefined) {
810
+ payload.platformImpressionId = platformImpressionId;
735
811
  }
736
812
  const result = await recordEntitlementGrantWithEvidence(context.store, {
737
813
  playerId: request.playerId,
@@ -800,12 +876,23 @@ async function verifyEvidence(verify, timeoutMs) {
800
876
  return { status: 'rejected', reason: 'EVIDENCE_VERIFIER_ERROR' };
801
877
  }
802
878
  }
803
- 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) {
804
891
  if (verification.platformEvidenceId !== null || verification.payload === undefined) {
805
892
  return verification.payload;
806
893
  }
807
- const { platformTransactionId: suppressedPlatformTransactionId, ...payload } = verification.payload;
808
- void suppressedPlatformTransactionId;
894
+ const { [platformEvidenceField]: suppressedPlatformEvidenceId, ...payload } = verification.payload;
895
+ void suppressedPlatformEvidenceId;
809
896
  return payload;
810
897
  }
811
898
  function assertEvidenceVerificationDecision(decision) {
@@ -1068,11 +1155,12 @@ function okResponse(body) {
1068
1155
  body,
1069
1156
  };
1070
1157
  }
1071
- function errorResponse(status, error) {
1158
+ function errorResponse(status, error, message) {
1072
1159
  return {
1073
1160
  status,
1074
1161
  body: {
1075
1162
  error,
1163
+ ...(message === undefined ? {} : { message }),
1076
1164
  },
1077
1165
  };
1078
1166
  }
@@ -1141,13 +1229,51 @@ function encodeIdSegment(value) {
1141
1229
  function isGameServicesBackendEndpoint(pathname) {
1142
1230
  return Object.values(gameServicesBackendEndpoints).includes(pathname);
1143
1231
  }
1144
- async function readRequestJson(request) {
1145
- 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);
1146
1240
  if (text.length === 0) {
1147
1241
  return {};
1148
1242
  }
1149
1243
  return JSON.parse(text);
1150
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
+ }
1151
1277
  function jsonResponse(body, status, corsHeaders) {
1152
1278
  const headers = new Headers({
1153
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;
package/dist/types.js CHANGED
@@ -1,14 +1,21 @@
1
+ import { assertOwnEnumerablePropertyLimit, gameServicesRequestLimits, toGameServicesRequestValidationError, } from './validation.js';
2
+ /**
3
+ * Validates an untrusted purchase verification request. Failures throw
4
+ * `GameServicesRequestValidationError` so transports can answer with a stable 400.
5
+ */
1
6
  export function assertVerifyPurchaseRequest(input) {
2
- assertRecord(input, 'VerifyPurchaseRequest');
3
- assertStoreTarget(input.target);
4
- assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
5
- assertNonEmptyString(input.playerId, 'playerId');
6
- assertNonEmptyString(input.productId, 'productId');
7
- assertNonEmptyString(input.platformTransactionId, 'platformTransactionId');
8
- assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
9
- assertNonEmptyString(input.purchasedAt, 'purchasedAt');
10
- assertOptionalEvidenceEnvelope(input.evidence);
11
- return input;
7
+ return validateRequest(() => {
8
+ assertRecord(input, 'VerifyPurchaseRequest');
9
+ assertStoreTarget(input.target);
10
+ assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
11
+ assertRequestString(input.playerId, 'playerId');
12
+ assertRequestString(input.productId, 'productId');
13
+ assertRequestString(input.platformTransactionId, 'platformTransactionId');
14
+ assertRequestString(input.idempotencyKey, 'idempotencyKey');
15
+ assertRequestString(input.purchasedAt, 'purchasedAt');
16
+ assertOptionalEvidenceEnvelope(input.evidence);
17
+ return input;
18
+ });
12
19
  }
13
20
  export function assertVerifyPurchaseResponse(input) {
14
21
  assertRecord(input, 'VerifyPurchaseResponse');
@@ -43,17 +50,23 @@ export function assertPurchaseGrantFinalization(input) {
43
50
  }
44
51
  return input;
45
52
  }
53
+ /**
54
+ * Validates an untrusted ad reward claim. Failures throw
55
+ * `GameServicesRequestValidationError` so transports can answer with a stable 400.
56
+ */
46
57
  export function assertClaimAdRewardRequest(input) {
47
- assertRecord(input, 'ClaimAdRewardRequest');
48
- assertAdRewardTarget(input.target);
49
- assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
50
- assertNonEmptyString(input.playerId, 'playerId');
51
- assertNonEmptyString(input.placementId, 'placementId');
52
- assertOptionalNonEmptyString(input.platformImpressionId, 'platformImpressionId');
53
- assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
54
- assertNonEmptyString(input.completedAt, 'completedAt');
55
- assertOptionalEvidenceEnvelope(input.evidence);
56
- return input;
58
+ return validateRequest(() => {
59
+ assertRecord(input, 'ClaimAdRewardRequest');
60
+ assertAdRewardTarget(input.target);
61
+ assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
62
+ assertRequestString(input.playerId, 'playerId');
63
+ assertRequestString(input.placementId, 'placementId');
64
+ assertOptionalRequestString(input.platformImpressionId, 'platformImpressionId');
65
+ assertRequestString(input.idempotencyKey, 'idempotencyKey');
66
+ assertRequestString(input.completedAt, 'completedAt');
67
+ assertOptionalEvidenceEnvelope(input.evidence);
68
+ return input;
69
+ });
57
70
  }
58
71
  export function assertClaimAdRewardResponse(input) {
59
72
  assertRecord(input, 'ClaimAdRewardResponse');
@@ -64,15 +77,22 @@ export function assertClaimAdRewardResponse(input) {
64
77
  assertOptionalNonEmptyString(input.reason, 'reason');
65
78
  return input;
66
79
  }
80
+ /**
81
+ * Validates an untrusted leaderboard score submission. Failures throw
82
+ * `GameServicesRequestValidationError` so transports can answer with a stable 400.
83
+ */
67
84
  export function assertRecordLeaderboardScoreRequest(input) {
85
+ return validateRequest(() => validateRecordLeaderboardScoreRequest(input));
86
+ }
87
+ function validateRecordLeaderboardScoreRequest(input) {
68
88
  assertRecord(input, 'RecordLeaderboardScoreRequest');
69
89
  assertLeaderboardTarget(input.target);
70
- assertNonEmptyString(input.playerId, 'playerId');
71
- assertNonEmptyString(input.leaderboardId, 'leaderboardId');
90
+ assertRequestString(input.playerId, 'playerId');
91
+ assertRequestString(input.leaderboardId, 'leaderboardId');
72
92
  assertFiniteNumber(input.score, 'score');
73
- assertNonEmptyString(input.runId, 'runId');
74
- assertNonEmptyString(input.submittedAt, 'submittedAt');
75
- assertOptionalNonEmptyString(input.platformSubmissionId, 'platformSubmissionId');
93
+ assertRequestString(input.runId, 'runId');
94
+ assertRequestString(input.submittedAt, 'submittedAt');
95
+ assertOptionalRequestString(input.platformSubmissionId, 'platformSubmissionId');
76
96
  return input;
77
97
  }
78
98
  export function assertRecordLeaderboardScoreResponse(input) {
@@ -119,7 +139,7 @@ export function assertProductGrantTransaction(input) {
119
139
  return input;
120
140
  }
121
141
  export function assertLeaderboardScoreTransaction(input) {
122
- assertRecordLeaderboardScoreRequest(input);
142
+ validateRecordLeaderboardScoreRequest(input);
123
143
  assertNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
124
144
  assertNonEmptyString(input.recordedAt, 'recordedAt');
125
145
  return input;
@@ -180,11 +200,31 @@ function assertLeaderboardTarget(input) {
180
200
  throw new Error('target must be browser, microsoft-store, android, ios, ait, or reddit.');
181
201
  }
182
202
  }
203
+ function validateRequest(validate) {
204
+ try {
205
+ return validate();
206
+ }
207
+ catch (error) {
208
+ throw toGameServicesRequestValidationError(error);
209
+ }
210
+ }
183
211
  function assertNonEmptyString(input, label) {
184
212
  if (typeof input !== 'string' || input.length === 0) {
185
213
  throw new Error(`${label} must be a non-empty string.`);
186
214
  }
187
215
  }
216
+ /** Client-supplied identifiers feed ledger keys, so they are bounded as well as non-empty. */
217
+ function assertRequestString(input, label) {
218
+ assertNonEmptyString(input, label);
219
+ if (input.length > gameServicesRequestLimits.maxStringLength) {
220
+ throw new Error(`${label} must not exceed ${String(gameServicesRequestLimits.maxStringLength)} characters.`);
221
+ }
222
+ }
223
+ function assertOptionalRequestString(input, label) {
224
+ if (input !== undefined) {
225
+ assertRequestString(input, label);
226
+ }
227
+ }
188
228
  function assertOptionalNonEmptyString(input, label) {
189
229
  if (input !== undefined) {
190
230
  assertNonEmptyString(input, label);
@@ -261,6 +301,7 @@ function assertOptionalEvidenceEnvelope(input) {
261
301
  return;
262
302
  }
263
303
  assertRecord(input, 'evidence');
264
- assertNonEmptyString(input.schema, 'evidence.schema');
304
+ assertRequestString(input.schema, 'evidence.schema');
265
305
  assertPayload(input.payload);
306
+ assertOwnEnumerablePropertyLimit(input.payload, gameServicesRequestLimits.maxEvidencePayloadEntries, 'evidence.payload');
266
307
  }
@@ -1 +1,19 @@
1
+ /** Hard limits applied to untrusted client requests before they reach ledger keys. */
2
+ export declare const gameServicesRequestLimits: {
3
+ /** Maximum length of any identifier or timestamp string in a client request. */
4
+ readonly maxStringLength: 256;
5
+ /** Maximum number of entries accepted inside a platform evidence payload. */
6
+ readonly maxEvidencePayloadEntries: 64;
7
+ /** Maximum request body size accepted by the HTTP and oRPC fetch handlers. */
8
+ readonly maxBodyBytes: number;
9
+ };
10
+ /**
11
+ * A client-supplied request failed validation. Handlers map this error to a 400 response
12
+ * with the stable `INVALID_REQUEST` code; every other error is treated as internal.
13
+ */
14
+ export declare class GameServicesRequestValidationError extends Error {
15
+ readonly code = "INVALID_REQUEST";
16
+ constructor(message: string);
17
+ }
18
+ export declare function toGameServicesRequestValidationError(error: unknown): GameServicesRequestValidationError;
1
19
  export declare function assertOwnEnumerablePropertyLimit(input: Record<string, unknown>, maximum: number, label: string): void;
@@ -1,3 +1,29 @@
1
+ /** Hard limits applied to untrusted client requests before they reach ledger keys. */
2
+ export const gameServicesRequestLimits = {
3
+ /** Maximum length of any identifier or timestamp string in a client request. */
4
+ maxStringLength: 256,
5
+ /** Maximum number of entries accepted inside a platform evidence payload. */
6
+ maxEvidencePayloadEntries: 64,
7
+ /** Maximum request body size accepted by the HTTP and oRPC fetch handlers. */
8
+ maxBodyBytes: 64 * 1024,
9
+ };
10
+ /**
11
+ * A client-supplied request failed validation. Handlers map this error to a 400 response
12
+ * with the stable `INVALID_REQUEST` code; every other error is treated as internal.
13
+ */
14
+ export class GameServicesRequestValidationError extends Error {
15
+ code = 'INVALID_REQUEST';
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'GameServicesRequestValidationError';
19
+ }
20
+ }
21
+ export function toGameServicesRequestValidationError(error) {
22
+ if (error instanceof GameServicesRequestValidationError) {
23
+ return error;
24
+ }
25
+ return new GameServicesRequestValidationError(error instanceof Error ? error.message : 'Request validation failed.');
26
+ }
1
27
  export function assertOwnEnumerablePropertyLimit(input, maximum, label) {
2
28
  let propertyCount = 0;
3
29
  for (const key in input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {