@mpgd/game-services 0.17.2 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/ad-reward-verifier-registry.d.ts +23 -0
- package/dist/ad-reward-verifier-registry.js +83 -0
- package/dist/admob-client-reward.d.ts +1 -1
- package/dist/admob-client-reward.js +1 -1
- package/dist/client-reward-evidence.d.ts +22 -0
- package/dist/client-reward-evidence.js +93 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +11 -27
- package/dist/default-client-reward-evidence.d.ts +4 -0
- package/dist/default-client-reward-evidence.js +38 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/microsoft-store-purchase.d.ts +25 -0
- package/dist/microsoft-store-purchase.js +68 -2
- package/dist/monetization-recovery.js +53 -8
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +1 -0
- package/dist/server.js +2 -0
- package/dist/types.d.ts +3 -1
- package/dist/types.js +4 -2
- package/package.json +18 -6
package/README.md
CHANGED
|
@@ -4,6 +4,52 @@ Client, contract, backend, and store helpers for authoritative game services.
|
|
|
4
4
|
See [backend integration](../../docs/GAME_SERVICES_BACKEND.md) for the existing
|
|
5
5
|
verification and ledger boundaries.
|
|
6
6
|
|
|
7
|
+
## Registered rewarded-ad evidence
|
|
8
|
+
|
|
9
|
+
`createGameServicesClient`, `createRecoverableMonetizationClient`, and
|
|
10
|
+
`createGameServicesRuntime` accept `rewardEvidenceRegistry`. Create one with
|
|
11
|
+
`createClientRewardEvidenceRegistry` from
|
|
12
|
+
`@mpgd/game-services/client-reward-evidence`, or extend the default AdMob,
|
|
13
|
+
Verse8, and Apps in Toss decoders with
|
|
14
|
+
`createDefaultClientRewardEvidenceRegistry` from
|
|
15
|
+
`@mpgd/game-services/default-client-reward-evidence`.
|
|
16
|
+
|
|
17
|
+
Each explicit registration has a unique `schema` and a `normalize(reward)`
|
|
18
|
+
function returning `{ platformImpressionId? }` or `undefined`. The registry
|
|
19
|
+
passes a bounded, frozen copy of the evidence to the decoder. Decoder errors,
|
|
20
|
+
unknown schemas, and malformed candidates produce no claim. A decoder only
|
|
21
|
+
recognizes a claim candidate: the backend must independently verify proof
|
|
22
|
+
before a ledger grant. An SDK earned event may have `rewardGranted: false`
|
|
23
|
+
and still produce a pending or verified backend claim.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createDefaultClientRewardEvidenceRegistry } from
|
|
27
|
+
'@mpgd/game-services/default-client-reward-evidence';
|
|
28
|
+
|
|
29
|
+
const rewardEvidenceRegistry = createDefaultClientRewardEvidenceRegistry([{
|
|
30
|
+
schema: 'game-provider.reward.v1',
|
|
31
|
+
normalize(reward) {
|
|
32
|
+
const id = reward.evidence?.payload.impressionId;
|
|
33
|
+
return typeof id === 'string' && id.trim() !== ''
|
|
34
|
+
? { platformImpressionId: id } : undefined;
|
|
35
|
+
},
|
|
36
|
+
}]);
|
|
37
|
+
// Pass this registry to the client/runtime, and configure server verification separately.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Recovery uses the same registered decoder and checks the journaled claim
|
|
41
|
+
against the original evidence and impression identity. Missing registrations
|
|
42
|
+
or altered evidence leave reconciliation `action-required`; they cannot
|
|
43
|
+
reopen the SDK display. Supply the same registrations after a restart.
|
|
44
|
+
|
|
45
|
+
For existing v1 consumers that supply neither a registry nor `ads.provider`,
|
|
46
|
+
the completed SDK reward flag still forwards a legacy candidate to the
|
|
47
|
+
backend. A registered schema that fails decoding cannot take this fallback.
|
|
48
|
+
Explicit registries and versioned providers reject unknown schemas even if
|
|
49
|
+
an SDK reports a reward. Existing `admob-client-reward` exports remain
|
|
50
|
+
available for compatibility. Target routing and server verifier registration
|
|
51
|
+
are separate integration boundaries.
|
|
52
|
+
|
|
7
53
|
## Optional operation progress
|
|
8
54
|
|
|
9
55
|
`purchase(input, options?)` and `claimRewardedAd(input, options?)` accept optional
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EvidenceVerificationDecision, VerifyAdRewardEvidenceInput } from './evidence-verification.js';
|
|
2
|
+
import { type GameServicesAdRewardTarget } from './types.js';
|
|
3
|
+
export interface AdRewardVerifierBinding {
|
|
4
|
+
readonly target: GameServicesAdRewardTarget;
|
|
5
|
+
/** Omit only for the deployment whose key equals its platform target. */
|
|
6
|
+
readonly deploymentTarget?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AdRewardVerifierDescriptor {
|
|
9
|
+
readonly providerId: string;
|
|
10
|
+
readonly schema: string;
|
|
11
|
+
readonly bindings: readonly AdRewardVerifierBinding[];
|
|
12
|
+
}
|
|
13
|
+
export interface AdRewardVerifierRegistration extends AdRewardVerifierDescriptor {
|
|
14
|
+
/** Explicit server proof lookup without a client envelope (for example signed SSV recovery). */
|
|
15
|
+
readonly acceptsMissingEvidence?: boolean;
|
|
16
|
+
/** Authenticate proof and all request bindings; registration itself proves no reward. */
|
|
17
|
+
verify(input: VerifyAdRewardEvidenceInput): Promise<EvidenceVerificationDecision>;
|
|
18
|
+
}
|
|
19
|
+
export interface AdRewardEvidenceVerifierRegistry {
|
|
20
|
+
verifyAdReward(input: VerifyAdRewardEvidenceInput): Promise<EvidenceVerificationDecision>;
|
|
21
|
+
}
|
|
22
|
+
/** A deployment-owned allow-list. Client decoder registration never reaches this registry. */
|
|
23
|
+
export declare function createAdRewardEvidenceVerifierRegistry(entries: readonly AdRewardVerifierRegistration[]): AdRewardEvidenceVerifierRegistry;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { assertGameServicesDeploymentTarget } from './types.js';
|
|
2
|
+
/** A deployment-owned allow-list. Client decoder registration never reaches this registry. */
|
|
3
|
+
export function createAdRewardEvidenceVerifierRegistry(entries) {
|
|
4
|
+
const registered = new Map();
|
|
5
|
+
const providerSchemas = new Map();
|
|
6
|
+
for (const entry of entries) {
|
|
7
|
+
assertIdentity(entry.providerId);
|
|
8
|
+
assertIdentity(entry.schema);
|
|
9
|
+
if (!Array.isArray(entry.bindings) || entry.bindings.length === 0 || typeof entry.verify !== 'function'
|
|
10
|
+
|| entry.acceptsMissingEvidence !== undefined && typeof entry.acceptsMissingEvidence !== 'boolean') {
|
|
11
|
+
throw new TypeError('Invalid advertising verifier registration.');
|
|
12
|
+
}
|
|
13
|
+
const owner = providerSchemas.get(entry.schema);
|
|
14
|
+
if (owner !== undefined && owner !== entry.providerId) {
|
|
15
|
+
throw new TypeError('Advertising evidence schema belongs to another provider.');
|
|
16
|
+
}
|
|
17
|
+
providerSchemas.set(entry.schema, entry.providerId);
|
|
18
|
+
for (const binding of entry.bindings) {
|
|
19
|
+
if (!rewardTargets.has(binding.target)) {
|
|
20
|
+
throw new TypeError('Invalid advertising verifier target.');
|
|
21
|
+
}
|
|
22
|
+
const deploymentTarget = binding.deploymentTarget ?? binding.target;
|
|
23
|
+
assertGameServicesDeploymentTarget(deploymentTarget);
|
|
24
|
+
const key = bindingKey(entry.schema, binding.target, deploymentTarget);
|
|
25
|
+
if (registered.has(key)) {
|
|
26
|
+
throw new TypeError('Duplicate advertising verifier binding.');
|
|
27
|
+
}
|
|
28
|
+
const verifier = Object.freeze({ providerId: entry.providerId, verify: entry.verify });
|
|
29
|
+
registered.set(key, verifier);
|
|
30
|
+
if (entry.acceptsMissingEvidence === true) {
|
|
31
|
+
const missingKey = bindingKey(null, binding.target, deploymentTarget);
|
|
32
|
+
if (registered.has(missingKey)) {
|
|
33
|
+
throw new TypeError('Duplicate missing-evidence advertising verifier binding.');
|
|
34
|
+
}
|
|
35
|
+
registered.set(missingKey, verifier);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
async verifyAdReward(input) {
|
|
41
|
+
const request = input.request;
|
|
42
|
+
const schema = request.evidence === undefined ? null : request.evidence?.schema;
|
|
43
|
+
const selected = schema === undefined || schema === '' ? undefined : registered.get(bindingKey(schema, request.target, request.deploymentTarget ?? request.target));
|
|
44
|
+
if (selected === undefined) {
|
|
45
|
+
return { status: 'rejected', reason: 'AD_REWARD_VERIFIER_UNREGISTERED' };
|
|
46
|
+
}
|
|
47
|
+
const { providerId, verify } = selected;
|
|
48
|
+
if (request.providerId !== undefined && request.providerId !== providerId) {
|
|
49
|
+
return { status: 'rejected', reason: 'AD_REWARD_PROVIDER_MISMATCH' };
|
|
50
|
+
}
|
|
51
|
+
// Proof verification stays abortable and server-owned. Preserve its authoritative
|
|
52
|
+
// identity so ledger replay checks remain stable across registry migration.
|
|
53
|
+
const decision = await verify(input);
|
|
54
|
+
if (decision?.status !== 'verified') {
|
|
55
|
+
return decision;
|
|
56
|
+
}
|
|
57
|
+
const payload = decision.payload;
|
|
58
|
+
if (payload !== undefined && (typeof payload !== 'object' || payload === null
|
|
59
|
+
|| Array.isArray(payload) || Object.values(payload).some((value) => typeof value !== 'string' && typeof value !== 'boolean'
|
|
60
|
+
&& (typeof value !== 'number' || !Number.isFinite(value))))) {
|
|
61
|
+
return { status: 'rejected', reason: 'EVIDENCE_VERIFIER_ERROR' };
|
|
62
|
+
}
|
|
63
|
+
return { ...decision, payload: { ...payload, adProviderId: providerId } };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const rewardTargets = new Set([
|
|
68
|
+
'browser',
|
|
69
|
+
'microsoft-store',
|
|
70
|
+
'android',
|
|
71
|
+
'ios',
|
|
72
|
+
'ait',
|
|
73
|
+
'reddit',
|
|
74
|
+
'verse8',
|
|
75
|
+
]);
|
|
76
|
+
function bindingKey(schema, target, deploymentTarget) {
|
|
77
|
+
return JSON.stringify([schema, target, deploymentTarget]);
|
|
78
|
+
}
|
|
79
|
+
function assertIdentity(value) {
|
|
80
|
+
if (typeof value !== 'string' || value.trim() === '' || value.length > 512) {
|
|
81
|
+
throw new TypeError('Invalid advertising verifier identity.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -16,7 +16,7 @@ export declare const verse8ClientRewardEvidenceSchema = "verse8.ads.reward.v1";
|
|
|
16
16
|
*/
|
|
17
17
|
export declare const clientRewardEvidenceSchemas: readonly ["mpgd.admob.client-reward.v1", "verse8.ads.reward.v1"];
|
|
18
18
|
export type ClientRewardEvidenceSchema = (typeof clientRewardEvidenceSchemas)[number];
|
|
19
|
-
/**
|
|
19
|
+
/** @deprecated Built-in v1 compatibility helper. Claim/recovery flows use the injected reward evidence registry. */
|
|
20
20
|
export declare function isClientRewardEvidence(value: unknown): boolean;
|
|
21
21
|
export declare function isVerse8ClientRewardEvidence(value: unknown): boolean;
|
|
22
22
|
/**
|
|
@@ -35,7 +35,7 @@ export const clientRewardEvidenceSchemas = Object.freeze([
|
|
|
35
35
|
admobClientRewardEvidenceSchema,
|
|
36
36
|
verse8ClientRewardEvidenceSchema,
|
|
37
37
|
]);
|
|
38
|
-
/**
|
|
38
|
+
/** @deprecated Built-in v1 compatibility helper. Claim/recovery flows use the injected reward evidence registry. */
|
|
39
39
|
export function isClientRewardEvidence(value) {
|
|
40
40
|
return isAdMobClientRewardEvidence(value) || isVerse8ClientRewardEvidence(value);
|
|
41
41
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PlatformEvidenceEnvelope, RewardedAdResult } from '@mpgd/platform';
|
|
2
|
+
export interface ClientRewardClaimCandidate {
|
|
3
|
+
readonly evidence?: PlatformEvidenceEnvelope;
|
|
4
|
+
readonly platformImpressionId?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ClientRewardEvidenceNormalizer {
|
|
7
|
+
readonly schema: string;
|
|
8
|
+
/** Decode a claim candidate only. This cannot authorize a server grant. */
|
|
9
|
+
normalize(reward: RewardedAdResult): {
|
|
10
|
+
readonly platformImpressionId?: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientRewardEvidenceRegistry {
|
|
14
|
+
resolve(reward: RewardedAdResult): ClientRewardClaimCandidate | undefined;
|
|
15
|
+
recognizes(schema: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
/** An immutable, explicitly registered client decoder set, independent of server verification. */
|
|
18
|
+
export declare function createClientRewardEvidenceRegistry(entries: readonly ClientRewardEvidenceNormalizer[]): ClientRewardEvidenceRegistry;
|
|
19
|
+
/** Preserve the old SDK-flag claim path only for consumers without a versioned/registered provider. */
|
|
20
|
+
export declare function resolveClientRewardClaim(reward: RewardedAdResult, registry: ClientRewardEvidenceRegistry, options: {
|
|
21
|
+
readonly allowLegacyCompletion: boolean;
|
|
22
|
+
}): ClientRewardClaimCandidate | undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** An immutable, explicitly registered client decoder set, independent of server verification. */
|
|
2
|
+
export function createClientRewardEvidenceRegistry(entries) {
|
|
3
|
+
const normalizers = new Map();
|
|
4
|
+
for (const entry of entries) {
|
|
5
|
+
if (typeof entry.schema !== 'string' || entry.schema.trim() === '' || entry.schema.length > 512
|
|
6
|
+
|| typeof entry.normalize !== 'function' || normalizers.has(entry.schema)) {
|
|
7
|
+
throw new TypeError('Invalid or duplicate client reward evidence registration.');
|
|
8
|
+
}
|
|
9
|
+
normalizers.set(entry.schema, entry.normalize);
|
|
10
|
+
}
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
recognizes: (schema) => normalizers.has(schema),
|
|
13
|
+
resolve(reward) {
|
|
14
|
+
if ((reward.status !== 'completed' && reward.status !== 'pending')
|
|
15
|
+
|| typeof reward.rewardGranted !== 'boolean'
|
|
16
|
+
|| (reward.ledgerEntryId !== undefined && (typeof reward.ledgerEntryId !== 'string' || reward.ledgerEntryId.trim() === '' || reward.ledgerEntryId.length > 512))) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const evidence = cloneEvidence(reward.evidence);
|
|
20
|
+
if (evidence === undefined) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const normalize = normalizers.get(evidence.schema);
|
|
24
|
+
if (normalize === undefined) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const normalized = normalize(Object.freeze({
|
|
29
|
+
status: reward.status, rewardGranted: reward.rewardGranted, evidence,
|
|
30
|
+
...(reward.ledgerEntryId === undefined ? {} : { ledgerEntryId: reward.ledgerEntryId }),
|
|
31
|
+
}));
|
|
32
|
+
if (typeof normalized !== 'object' || normalized === null || Array.isArray(normalized)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const impressionId = normalized.platformImpressionId;
|
|
36
|
+
if (impressionId !== undefined && (typeof impressionId !== 'string' || impressionId.trim() === '' || impressionId.length > 512)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
evidence,
|
|
41
|
+
...(impressionId === undefined ? {} : { platformImpressionId: impressionId }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Decoder failures cannot turn untrusted evidence into a claim or leak its contents.
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Preserve the old SDK-flag claim path only for consumers without a versioned/registered provider. */
|
|
52
|
+
export function resolveClientRewardClaim(reward, registry, options) {
|
|
53
|
+
const candidate = registry.resolve(reward);
|
|
54
|
+
if (candidate !== undefined) {
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
if (!options.allowLegacyCompletion || reward.status !== 'completed' || reward.rewardGranted !== true) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const evidence = reward.evidence === undefined ? undefined : cloneEvidence(reward.evidence);
|
|
61
|
+
if ((reward.evidence !== undefined && evidence === undefined)
|
|
62
|
+
|| (evidence !== undefined && registry.recognizes(evidence.schema))
|
|
63
|
+
|| (reward.ledgerEntryId !== undefined && (typeof reward.ledgerEntryId !== 'string'
|
|
64
|
+
|| reward.ledgerEntryId.trim() === '' || reward.ledgerEntryId.length > 512))) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
69
|
+
...(reward.ledgerEntryId === undefined ? {} : { platformImpressionId: reward.ledgerEntryId }),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function cloneEvidence(value) {
|
|
73
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const envelope = value;
|
|
77
|
+
const payload = envelope.payload;
|
|
78
|
+
if (typeof envelope.schema !== 'string' || envelope.schema.trim() === '' || envelope.schema.length > 512
|
|
79
|
+
|| typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const entries = Object.entries(payload);
|
|
83
|
+
if (entries.length > 64 || entries.some(([key, field]) => key.length > 128
|
|
84
|
+
|| (typeof field !== 'string' && typeof field !== 'number' && typeof field !== 'boolean')
|
|
85
|
+
|| (typeof field === 'string' && field.length > 8192)
|
|
86
|
+
|| (typeof field === 'number' && !Number.isFinite(field)))) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
schema: envelope.schema,
|
|
91
|
+
payload: Object.freeze(Object.fromEntries(entries)),
|
|
92
|
+
});
|
|
93
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AnalyticsSink } from '@mpgd/analytics';
|
|
2
2
|
import type { LeaderboardScoreInput, PlatformGateway, PlatformTarget } from '@mpgd/platform';
|
|
3
|
+
import { type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
|
|
3
4
|
import type { GameServicesOperationClient } from './operations.js';
|
|
4
5
|
export type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult, } from './operations.js';
|
|
5
6
|
import type { GameServicesContractClient } from './contract.js';
|
|
@@ -100,6 +101,8 @@ export interface CreateGameServicesClientInput {
|
|
|
100
101
|
readonly now?: () => string;
|
|
101
102
|
/** Override backend evidence timestamps without backdating analytics events. */
|
|
102
103
|
readonly requestNow?: () => string;
|
|
104
|
+
/** Explicit client decoders only; server verification is configured independently. */
|
|
105
|
+
readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
|
|
103
106
|
}
|
|
104
107
|
export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
|
|
105
108
|
}
|
package/dist/client.js
CHANGED
|
@@ -2,7 +2,8 @@ import { createORPCClient } from '@orpc/client';
|
|
|
2
2
|
import { RPCLink } from '@orpc/client/fetch';
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
4
|
import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
|
|
5
|
-
import {
|
|
5
|
+
import { resolveClientRewardClaim, } from './client-reward-evidence.js';
|
|
6
|
+
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
6
7
|
import { observeGameServicesOperation } from './operation-progress.js';
|
|
7
8
|
export const gameServicesBackendEndpoints = {
|
|
8
9
|
verifyPurchase: '/game-services/purchases/verify',
|
|
@@ -57,6 +58,8 @@ export async function resolveGameServicesRequestHeaders(input) {
|
|
|
57
58
|
export function createGameServicesClient(input) {
|
|
58
59
|
const now = input.now ?? (() => new Date().toISOString());
|
|
59
60
|
const requestNow = input.requestNow ?? now;
|
|
61
|
+
const rewardEvidenceRegistry = input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry();
|
|
62
|
+
const allowLegacyRewardCompletion = input.rewardEvidenceRegistry === undefined && input.gateway.ads?.provider === undefined;
|
|
60
63
|
const analytics = createAnalyticsReporter({
|
|
61
64
|
target: input.target,
|
|
62
65
|
sessionId: input.analyticsSessionId ?? input.playerId,
|
|
@@ -177,30 +180,13 @@ export function createGameServicesClient(input) {
|
|
|
177
180
|
async claimRewardedAd(rewardInput, options) {
|
|
178
181
|
return observeGameServicesOperation('rewarded-ad', options, async (progress) => {
|
|
179
182
|
const target = input.target;
|
|
180
|
-
if (!isGameServicesAdRewardTarget(target)) {
|
|
181
|
-
const reward = {
|
|
182
|
-
status: 'unavailable',
|
|
183
|
-
rewardGranted: false,
|
|
184
|
-
};
|
|
185
|
-
await analytics.track({
|
|
186
|
-
name: 'rewarded_ad_rejected',
|
|
187
|
-
properties: {
|
|
188
|
-
placementId: rewardInput.placementId,
|
|
189
|
-
status: reward.status,
|
|
190
|
-
rewardGranted: reward.rewardGranted,
|
|
191
|
-
reason: 'unsupported_target',
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
return {
|
|
195
|
-
status: 'rejected',
|
|
196
|
-
reward,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
183
|
progress.platformRequested();
|
|
200
184
|
const reward = await input.gateway.ads.showRewarded(rewardInput);
|
|
201
185
|
progress.platformResult(reward.status);
|
|
202
|
-
|
|
203
|
-
|
|
186
|
+
const candidate = resolveClientRewardClaim(reward, rewardEvidenceRegistry, {
|
|
187
|
+
allowLegacyCompletion: allowLegacyRewardCompletion,
|
|
188
|
+
});
|
|
189
|
+
if (candidate === undefined) {
|
|
204
190
|
await analytics.track({
|
|
205
191
|
name: reward.status === 'pending' ? 'rewarded_ad_pending' : 'rewarded_ad_rejected',
|
|
206
192
|
properties: {
|
|
@@ -214,9 +200,10 @@ export function createGameServicesClient(input) {
|
|
|
214
200
|
reward,
|
|
215
201
|
};
|
|
216
202
|
}
|
|
217
|
-
const platformImpressionId =
|
|
203
|
+
const platformImpressionId = candidate.platformImpressionId;
|
|
218
204
|
const claimRequest = {
|
|
219
205
|
target,
|
|
206
|
+
...(input.gateway.ads.provider === undefined ? {} : { providerId: input.gateway.ads.provider.id }),
|
|
220
207
|
...(input.deploymentTarget === undefined || input.deploymentTarget === target
|
|
221
208
|
? {}
|
|
222
209
|
: { deploymentTarget: input.deploymentTarget }),
|
|
@@ -227,7 +214,7 @@ export function createGameServicesClient(input) {
|
|
|
227
214
|
: { platformImpressionId }),
|
|
228
215
|
idempotencyKey: rewardInput.idempotencyKey,
|
|
229
216
|
completedAt: requestNow(),
|
|
230
|
-
...(
|
|
217
|
+
...(candidate.evidence === undefined ? {} : { evidence: candidate.evidence }),
|
|
231
218
|
};
|
|
232
219
|
progress.serverRequested();
|
|
233
220
|
const claim = await input.backend.adRewards.claimAdReward(claimRequest);
|
|
@@ -414,9 +401,6 @@ function isGameServicesCommerceTarget(target) {
|
|
|
414
401
|
|| target === 'ait'
|
|
415
402
|
|| target === 'verse8';
|
|
416
403
|
}
|
|
417
|
-
function isGameServicesAdRewardTarget(target) {
|
|
418
|
-
return target === 'android' || target === 'ios' || target === 'ait' || target === 'verse8';
|
|
419
|
-
}
|
|
420
404
|
function isGameServicesLeaderboardTarget(target) {
|
|
421
405
|
return target === 'browser'
|
|
422
406
|
|| target === 'microsoft-store'
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ClientRewardEvidenceNormalizer, type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
|
|
2
|
+
/** Registered decoder data. Common claim and recovery code never inspects these provider payloads. */
|
|
3
|
+
export declare const defaultClientRewardEvidenceNormalizers: readonly ClientRewardEvidenceNormalizer[];
|
|
4
|
+
export declare function createDefaultClientRewardEvidenceRegistry(additional?: readonly ClientRewardEvidenceNormalizer[]): ClientRewardEvidenceRegistry;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { admobClientRewardEvidenceSchema, isAdMobClientRewardEvidence, isVerse8ClientRewardEvidence, verse8ClientRewardEvidenceSchema, } from './admob-client-reward.js';
|
|
2
|
+
import { createClientRewardEvidenceRegistry, } from './client-reward-evidence.js';
|
|
3
|
+
/** Registered decoder data. Common claim and recovery code never inspects these provider payloads. */
|
|
4
|
+
export const defaultClientRewardEvidenceNormalizers = Object.freeze([
|
|
5
|
+
Object.freeze({
|
|
6
|
+
schema: admobClientRewardEvidenceSchema,
|
|
7
|
+
normalize: (reward) => isAdMobClientRewardEvidence(reward) ? {} : undefined,
|
|
8
|
+
}),
|
|
9
|
+
Object.freeze({
|
|
10
|
+
schema: verse8ClientRewardEvidenceSchema,
|
|
11
|
+
normalize: (reward) => {
|
|
12
|
+
if (!isVerse8ClientRewardEvidence(reward)) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const requestId = reward.evidence?.payload.requestId;
|
|
16
|
+
return typeof requestId === 'string' ? { platformImpressionId: requestId } : undefined;
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
Object.freeze({
|
|
20
|
+
schema: 'apps-in-toss.rewarded-ad.callback.v1',
|
|
21
|
+
normalize: (reward) => {
|
|
22
|
+
const payload = reward.evidence?.payload;
|
|
23
|
+
const correlationId = payload?.correlationId;
|
|
24
|
+
if (payload?.event !== 'user-earned-reward' || typeof correlationId !== 'string' || correlationId.trim() === ''
|
|
25
|
+
|| typeof payload.placementId !== 'string' || payload.placementId.trim() === ''
|
|
26
|
+
|| (reward.ledgerEntryId !== undefined && reward.ledgerEntryId !== correlationId)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return { platformImpressionId: correlationId };
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
export function createDefaultClientRewardEvidenceRegistry(additional = []) {
|
|
34
|
+
return createClientRewardEvidenceRegistry([
|
|
35
|
+
...defaultClientRewardEvidenceNormalizers,
|
|
36
|
+
...additional,
|
|
37
|
+
]);
|
|
38
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export * from './admob-ssv.js';
|
|
2
|
+
export * from './ad-reward-verifier-registry.js';
|
|
2
3
|
export * from './app-store-verifier.js';
|
|
3
4
|
export * from './app-store-recovery.js';
|
|
4
5
|
export * from './apps-in-toss-evidence-verification.js';
|
|
5
6
|
export * from './apps-in-toss-partner-api.js';
|
|
6
7
|
export * from './client.js';
|
|
8
|
+
export * from './client-reward-evidence.js';
|
|
9
|
+
export * from './default-client-reward-evidence.js';
|
|
7
10
|
export * from './admob-client-reward.js';
|
|
8
11
|
export * from './contract.js';
|
|
9
12
|
export * from './evidence-verification.js';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export * from './admob-ssv.js';
|
|
2
|
+
export * from './ad-reward-verifier-registry.js';
|
|
2
3
|
export * from './app-store-verifier.js';
|
|
3
4
|
export * from './app-store-recovery.js';
|
|
4
5
|
export * from './apps-in-toss-evidence-verification.js';
|
|
5
6
|
export * from './apps-in-toss-partner-api.js';
|
|
6
7
|
export * from './client.js';
|
|
8
|
+
export * from './client-reward-evidence.js';
|
|
9
|
+
export * from './default-client-reward-evidence.js';
|
|
7
10
|
export * from './admob-client-reward.js';
|
|
8
11
|
export * from './contract.js';
|
|
9
12
|
export * from './evidence-verification.js';
|
|
@@ -58,8 +58,33 @@ export interface CreateMicrosoftStorePurchaseBoundaryInput {
|
|
|
58
58
|
* Production implementations must share this state across every game-service instance.
|
|
59
59
|
*/
|
|
60
60
|
readonly recoveryOwnershipStore: MicrosoftStoreRecoveryOwnershipStore;
|
|
61
|
+
/**
|
|
62
|
+
* Persist validated consume/order attribution before releasing recovery ownership.
|
|
63
|
+
* Must be durable and idempotent by evidenceVerificationId/trackingId. Throw to keep
|
|
64
|
+
* finalization pending. On developer-managed retries, orderTransactions can be empty;
|
|
65
|
+
* resolve only if an exact previously persisted receipt is available in that case.
|
|
66
|
+
* This hook does not receive credentials and does not process Clawback events.
|
|
67
|
+
*/
|
|
68
|
+
readonly persistConsumptionReceipt?: (receipt: MicrosoftStoreConsumptionReceipt, signal: AbortSignal) => Promise<void>;
|
|
61
69
|
readonly now?: () => string;
|
|
62
70
|
}
|
|
71
|
+
export interface MicrosoftStoreConsumeOrderTransaction {
|
|
72
|
+
readonly orderId: string;
|
|
73
|
+
readonly orderLineItemId: string;
|
|
74
|
+
readonly quantityConsumed: number;
|
|
75
|
+
}
|
|
76
|
+
/** Server-only attribution for subsequent provider refund reconciliation. */
|
|
77
|
+
export interface MicrosoftStoreConsumptionReceipt {
|
|
78
|
+
readonly evidenceVerificationId: string;
|
|
79
|
+
readonly playerId: string;
|
|
80
|
+
/** Stable logical product, distinct from the provider Store ID. */
|
|
81
|
+
readonly productId: string;
|
|
82
|
+
readonly storeId: string;
|
|
83
|
+
readonly collectionItemId: string;
|
|
84
|
+
readonly trackingId: string;
|
|
85
|
+
/** Empty when the provider omitted order IDs on a developer-managed retry. */
|
|
86
|
+
readonly orderTransactions: readonly MicrosoftStoreConsumeOrderTransaction[];
|
|
87
|
+
}
|
|
63
88
|
export interface MicrosoftStoreHistoricalProductMapping {
|
|
64
89
|
readonly inAppOfferToken: string;
|
|
65
90
|
readonly storeId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gameServicesRequestLimits } from './validation.js';
|
|
1
2
|
export const microsoftStoreDigitalGoodsEvidenceSchema = 'mpgd.microsoft-store.digital-goods.v1';
|
|
2
3
|
export const microsoftStoreCollectionsQueryUrl = 'https://collections.mp.microsoft.com/v9.0/collections/publisherQuery';
|
|
3
4
|
export const microsoftStoreCollectionsConsumeUrl = 'https://collections.mp.microsoft.com/v8.0/collections/consume';
|
|
@@ -112,7 +113,9 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
112
113
|
const clientEvidence = readRecoveryOwnershipEvidence(ownershipInput);
|
|
113
114
|
if (playerId === undefined
|
|
114
115
|
|| productId === undefined
|
|
115
|
-
|| clientEvidence === undefined
|
|
116
|
+
|| clientEvidence === undefined
|
|
117
|
+
|| (ownershipInput.idempotencyKey !== undefined
|
|
118
|
+
&& (generation === undefined || generation.length > gameServicesRequestLimits.maxStringLength))) {
|
|
116
119
|
return { status: 'denied' };
|
|
117
120
|
}
|
|
118
121
|
const currentInAppOfferToken = inAppOfferTokens.get(productId);
|
|
@@ -239,7 +242,7 @@ export function createMicrosoftStorePurchaseBoundary(input) {
|
|
|
239
242
|
}
|
|
240
243
|
const effective = await input.recoveryOwnershipStore.claim({
|
|
241
244
|
...ownership.record,
|
|
242
|
-
generation: ownership.providerPurchaseId,
|
|
245
|
+
generation: await createRecoveryGeneration(ownership.providerPurchaseId),
|
|
243
246
|
providerPurchaseId: ownership.providerPurchaseId,
|
|
244
247
|
});
|
|
245
248
|
return effective?.playerId === ownership.record.playerId
|
|
@@ -437,6 +440,34 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
|
|
|
437
440
|
|| raw.newQuantity !== 0) {
|
|
438
441
|
return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_MISMATCH');
|
|
439
442
|
}
|
|
443
|
+
if (input.persistConsumptionReceipt !== undefined) {
|
|
444
|
+
const orderTransactions = readConsumeOrderTransactions(raw.orderTransactions);
|
|
445
|
+
// Some consume responses omit the product echo. Attribution comes from the
|
|
446
|
+
// verified context and the item/tracking checks above; reject a conflicting echo.
|
|
447
|
+
if ((raw.productId !== undefined && raw.productId !== context.storeId)
|
|
448
|
+
|| orderTransactions === undefined) {
|
|
449
|
+
return finalizationPending('MICROSOFT_STORE_CONSUMPTION_RECEIPT_INVALID');
|
|
450
|
+
}
|
|
451
|
+
finalizationInput.signal.throwIfAborted();
|
|
452
|
+
try {
|
|
453
|
+
await input.persistConsumptionReceipt(Object.freeze({
|
|
454
|
+
evidenceVerificationId: finalizationInput.evidenceVerificationId,
|
|
455
|
+
playerId: finalizationInput.request.playerId,
|
|
456
|
+
productId: finalizationInput.request.productId,
|
|
457
|
+
storeId: context.storeId,
|
|
458
|
+
collectionItemId: context.collectionItemId,
|
|
459
|
+
trackingId,
|
|
460
|
+
orderTransactions,
|
|
461
|
+
}), finalizationInput.signal);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
if (finalizationInput.signal.aborted) {
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
return finalizationPending('MICROSOFT_STORE_CONSUMPTION_RECEIPT_UNAVAILABLE');
|
|
468
|
+
}
|
|
469
|
+
finalizationInput.signal.throwIfAborted();
|
|
470
|
+
}
|
|
440
471
|
try {
|
|
441
472
|
await input.recoveryOwnershipStore.release({
|
|
442
473
|
accountBindingHash,
|
|
@@ -462,6 +493,32 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
|
|
|
462
493
|
return finalizationPending('MICROSOFT_STORE_CONSUME_UNAVAILABLE');
|
|
463
494
|
}
|
|
464
495
|
}
|
|
496
|
+
function readConsumeOrderTransactions(value) {
|
|
497
|
+
// Microsoft documents omitted order IDs for developer-managed consume retries.
|
|
498
|
+
// Never invent them from the collection item, client token, or tracking ID.
|
|
499
|
+
if (value === undefined) {
|
|
500
|
+
return Object.freeze([]);
|
|
501
|
+
}
|
|
502
|
+
if (!Array.isArray(value) || value.length > 1) {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
const transactions = [];
|
|
506
|
+
const guid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/iu;
|
|
507
|
+
for (const item of value) {
|
|
508
|
+
// This boundary only grants developer-managed consumables with quantity one.
|
|
509
|
+
if (!isRecord(item) || typeof item.orderId !== 'string' || !guid.test(item.orderId)
|
|
510
|
+
|| typeof item.orderLineItemId !== 'string' || !guid.test(item.orderLineItemId)
|
|
511
|
+
|| item.quantityConsumed !== 1) {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
transactions.push(Object.freeze({
|
|
515
|
+
orderId: item.orderId.toLowerCase(),
|
|
516
|
+
orderLineItemId: item.orderLineItemId.toLowerCase(),
|
|
517
|
+
quantityConsumed: 1,
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
return Object.freeze(transactions);
|
|
521
|
+
}
|
|
465
522
|
function inspectPublisherQuery(raw, expectedStoreId) {
|
|
466
523
|
if (!isRecord(raw)) {
|
|
467
524
|
return rejected('MICROSOFT_STORE_COLLECTIONS_RESPONSE_INVALID');
|
|
@@ -592,6 +649,15 @@ function readFinalizationContext(input) {
|
|
|
592
649
|
function createVerificationId(item) {
|
|
593
650
|
return `microsoft-store:${item.productId}:${item.id}:${item.modifiedDate}`;
|
|
594
651
|
}
|
|
652
|
+
/** Keep existing short generations stable while bounding new provider-derived request keys. */
|
|
653
|
+
async function createRecoveryGeneration(providerPurchaseId) {
|
|
654
|
+
if (providerPurchaseId.length <= gameServicesRequestLimits.maxStringLength) {
|
|
655
|
+
return providerPurchaseId;
|
|
656
|
+
}
|
|
657
|
+
const encoded = new TextEncoder().encode(providerPurchaseId);
|
|
658
|
+
const digest = new Uint8Array(await readGlobalSubtleCrypto().digest('SHA-256', encoded));
|
|
659
|
+
return `microsoft-store-recovery:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
|
|
660
|
+
}
|
|
595
661
|
async function deterministicTrackingId(value) {
|
|
596
662
|
const subtle = readGlobalSubtleCrypto();
|
|
597
663
|
const encoded = new TextEncoder().encode(value);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isAuthoritativeMicrosoftStoreCompletion } from './authoritative-purchase.js';
|
|
2
2
|
import { createGameServicesClient } from './client.js';
|
|
3
|
-
import {
|
|
3
|
+
import { resolveClientRewardClaim } from './client-reward-evidence.js';
|
|
4
|
+
import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
|
|
4
5
|
import { forwardGameServicesProgress, observeGameServicesOperation, } from './operation-progress.js';
|
|
5
6
|
/**
|
|
6
7
|
* Client-side recovery coordinates UI callbacks and authoritative backend calls.
|
|
@@ -9,6 +10,8 @@ import { forwardGameServicesProgress, observeGameServicesOperation, } from './op
|
|
|
9
10
|
*/
|
|
10
11
|
export function createRecoverableMonetizationClient(input) {
|
|
11
12
|
const store = input.operationStore;
|
|
13
|
+
const rewardEvidenceRegistry = input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry();
|
|
14
|
+
const allowLegacyRewardCompletion = input.rewardEvidenceRegistry === undefined && input.gateway.ads.provider === undefined;
|
|
12
15
|
const observedAt = () => input.now?.() ?? new Date().toISOString();
|
|
13
16
|
const inFlight = new Map();
|
|
14
17
|
const unjournaledPurchases = new Map();
|
|
@@ -442,18 +445,19 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
442
445
|
assertOwner(found, 'rewarded-ad', idempotencyKey, found.input.placementId);
|
|
443
446
|
const transient = unjournaledRewards.get(key);
|
|
444
447
|
if (transient !== undefined && !samePlatformResult(transient.platform, platform)
|
|
445
|
-
&& !(transient.platform
|
|
448
|
+
&& !canAdvanceReward(transient.platform, platform, false)) {
|
|
446
449
|
throw new Error('A platform ad callback conflicts with the unjournaled result.');
|
|
447
450
|
}
|
|
448
|
-
const canAdvance = found.platform
|
|
449
|
-
&&
|
|
451
|
+
const canAdvance = found.platform !== undefined
|
|
452
|
+
&& !samePlatformResult(found.platform, platform)
|
|
453
|
+
&& canAdvanceReward(found.platform, platform, found.request !== undefined);
|
|
450
454
|
if (found.platform !== undefined && !samePlatformResult(found.platform, platform)
|
|
451
455
|
&& !canAdvance) {
|
|
452
456
|
throw new Error('A platform ad callback conflicts with the recorded operation.');
|
|
453
457
|
}
|
|
454
458
|
let record = found;
|
|
455
459
|
if (found.platform === undefined || canAdvance) {
|
|
456
|
-
const completedAt = observedAt();
|
|
460
|
+
const completedAt = found.platformCompletedAt ?? observedAt();
|
|
457
461
|
try {
|
|
458
462
|
record = await save(found, { platform, platformCompletedAt: completedAt });
|
|
459
463
|
}
|
|
@@ -466,6 +470,37 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
466
470
|
return resumeReward(record);
|
|
467
471
|
});
|
|
468
472
|
}
|
|
473
|
+
function canAdvanceReward(previous, next, hasRequest) {
|
|
474
|
+
if (previous.status !== 'pending') {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
const legacyUpgrade = !hasRequest && allowLegacyRewardCompletion
|
|
478
|
+
&& previous.rewardGranted === false && next.rewardGranted === true && next.status === 'completed';
|
|
479
|
+
// Once a candidate is recorded, its correlation cannot be replaced even
|
|
480
|
+
// when dispatch has not yet reached the server or its journal write failed.
|
|
481
|
+
if (previous.evidence !== undefined && (!sameRewardEvidence(previous.evidence, next.evidence)
|
|
482
|
+
|| !legacyUpgrade && (previous.rewardGranted !== next.rewardGranted || previous.ledgerEntryId !== next.ledgerEntryId))) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
if (previous.ledgerEntryId !== undefined && previous.ledgerEntryId !== next.ledgerEntryId) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
const candidate = resolveClientRewardClaim(next, rewardEvidenceRegistry, {
|
|
489
|
+
allowLegacyCompletion: allowLegacyRewardCompletion,
|
|
490
|
+
});
|
|
491
|
+
if (candidate === undefined) {
|
|
492
|
+
return !hasRequest && previous.evidence === undefined && next.evidence === undefined
|
|
493
|
+
&& (next.status === 'skipped' || next.status === 'unavailable' || next.status === 'failed');
|
|
494
|
+
}
|
|
495
|
+
const prior = resolveClientRewardClaim(previous, rewardEvidenceRegistry, {
|
|
496
|
+
allowLegacyCompletion: allowLegacyRewardCompletion,
|
|
497
|
+
});
|
|
498
|
+
if (prior !== undefined && (prior.platformImpressionId !== candidate.platformImpressionId
|
|
499
|
+
|| !sameRewardEvidence(prior.evidence, candidate.evidence))) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
return !hasRequest || prior !== undefined;
|
|
503
|
+
}
|
|
469
504
|
async function reconcile() {
|
|
470
505
|
const pending = await store.listRecoverable(input.playerId);
|
|
471
506
|
const summaries = [];
|
|
@@ -618,15 +653,25 @@ function assertPurchaseRequest(record, input) {
|
|
|
618
653
|
}
|
|
619
654
|
function assertRewardRequest(record, input) {
|
|
620
655
|
const request = record.request;
|
|
656
|
+
const candidate = record.platform === undefined ? undefined : resolveClientRewardClaim(record.platform, input.rewardEvidenceRegistry ?? createDefaultClientRewardEvidenceRegistry(), { allowLegacyCompletion: input.rewardEvidenceRegistry === undefined && input.gateway.ads.provider === undefined });
|
|
621
657
|
if (request === undefined || record.platform === undefined
|
|
622
|
-
||
|
|
623
|
-
&& !isClientRewardEvidence(record.platform))
|
|
658
|
+
|| candidate === undefined
|
|
624
659
|
|| request.playerId !== record.playerId || request.playerId !== input.playerId
|
|
625
660
|
|| request.target !== record.target || request.placementId !== record.input.placementId
|
|
626
661
|
|| request.idempotencyKey !== record.input.idempotencyKey
|
|
627
|
-
|| request.platformImpressionId !==
|
|
662
|
+
|| request.platformImpressionId !== candidate.platformImpressionId
|
|
663
|
+
|| request.providerId !== undefined && request.providerId !== input.gateway.ads.provider?.id
|
|
664
|
+
|| !sameRewardEvidence(request.evidence, candidate.evidence)
|
|
628
665
|
|| request.deploymentTarget !== (input.deploymentTarget === input.target
|
|
629
666
|
? undefined : input.deploymentTarget)) {
|
|
630
667
|
throw new Error('Journaled ad claim is not bound to its platform result.');
|
|
631
668
|
}
|
|
632
669
|
}
|
|
670
|
+
function sameRewardEvidence(request, candidate) {
|
|
671
|
+
if (request === undefined || candidate === undefined) {
|
|
672
|
+
return request === candidate;
|
|
673
|
+
}
|
|
674
|
+
return request.schema === candidate.schema
|
|
675
|
+
&& Object.keys(request.payload).length === Object.keys(candidate.payload).length
|
|
676
|
+
&& Object.entries(request.payload).every(([key, value]) => Object.hasOwn(candidate.payload, key) && candidate.payload[key] === value);
|
|
677
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AnalyticsSink } from '@mpgd/analytics';
|
|
|
2
2
|
import type { PlatformGateway, PlatformTarget } from '@mpgd/platform';
|
|
3
3
|
import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesClient, type GameServicesHeaderResolver } from './client.js';
|
|
4
4
|
import type { GameServicesLedgerTarget } from './types.js';
|
|
5
|
+
import type { ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
|
|
5
6
|
import { type MonetizationOperationStore, type RecoverableMonetizationClient } from './monetization-recovery.js';
|
|
6
7
|
export type GameServicesAuthorityMode = 'production' | 'non-production';
|
|
7
8
|
export type GameServicesRuntimeMode = 'disabled' | 'local' | 'http' | 'orpc';
|
|
@@ -32,6 +33,7 @@ interface CreateGameServicesRuntimeCommonInput {
|
|
|
32
33
|
readonly now?: () => string;
|
|
33
34
|
/** Durable, compare-and-swap journal for purchase and rewarded-ad recovery. */
|
|
34
35
|
readonly operationStore?: MonetizationOperationStore;
|
|
36
|
+
readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
|
|
35
37
|
}
|
|
36
38
|
export type CreateGameServicesRuntimeInput = CreateGameServicesRuntimeCommonInput & ({
|
|
37
39
|
readonly transport?: 'http';
|
package/dist/runtime.js
CHANGED
|
@@ -61,6 +61,7 @@ export function createGameServicesRuntime(input) {
|
|
|
61
61
|
? {}
|
|
62
62
|
: { analyticsSessionId: input.analyticsSessionId }),
|
|
63
63
|
...(input.now === undefined ? {} : { now: input.now }),
|
|
64
|
+
...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
|
|
64
65
|
};
|
|
65
66
|
const client = createGameServicesClient(clientInput);
|
|
66
67
|
const monetizationRecovery = input.operationStore === undefined
|
package/dist/server.js
CHANGED
|
@@ -394,11 +394,13 @@ function resolveMaxBodyBytes(maxBodyBytes) {
|
|
|
394
394
|
return resolved;
|
|
395
395
|
}
|
|
396
396
|
const gameServicesDeploymentTargetPlatforms = new Set([
|
|
397
|
+
'browser',
|
|
397
398
|
'microsoft-store',
|
|
398
399
|
'android',
|
|
399
400
|
'ios',
|
|
400
401
|
'ait',
|
|
401
402
|
'verse8',
|
|
403
|
+
'reddit',
|
|
402
404
|
]);
|
|
403
405
|
function resolveDeploymentTargetBindings(input) {
|
|
404
406
|
const bindings = {};
|
package/dist/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ClaimAdRewardResponse, PurchaseGrantFinalization, VerifyPurchaseRe
|
|
|
4
4
|
export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, VerifyPurchaseResponse, } from './operation-results.js';
|
|
5
5
|
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
6
6
|
export type GameServicesStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait'>;
|
|
7
|
-
export type GameServicesAdRewardTarget =
|
|
7
|
+
export type GameServicesAdRewardTarget = GameServicesLedgerTarget;
|
|
8
8
|
export type GameServicesLeaderboardTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit'>;
|
|
9
9
|
export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8'>;
|
|
10
10
|
export type PurchaseIdempotencyKey = string;
|
|
@@ -25,6 +25,8 @@ export interface VerifyPurchaseRequest {
|
|
|
25
25
|
}
|
|
26
26
|
export interface ClaimAdRewardRequest {
|
|
27
27
|
readonly target: GameServicesAdRewardTarget;
|
|
28
|
+
/** Versioned provider identity, independent of build target; checked by server registration. */
|
|
29
|
+
readonly providerId?: string;
|
|
28
30
|
/** Deployment config key used to resolve game-owned platform placement identifiers. */
|
|
29
31
|
readonly deploymentTarget?: string;
|
|
30
32
|
readonly playerId: string;
|
package/dist/types.js
CHANGED
|
@@ -58,6 +58,7 @@ export function assertClaimAdRewardRequest(input) {
|
|
|
58
58
|
return validateRequest(() => {
|
|
59
59
|
assertRecord(input, 'ClaimAdRewardRequest');
|
|
60
60
|
assertAdRewardTarget(input.target);
|
|
61
|
+
assertOptionalRequestString(input.providerId, 'providerId');
|
|
61
62
|
assertOptionalGameServicesDeploymentTarget(input.deploymentTarget);
|
|
62
63
|
assertRequestString(input.playerId, 'playerId');
|
|
63
64
|
assertRequestString(input.placementId, 'placementId');
|
|
@@ -158,8 +159,9 @@ function assertStoreTarget(input) {
|
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
function assertAdRewardTarget(input) {
|
|
161
|
-
if (input !== '
|
|
162
|
-
|
|
162
|
+
if (input !== 'browser' && input !== 'microsoft-store' && input !== 'android'
|
|
163
|
+
&& input !== 'ios' && input !== 'ait' && input !== 'reddit' && input !== 'verse8') {
|
|
164
|
+
throw new Error('target must be a supported platform target.');
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
export function assertGameServicesDeploymentTarget(input) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/game-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
"type": "module",
|
|
26
26
|
"sideEffects": false,
|
|
27
27
|
"exports": {
|
|
28
|
+
"./client-reward-evidence": {
|
|
29
|
+
"types": "./dist/client-reward-evidence.d.ts",
|
|
30
|
+
"default": "./dist/client-reward-evidence.js"
|
|
31
|
+
},
|
|
32
|
+
"./default-client-reward-evidence": {
|
|
33
|
+
"types": "./dist/default-client-reward-evidence.d.ts",
|
|
34
|
+
"default": "./dist/default-client-reward-evidence.js"
|
|
35
|
+
},
|
|
28
36
|
".": {
|
|
29
37
|
"types": "./dist/index.d.ts",
|
|
30
38
|
"default": "./dist/index.js"
|
|
@@ -144,6 +152,10 @@
|
|
|
144
152
|
"./operations": {
|
|
145
153
|
"types": "./dist/operations.d.ts",
|
|
146
154
|
"default": "./dist/operations.js"
|
|
155
|
+
},
|
|
156
|
+
"./ad-reward-verifier-registry": {
|
|
157
|
+
"types": "./dist/ad-reward-verifier-registry.d.ts",
|
|
158
|
+
"default": "./dist/ad-reward-verifier-registry.js"
|
|
147
159
|
}
|
|
148
160
|
},
|
|
149
161
|
"files": [
|
|
@@ -154,9 +166,9 @@
|
|
|
154
166
|
"@orpc/client": "2.0.0-beta.14",
|
|
155
167
|
"@orpc/contract": "2.0.0-beta.14",
|
|
156
168
|
"@orpc/server": "2.0.0-beta.14",
|
|
157
|
-
"@mpgd/analytics": "0.4.
|
|
158
|
-
"@mpgd/
|
|
159
|
-
"@mpgd/
|
|
169
|
+
"@mpgd/analytics": "0.4.2",
|
|
170
|
+
"@mpgd/platform": "0.16.0",
|
|
171
|
+
"@mpgd/catalog": "0.7.8"
|
|
160
172
|
},
|
|
161
173
|
"devDependencies": {
|
|
162
174
|
"@opentelemetry/api": "1.9.0",
|
|
@@ -174,8 +186,8 @@
|
|
|
174
186
|
"format": "ttsc format",
|
|
175
187
|
"fix": "ttsc fix",
|
|
176
188
|
"build": "cd ../.. && node tools/run-ttsx.mjs tools/package/build-packages.ts @mpgd/game-services",
|
|
177
|
-
"test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs",
|
|
178
|
-
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts",
|
|
189
|
+
"test:packed-transport": "pnpm build && node test/runtime-transport-package-import.mjs && node test/reward-evidence-import.mjs",
|
|
190
|
+
"test:unit": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-progress.test.ts && node tools/run-ttsx.mjs packages/game-services/src/runtime.test.ts && node tools/run-ttsx.mjs packages/game-services/src/types.test.ts && node tools/run-ttsx.mjs packages/game-services/src/server.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/google-play-publisher-client.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-purchase.test.ts && node tools/run-ttsx.mjs packages/game-services/src/microsoft-store-identity.test.ts && node tools/run-ttsx.mjs packages/game-services/src/platform-order.test.ts && node tools/run-ttsx.mjs packages/game-services/src/progress-link.test.ts && node tools/run-ttsx.mjs packages/game-services/src/notification-delivery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/admob-ssv.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-verifier.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-evidence-verification-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/apps-in-toss-partner-api.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-durability-conformance.test.ts && node tools/run-ttsx.mjs packages/game-services/src/verified-leaderboard-transport.test.ts && node tools/run-ttsx.mjs packages/game-services/src/guest-session.test.ts && node tools/run-ttsx.mjs packages/game-services/src/client-reward-evidence.test.ts && node tools/run-ttsx.mjs packages/game-services/src/ad-reward-verifier-registry.test.ts",
|
|
179
191
|
"test:recovery": "cd ../.. && node tools/run-ttsx.mjs packages/game-services/src/monetization-recovery.test.ts && node tools/run-ttsx.mjs packages/game-services/src/app-store-recovery.test.ts",
|
|
180
192
|
"test": "pnpm test:unit && pnpm test:recovery && pnpm test:packed-transport"
|
|
181
193
|
}
|