@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.
- package/dist/admob-client-reward.d.ts +24 -0
- package/dist/admob-client-reward.js +63 -0
- package/dist/admob-ssv.js +3 -0
- package/dist/apps-in-toss-evidence-verification-conformance.d.ts +1 -1
- package/dist/apps-in-toss-evidence-verification-conformance.js +37 -0
- package/dist/apps-in-toss-evidence-verification.js +5 -0
- package/dist/client.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/monetization-recovery.js +3 -3
- package/dist/server.d.ts +20 -2
- package/dist/server.js +152 -26
- package/dist/types.d.ts +12 -0
- package/dist/types.js +68 -27
- package/dist/validation.d.ts +18 -0
- package/dist/validation.js +26 -0
- package/package.json +1 -1
|
@@ -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
|
-
&& !
|
|
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
|
-
...(
|
|
225
|
+
...(platformImpressionId === undefined
|
|
225
226
|
? {}
|
|
226
|
-
: { platformImpressionId
|
|
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
|
-
&& !
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
377
|
+
body,
|
|
314
378
|
});
|
|
315
379
|
return jsonResponse(response.body, response.status, options.corsHeaders);
|
|
316
380
|
}
|
|
317
381
|
catch (error) {
|
|
318
|
-
|
|
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
|
|
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 =
|
|
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
|
-
...(
|
|
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
|
|
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 (
|
|
734
|
-
payload.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
|
-
|
|
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 {
|
|
808
|
-
void
|
|
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
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
71
|
-
|
|
90
|
+
assertRequestString(input.playerId, 'playerId');
|
|
91
|
+
assertRequestString(input.leaderboardId, 'leaderboardId');
|
|
72
92
|
assertFiniteNumber(input.score, 'score');
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
assertRequestString(input.schema, 'evidence.schema');
|
|
265
305
|
assertPayload(input.payload);
|
|
306
|
+
assertOwnEnumerablePropertyLimit(input.payload, gameServicesRequestLimits.maxEvidencePayloadEntries, 'evidence.payload');
|
|
266
307
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -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;
|
package/dist/validation.js
CHANGED
|
@@ -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) {
|