@mpgd/game-services 0.21.1 → 0.22.0
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 +30 -0
- package/dist/admob-ssv-conformance.js +3 -1
- package/dist/app-store-recovery.js +5 -0
- package/dist/client.d.ts +39 -1
- package/dist/client.js +198 -0
- package/dist/commerce-evidence-registry.d.ts +406 -0
- package/dist/commerce-evidence-registry.js +773 -0
- package/dist/commerce-ledger.d.ts +64 -0
- package/dist/commerce-ledger.js +146 -0
- package/dist/commerce-reconciler.d.ts +191 -0
- package/dist/commerce-reconciler.js +1251 -0
- package/dist/contract.d.ts +4 -1
- package/dist/contract.js +8 -0
- package/dist/evidence-verification.d.ts +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/monetization-operation-store.d.ts +20 -0
- package/dist/monetization-operation-store.js +80 -0
- package/dist/monetization-recovery.d.ts +57 -2
- package/dist/monetization-recovery.js +102 -8
- package/dist/operation-results.d.ts +14 -0
- package/dist/operations.d.ts +2 -1
- package/dist/platform-order.d.ts +47 -0
- package/dist/platform-order.js +86 -0
- package/dist/runtime.js +12 -1
- package/dist/server.d.ts +43 -1
- package/dist/server.js +305 -7
- package/dist/types.d.ts +79 -3
- package/dist/types.js +97 -6
- package/package.json +18 -6
package/dist/runtime.js
CHANGED
|
@@ -63,16 +63,27 @@ export function createGameServicesRuntime(input) {
|
|
|
63
63
|
...(input.now === undefined ? {} : { now: input.now }),
|
|
64
64
|
...(input.rewardEvidenceRegistry === undefined ? {} : { rewardEvidenceRegistry: input.rewardEvidenceRegistry }),
|
|
65
65
|
};
|
|
66
|
-
const client = createGameServicesClient(clientInput);
|
|
67
66
|
const monetizationRecovery = input.operationStore === undefined
|
|
68
67
|
? undefined
|
|
69
68
|
: createRecoverableMonetizationClient({ ...clientInput, operationStore: input.operationStore });
|
|
69
|
+
// The recovery client owns the reconciler over the durable journal; the
|
|
70
|
+
// plain client shares it instead of journaling in memory.
|
|
71
|
+
const client = createGameServicesClient({
|
|
72
|
+
...clientInput,
|
|
73
|
+
...(monetizationRecovery?.commerceReconciler === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { commerceReconciler: monetizationRecovery.commerceReconciler }),
|
|
76
|
+
});
|
|
70
77
|
let routedClient = client;
|
|
71
78
|
if (monetizationRecovery !== undefined) {
|
|
72
79
|
routedClient = {
|
|
73
80
|
...client,
|
|
74
81
|
purchase: monetizationRecovery.purchase,
|
|
75
82
|
claimRewardedAd: monetizationRecovery.claimRewardedAd,
|
|
83
|
+
// The recovery client owns the reconciler over the durable journal.
|
|
84
|
+
...(monetizationRecovery.commerceReconciler === undefined
|
|
85
|
+
? {}
|
|
86
|
+
: { commerceReconciler: monetizationRecovery.commerceReconciler }),
|
|
76
87
|
};
|
|
77
88
|
}
|
|
78
89
|
return {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type AnalyticsSink } from '@mpgd/analytics';
|
|
2
2
|
import { type AdPlacements, type ProductCatalog } from '@mpgd/catalog';
|
|
3
3
|
import { type GameServicesBackendApi, type GameServicesBackendTransport, type GameServicesBackendTransportRequest, type GameServicesBackendTransportResponse } from './client.js';
|
|
4
|
+
import { type CommerceEvidenceRegistry } from './commerce-evidence-registry.js';
|
|
4
5
|
import { type GameServicesHealthResponse } from './contract.js';
|
|
5
6
|
import { type GameServicesEvidenceVerifier, type GameServicesPurchaseGrantFinalizer } from './evidence-verification.js';
|
|
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
|
+
import { type ClaimAdRewardRequest, type ClaimAdRewardResponse, type EntitlementLedgerGrant, type EntitlementLedgerResult, type EntitlementLedgerRevocation, type EntitlementRevocationTransaction, type GameServicesAdRewardTarget, type GameServicesStoreTarget, type LeaderboardScoreTransaction, type ProductGrantTransaction, type PurchaseLedgerStatusRequest, type RecordLeaderboardScoreRequest, type RecordLeaderboardScoreResponse, type VerifyPurchaseRequest, type VerifyPurchaseResponse } from './types.js';
|
|
7
8
|
export { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
|
|
9
|
+
export { listActiveEntitlementTransactions, readPurchaseLedgerStatuses, revokeEntitlementGrant, RevocationKeyConflictError, type RevokeEntitlementGrantResult, } from './commerce-ledger.js';
|
|
8
10
|
type CorsHeaders = Record<string, string>;
|
|
9
11
|
export interface CreateGameServicesBackendInput {
|
|
10
12
|
readonly catalog: ProductCatalog;
|
|
@@ -21,6 +23,16 @@ export interface CreateGameServicesBackendInput {
|
|
|
21
23
|
readonly purchaseGrantFinalizationTimeoutMs?: number;
|
|
22
24
|
readonly purchaseGrantFinalizer?: GameServicesPurchaseGrantFinalizer;
|
|
23
25
|
/**
|
|
26
|
+
* The commerce v2 evidence registry (RFC "Evidence registry"). With it,
|
|
27
|
+
* purchase evidence is routed by `evidence.schema` to the registered
|
|
28
|
+
* verifier and finalizer, `purchaseGrantFinalizer` is not consulted for
|
|
29
|
+
* purchases, `evidenceVerifier` serves rewarded ads only, and the backend
|
|
30
|
+
* publishes `getCommerceCapabilities`. Without it the backend keeps the v1
|
|
31
|
+
* behaviour: one `evidenceVerifier` and one `purchaseGrantFinalizer` for
|
|
32
|
+
* every purchase, and no capabilities endpoint.
|
|
33
|
+
*/
|
|
34
|
+
readonly commerceEvidenceRegistry?: CommerceEvidenceRegistry;
|
|
35
|
+
/**
|
|
24
36
|
* Receives unexpected (non-validation) failures such as store errors before the
|
|
25
37
|
* transport answers with the generic `INTERNAL_ERROR` code. Defaults to `console.error`.
|
|
26
38
|
* The hook is diagnostics only: if it throws or returns a rejecting promise, the failure
|
|
@@ -55,6 +67,26 @@ export interface GameServicesStore {
|
|
|
55
67
|
findEntitlementTransactionByPlatformEvidence?(input: EntitlementPlatformEvidenceIdentity): Promise<ProductGrantTransaction | undefined>;
|
|
56
68
|
getEntitlementTransaction(ledgerEntryId: string): Promise<ProductGrantTransaction | undefined>;
|
|
57
69
|
listEntitlementTransactions(): Promise<readonly ProductGrantTransaction[]>;
|
|
70
|
+
/** The grants with the given ids (at most `ledgerReadChunkSize` per call), any order. */
|
|
71
|
+
getEntitlementTransactions?(ledgerEntryIds: readonly string[]): Promise<readonly ProductGrantTransaction[]>;
|
|
72
|
+
/**
|
|
73
|
+
* Appends a `revoke` entry (RFC "Revocation and expiry entries"). A grant is
|
|
74
|
+
* reversed once: a second revocation of the same grant, under any key,
|
|
75
|
+
* answers the first entry with `alreadyProcessed: true`, as does a replay of
|
|
76
|
+
* the same `(playerId, idempotencyKey)`; a key reused for another grant
|
|
77
|
+
* throws `RevocationKeyConflictError`, a reused `evidenceVerificationId`
|
|
78
|
+
* throws `EvidenceAlreadyProcessedError`, and an unknown grant throws.
|
|
79
|
+
* Optional: a store without it answers every ledger status read as
|
|
80
|
+
* `active` and refuses `revokeEntitlementGrant`.
|
|
81
|
+
*/
|
|
82
|
+
recordEntitlementRevocation?(input: EntitlementLedgerRevocation): Promise<EntitlementLedgerResult>;
|
|
83
|
+
/**
|
|
84
|
+
* The revocations that reverse any of the given grant entries (at most
|
|
85
|
+
* `ledgerReadChunkSize` per call), oldest first.
|
|
86
|
+
*/
|
|
87
|
+
findEntitlementRevocations?(input: {
|
|
88
|
+
readonly ledgerEntryIds: readonly string[];
|
|
89
|
+
}): Promise<readonly EntitlementRevocationTransaction[]>;
|
|
58
90
|
recordLeaderboardScore(input: RecordLeaderboardScoreRequest, options?: {
|
|
59
91
|
readonly recordedAt?: string;
|
|
60
92
|
}): Promise<RecordLeaderboardScoreResponse>;
|
|
@@ -87,6 +119,9 @@ export declare class InMemoryGameServicesStore implements GameServicesStore {
|
|
|
87
119
|
private readonly entitlementTransactionsByEvidence;
|
|
88
120
|
private readonly entitlementTransactionsByPlatformEvidence;
|
|
89
121
|
private readonly entitlementTransactionsById;
|
|
122
|
+
private readonly revocationsByKey;
|
|
123
|
+
private readonly revocationsByGrant;
|
|
124
|
+
private readonly revocationsByEvidence;
|
|
90
125
|
private readonly leaderboardTransactionsByRun;
|
|
91
126
|
private readonly leaderboardTransactionsById;
|
|
92
127
|
recordEntitlementGrant(input: EntitlementLedgerGrant): Promise<EntitlementLedgerResult>;
|
|
@@ -102,6 +137,11 @@ export declare class InMemoryGameServicesStore implements GameServicesStore {
|
|
|
102
137
|
findEntitlementTransactionByPlatformEvidence(input: EntitlementPlatformEvidenceIdentity): Promise<ProductGrantTransaction | undefined>;
|
|
103
138
|
getEntitlementTransaction(ledgerEntryId: string): Promise<ProductGrantTransaction | undefined>;
|
|
104
139
|
listEntitlementTransactions(): Promise<readonly ProductGrantTransaction[]>;
|
|
140
|
+
getEntitlementTransactions(ledgerEntryIds: readonly string[]): Promise<readonly ProductGrantTransaction[]>;
|
|
141
|
+
recordEntitlementRevocation(input: EntitlementLedgerRevocation): Promise<EntitlementLedgerResult>;
|
|
142
|
+
findEntitlementRevocations(input: {
|
|
143
|
+
readonly ledgerEntryIds: readonly string[];
|
|
144
|
+
}): Promise<readonly EntitlementRevocationTransaction[]>;
|
|
105
145
|
recordLeaderboardScore(input: RecordLeaderboardScoreRequest, options?: {
|
|
106
146
|
readonly recordedAt?: string;
|
|
107
147
|
}): Promise<RecordLeaderboardScoreResponse>;
|
|
@@ -121,6 +161,8 @@ export declare function createGameServicesRouter(backend: GameServicesBackendApi
|
|
|
121
161
|
health: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, object>;
|
|
122
162
|
commerce: {
|
|
123
163
|
verifyPurchase: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, object>;
|
|
164
|
+
getPurchaseLedgerStatus: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<import("./operation-results.js").PurchaseLedgerStatusResponse, import("./operation-results.js").PurchaseLedgerStatusResponse>, object>;
|
|
165
|
+
getCapabilities: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<import("@mpgd/platform/commerce").CommerceServiceCapabilities, import("@mpgd/platform/commerce").CommerceServiceCapabilities>, object>;
|
|
124
166
|
};
|
|
125
167
|
ads: {
|
|
126
168
|
claimReward: import("@orpc/server").ImplementedProcedure<GameServicesOrpcContext & object, object, import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, object>;
|
package/dist/server.js
CHANGED
|
@@ -3,16 +3,22 @@ import { BodyLimitHandlerPlugin, RPCHandler } from '@orpc/server/fetch';
|
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
4
|
import { resolveAdPlacementPlatformId, resolveCatalogCurrencies, resolveProductPlatformId, } from '@mpgd/catalog';
|
|
5
5
|
import { gameServicesBackendEndpoints, } from './client.js';
|
|
6
|
+
import { resolveCommerceStoreId, } from './commerce-evidence-registry.js';
|
|
7
|
+
import { isEntitlementGrantReversed, readPurchaseLedgerStatuses, RevocationKeyConflictError, } from './commerce-ledger.js';
|
|
6
8
|
import { gameServicesContract } from './contract.js';
|
|
7
9
|
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
|
|
8
|
-
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
|
|
10
|
+
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertEntitlementLedgerRevocation, assertEntitlementRevocationTransaction, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
|
|
9
11
|
import { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
|
|
10
12
|
export { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
|
|
13
|
+
export { listActiveEntitlementTransactions, readPurchaseLedgerStatuses, revokeEntitlementGrant, RevocationKeyConflictError, } from './commerce-ledger.js';
|
|
11
14
|
export class InMemoryGameServicesStore {
|
|
12
15
|
entitlementTransactionsByKey = new Map();
|
|
13
16
|
entitlementTransactionsByEvidence = new Map();
|
|
14
17
|
entitlementTransactionsByPlatformEvidence = new Map();
|
|
15
18
|
entitlementTransactionsById = new Map();
|
|
19
|
+
revocationsByKey = new Map();
|
|
20
|
+
revocationsByGrant = new Map();
|
|
21
|
+
revocationsByEvidence = new Map();
|
|
16
22
|
leaderboardTransactionsByRun = new Map();
|
|
17
23
|
leaderboardTransactionsById = new Map();
|
|
18
24
|
async recordEntitlementGrant(input) {
|
|
@@ -71,6 +77,64 @@ export class InMemoryGameServicesStore {
|
|
|
71
77
|
async listEntitlementTransactions() {
|
|
72
78
|
return [...this.entitlementTransactionsById.values()];
|
|
73
79
|
}
|
|
80
|
+
async getEntitlementTransactions(ledgerEntryIds) {
|
|
81
|
+
const found = [];
|
|
82
|
+
for (const ledgerEntryId of new Set(ledgerEntryIds)) {
|
|
83
|
+
const transaction = this.entitlementTransactionsById.get(ledgerEntryId);
|
|
84
|
+
if (transaction !== undefined) {
|
|
85
|
+
found.push(transaction);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
async recordEntitlementRevocation(input) {
|
|
91
|
+
const revocation = assertEntitlementLedgerRevocation(input);
|
|
92
|
+
const key = createRevocationIdempotencyKey(revocation);
|
|
93
|
+
const byKey = this.revocationsByKey.get(key);
|
|
94
|
+
if (byKey !== undefined) {
|
|
95
|
+
if (byKey.revokesLedgerEntryId !== revocation.revokesLedgerEntryId) {
|
|
96
|
+
throw new RevocationKeyConflictError();
|
|
97
|
+
}
|
|
98
|
+
return assertEntitlementLedgerResult({
|
|
99
|
+
ledgerEntryId: byKey.ledgerEntryId,
|
|
100
|
+
alreadyProcessed: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const byGrant = this.revocationsByGrant.get(revocation.revokesLedgerEntryId);
|
|
104
|
+
if (byGrant !== undefined) {
|
|
105
|
+
return assertEntitlementLedgerResult({
|
|
106
|
+
ledgerEntryId: byGrant.ledgerEntryId,
|
|
107
|
+
alreadyProcessed: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (revocation.evidenceVerificationId !== undefined
|
|
111
|
+
&& this.revocationsByEvidence.has(revocation.evidenceVerificationId)) {
|
|
112
|
+
throw new EvidenceAlreadyProcessedError();
|
|
113
|
+
}
|
|
114
|
+
if (!this.entitlementTransactionsById.has(revocation.revokesLedgerEntryId)) {
|
|
115
|
+
// The D1 store's foreign key refuses the same write.
|
|
116
|
+
throw new Error('Entitlement revocation references an unknown grant.');
|
|
117
|
+
}
|
|
118
|
+
const transaction = assertEntitlementRevocationTransaction({
|
|
119
|
+
...revocation,
|
|
120
|
+
ledgerEntryId: createRevocationLedgerEntryId(revocation),
|
|
121
|
+
});
|
|
122
|
+
this.revocationsByKey.set(key, transaction);
|
|
123
|
+
this.revocationsByGrant.set(revocation.revokesLedgerEntryId, transaction);
|
|
124
|
+
if (revocation.evidenceVerificationId !== undefined) {
|
|
125
|
+
this.revocationsByEvidence.set(revocation.evidenceVerificationId, transaction);
|
|
126
|
+
}
|
|
127
|
+
return assertEntitlementLedgerResult({
|
|
128
|
+
ledgerEntryId: transaction.ledgerEntryId,
|
|
129
|
+
alreadyProcessed: false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async findEntitlementRevocations(input) {
|
|
133
|
+
const wanted = new Set(input.ledgerEntryIds);
|
|
134
|
+
return [...this.revocationsByGrant.values()]
|
|
135
|
+
.filter((revocation) => wanted.has(revocation.revokesLedgerEntryId))
|
|
136
|
+
.sort((left, right) => left.revokedAt.localeCompare(right.revokedAt));
|
|
137
|
+
}
|
|
74
138
|
async recordLeaderboardScore(input, options = {}) {
|
|
75
139
|
const request = assertRecordLeaderboardScoreRequest(input);
|
|
76
140
|
const runKey = createLeaderboardRunKey(request);
|
|
@@ -141,6 +205,25 @@ export function createGameServicesBackend(input) {
|
|
|
141
205
|
now,
|
|
142
206
|
...(input.analytics === undefined ? {} : { sink: input.analytics }),
|
|
143
207
|
});
|
|
208
|
+
const registry = input.commerceEvidenceRegistry;
|
|
209
|
+
if (registry !== undefined && input.purchaseGrantFinalizer !== undefined) {
|
|
210
|
+
throw new Error('createGameServicesBackend: pass the v1 purchaseGrantFinalizer as the registry '
|
|
211
|
+
+ 'unregistered.finalizer instead of next to commerceEvidenceRegistry; with a registry '
|
|
212
|
+
+ 'the entries own finalization.');
|
|
213
|
+
}
|
|
214
|
+
// With a registry, purchase evidence is routed by schema and the registry's
|
|
215
|
+
// finalizer owns post-grant finalization; the configured verifier keeps
|
|
216
|
+
// serving rewarded ads. Without one, the v1 single verifier and finalizer
|
|
217
|
+
// serve every purchase unchanged.
|
|
218
|
+
const purchaseEvidenceVerifier = registry === undefined
|
|
219
|
+
? evidenceVerifier
|
|
220
|
+
: {
|
|
221
|
+
verifyPurchase: (verificationInput) => registry.verifyPurchase(verificationInput),
|
|
222
|
+
verifyAdReward: (verificationInput) => evidenceVerifier.verifyAdReward(verificationInput),
|
|
223
|
+
};
|
|
224
|
+
const purchaseGrantFinalizer = registry === undefined
|
|
225
|
+
? input.purchaseGrantFinalizer
|
|
226
|
+
: registry.finalizer;
|
|
144
227
|
return {
|
|
145
228
|
version,
|
|
146
229
|
purchases: {
|
|
@@ -149,19 +232,21 @@ export function createGameServicesBackend(input) {
|
|
|
149
232
|
catalog: input.catalog,
|
|
150
233
|
store,
|
|
151
234
|
now,
|
|
152
|
-
evidenceVerifier,
|
|
235
|
+
evidenceVerifier: purchaseEvidenceVerifier,
|
|
153
236
|
evidenceVerificationTimeoutMs,
|
|
154
237
|
purchaseGrantFinalizationTimeoutMs,
|
|
155
238
|
deploymentTargetBindings,
|
|
156
|
-
...(
|
|
239
|
+
...(purchaseGrantFinalizer === undefined
|
|
157
240
|
? {}
|
|
158
|
-
: { purchaseGrantFinalizer
|
|
241
|
+
: { purchaseGrantFinalizer }),
|
|
242
|
+
...(registry === undefined ? {} : { registry }),
|
|
159
243
|
}, async (verification) => {
|
|
160
244
|
await analytics.track({
|
|
161
245
|
name: verification.verified ? 'purchase_granted'
|
|
162
246
|
: verification.disposition === 'pending' ? 'purchase_pending' : 'purchase_rejected',
|
|
163
247
|
properties: {
|
|
164
248
|
target: request.target,
|
|
249
|
+
store: resolveCommerceStoreId(request),
|
|
165
250
|
playerId: request.playerId,
|
|
166
251
|
productId: request.productId,
|
|
167
252
|
ledgerEntryId: verification.ledgerEntryId,
|
|
@@ -175,6 +260,16 @@ export function createGameServicesBackend(input) {
|
|
|
175
260
|
});
|
|
176
261
|
});
|
|
177
262
|
},
|
|
263
|
+
async getPurchaseLedgerStatus(request) {
|
|
264
|
+
return readPurchaseLedgerStatuses(store, request);
|
|
265
|
+
},
|
|
266
|
+
...(registry === undefined
|
|
267
|
+
? {}
|
|
268
|
+
: {
|
|
269
|
+
async getCommerceCapabilities() {
|
|
270
|
+
return registry.getCapabilities();
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
178
273
|
},
|
|
179
274
|
adRewards: {
|
|
180
275
|
async claimAdReward(request) {
|
|
@@ -321,6 +416,22 @@ export function createGameServicesRouter(backend, options = {}) {
|
|
|
321
416
|
verifyPurchase: contract.commerce.verifyPurchase.handler(({ input }) => {
|
|
322
417
|
return invoke(() => backend.purchases.verifyPurchase(input));
|
|
323
418
|
}),
|
|
419
|
+
getPurchaseLedgerStatus: contract.commerce.getPurchaseLedgerStatus.handler(({ input }) => {
|
|
420
|
+
const read = backend.purchases.getPurchaseLedgerStatus;
|
|
421
|
+
if (read === undefined) {
|
|
422
|
+
throw new ORPCError('NOT_FOUND', { message: 'This backend has no ledger status read.' });
|
|
423
|
+
}
|
|
424
|
+
return invoke(() => read.call(backend.purchases, input));
|
|
425
|
+
}),
|
|
426
|
+
getCapabilities: contract.commerce.getCapabilities.handler(() => {
|
|
427
|
+
const read = backend.purchases.getCommerceCapabilities;
|
|
428
|
+
if (read === undefined) {
|
|
429
|
+
throw new ORPCError('NOT_FOUND', {
|
|
430
|
+
message: 'This backend publishes no commerce capabilities.',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return invoke(() => read.call(backend.purchases));
|
|
434
|
+
}),
|
|
324
435
|
}),
|
|
325
436
|
ads: contract.ads.router({
|
|
326
437
|
claimReward: contract.ads.claimReward.handler(({ input }) => {
|
|
@@ -436,7 +547,9 @@ function resolveDeploymentTargetBindings(input) {
|
|
|
436
547
|
return bindings;
|
|
437
548
|
}
|
|
438
549
|
function bindRequestDeploymentTarget(request, bindings) {
|
|
439
|
-
const deploymentTarget = bindings
|
|
550
|
+
const deploymentTarget = (Object.hasOwn(bindings, request.target)
|
|
551
|
+
? bindings[request.target]
|
|
552
|
+
: undefined) ?? request.target;
|
|
440
553
|
if (request.deploymentTarget !== undefined
|
|
441
554
|
&& request.deploymentTarget !== deploymentTarget) {
|
|
442
555
|
throw new GameServicesRequestValidationError(`deploymentTarget must match the backend binding for ${request.target}.`);
|
|
@@ -445,14 +558,58 @@ function bindRequestDeploymentTarget(request, bindings) {
|
|
|
445
558
|
? request
|
|
446
559
|
: { ...request, deploymentTarget };
|
|
447
560
|
}
|
|
561
|
+
/** The ledger targets a v1 backend (no registry) accepts: the platform targets with a ledger. */
|
|
562
|
+
const gameServicesLedgerTargets = new Set([
|
|
563
|
+
'browser',
|
|
564
|
+
'microsoft-store',
|
|
565
|
+
'android',
|
|
566
|
+
'ios',
|
|
567
|
+
'ait',
|
|
568
|
+
'reddit',
|
|
569
|
+
'verse8',
|
|
570
|
+
'oneplay',
|
|
571
|
+
]);
|
|
572
|
+
/**
|
|
573
|
+
* A target or store the backend does not serve is a 400, as an unknown v1
|
|
574
|
+
* target was: without a registry the ledger targets are the known platform
|
|
575
|
+
* targets; with one, the store must be registered (or declared for the
|
|
576
|
+
* fallback) and the target a ledger target or a served store id.
|
|
577
|
+
*/
|
|
578
|
+
function assertServedPurchaseRequest(request, registry) {
|
|
579
|
+
if (registry === undefined) {
|
|
580
|
+
if (!gameServicesLedgerTargets.has(request.target)) {
|
|
581
|
+
throw new GameServicesRequestValidationError(`target ${request.target} is not a ledger target this backend serves.`);
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const store = resolveCommerceStoreId(request);
|
|
586
|
+
if (!registry.serves(request)) {
|
|
587
|
+
throw new GameServicesRequestValidationError(`store ${store} is not a store this backend serves for target ${request.target}.`);
|
|
588
|
+
}
|
|
589
|
+
// A served store's declared targets are the contract (a custom store may
|
|
590
|
+
// name a custom ledger target); a store without declared targets keeps the
|
|
591
|
+
// ledger target set and the served store ids.
|
|
592
|
+
const allowed = registry.allowedTargets(store);
|
|
593
|
+
if (allowed !== undefined) {
|
|
594
|
+
if (!allowed.has(request.target)) {
|
|
595
|
+
throw new GameServicesRequestValidationError(`target ${request.target} is not a ledger target of store ${store}.`);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!gameServicesLedgerTargets.has(request.target) && !registry.stores.has(request.target)) {
|
|
600
|
+
throw new GameServicesRequestValidationError(`target ${request.target} is not a ledger target this backend serves.`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
448
603
|
async function verifyPurchaseWithStore(input, context, onDecision) {
|
|
449
604
|
const request = bindRequestDeploymentTarget(assertVerifyPurchaseRequest(input), context.deploymentTargetBindings);
|
|
605
|
+
assertServedPurchaseRequest(request, context.registry);
|
|
450
606
|
const retryIdentity = {
|
|
451
607
|
source: 'purchase',
|
|
452
608
|
playerId: request.playerId,
|
|
453
609
|
idempotencyKey: request.idempotencyKey,
|
|
454
610
|
grantId: request.productId,
|
|
455
611
|
target: request.target,
|
|
612
|
+
store: resolveCommerceStoreId(request),
|
|
456
613
|
...(request.deploymentTarget === undefined
|
|
457
614
|
? {}
|
|
458
615
|
: { deploymentTarget: request.deploymentTarget }),
|
|
@@ -472,6 +629,15 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
472
629
|
reason: 'IDEMPOTENCY_KEY_CONFLICT',
|
|
473
630
|
});
|
|
474
631
|
}
|
|
632
|
+
if (await isEntitlementGrantReversed(context.store, transaction.ledgerEntryId)) {
|
|
633
|
+
// A replayed key for a refunded grant is not a grant, as the lookup path answers.
|
|
634
|
+
return assertVerifyPurchaseResponse({
|
|
635
|
+
verified: false,
|
|
636
|
+
alreadyProcessed: false,
|
|
637
|
+
disposition: 'rejected',
|
|
638
|
+
reason: 'LEDGER_GRANT_REVERSED',
|
|
639
|
+
});
|
|
640
|
+
}
|
|
475
641
|
const finalization = await finalizeExistingPurchaseGrant(request, transaction, context);
|
|
476
642
|
return assertVerifyPurchaseResponse({
|
|
477
643
|
verified: true,
|
|
@@ -509,6 +675,12 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
509
675
|
timeoutMs: context.evidenceVerificationTimeoutMs,
|
|
510
676
|
});
|
|
511
677
|
}, context.evidenceVerificationTimeoutMs);
|
|
678
|
+
if (verification.status === 'settled') {
|
|
679
|
+
// A server-push lookup found the grant the host's fulfillment path wrote.
|
|
680
|
+
// Nothing is written; the entry is answered once it is checked against
|
|
681
|
+
// the requesting player, product, and target.
|
|
682
|
+
return completeSettledPurchase(request, verification, context);
|
|
683
|
+
}
|
|
512
684
|
if (verification.status !== 'verified') {
|
|
513
685
|
// A matching grant can land while provider verification is in flight. Recheck
|
|
514
686
|
// before returning a stale provider state such as an already-consumed token.
|
|
@@ -527,6 +699,14 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
527
699
|
}
|
|
528
700
|
const verificationPayload = createVerificationPayload(verification, 'platformTransactionId');
|
|
529
701
|
const platformTransactionId = resolvePlatformEvidenceId(verification, request.platformTransactionId);
|
|
702
|
+
const evidenceSchema = request.evidence?.schema;
|
|
703
|
+
// The store written is the one the registry routed to (an entry's own
|
|
704
|
+
// store, or a declared fallback store); a fallback serving undeclared
|
|
705
|
+
// stores and the v1 path record the request's resolved store.
|
|
706
|
+
const routed = context.registry?.route(request);
|
|
707
|
+
const verifiedStore = routed?.status === 'routed'
|
|
708
|
+
? routed.entry.store
|
|
709
|
+
: resolveCommerceStoreId(request);
|
|
530
710
|
const grant = await recordEntitlementGrantWithEvidence(context.store, {
|
|
531
711
|
playerId: request.playerId,
|
|
532
712
|
grantId: product.id,
|
|
@@ -538,6 +718,7 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
538
718
|
payload: {
|
|
539
719
|
...verificationPayload,
|
|
540
720
|
target: request.target,
|
|
721
|
+
store: verifiedStore,
|
|
541
722
|
...(request.deploymentTarget === undefined
|
|
542
723
|
? {}
|
|
543
724
|
: { deploymentTarget: request.deploymentTarget }),
|
|
@@ -546,6 +727,7 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
546
727
|
platformProductId,
|
|
547
728
|
...(platformTransactionId === undefined ? {} : { platformTransactionId }),
|
|
548
729
|
purchasedAt: request.purchasedAt,
|
|
730
|
+
...(evidenceSchema === undefined ? {} : { evidenceSchema }),
|
|
549
731
|
evidenceVerificationId: verification.verificationId,
|
|
550
732
|
evidenceVerifiedAt: verification.verifiedAt,
|
|
551
733
|
},
|
|
@@ -557,11 +739,21 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
557
739
|
playerId: request.playerId,
|
|
558
740
|
grantId: request.productId,
|
|
559
741
|
target: request.target,
|
|
742
|
+
store: verifiedStore,
|
|
560
743
|
...(request.deploymentTarget === undefined
|
|
561
744
|
? {}
|
|
562
745
|
: { deploymentTarget: request.deploymentTarget }),
|
|
563
746
|
evidenceVerificationId: verification.verificationId,
|
|
564
747
|
})) {
|
|
748
|
+
if (await isEntitlementGrantReversed(context.store, existingEvidence.ledgerEntryId)) {
|
|
749
|
+
// A refunded grant's evidence replayed under another key is not a grant.
|
|
750
|
+
return assertVerifyPurchaseResponse({
|
|
751
|
+
verified: false,
|
|
752
|
+
alreadyProcessed: false,
|
|
753
|
+
disposition: 'rejected',
|
|
754
|
+
reason: 'LEDGER_GRANT_REVERSED',
|
|
755
|
+
});
|
|
756
|
+
}
|
|
565
757
|
const finalization = await finalizeExistingPurchaseGrant(request, existingEvidence, context);
|
|
566
758
|
// A different idempotency key is evidence replay unless a configured finalizer can resume
|
|
567
759
|
// the provider operation. Keep generic stores fail-closed while allowing Store consume
|
|
@@ -614,6 +806,47 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
|
|
|
614
806
|
...(finalization === undefined ? {} : { finalization }),
|
|
615
807
|
});
|
|
616
808
|
}
|
|
809
|
+
/**
|
|
810
|
+
* Answers a purchase a server-push lookup verifier found settled by an earlier
|
|
811
|
+
* ledger entry. The entry must be a purchase grant of the requesting player,
|
|
812
|
+
* product, and ledger target; a fulfillment path that bound the grant to a
|
|
813
|
+
* deployment target must agree with the request when both name one.
|
|
814
|
+
*/
|
|
815
|
+
async function completeSettledPurchase(request, decision, context) {
|
|
816
|
+
const settled = await context.store.getEntitlementTransaction(decision.ledgerEntryId);
|
|
817
|
+
if (settled === undefined
|
|
818
|
+
|| settled.source !== 'purchase'
|
|
819
|
+
|| settled.playerId !== request.playerId
|
|
820
|
+
|| settled.grantId !== request.productId
|
|
821
|
+
|| settled.payload.target !== (decision.ledgerTarget ?? request.target)
|
|
822
|
+
|| (settled.payload.deploymentTarget !== undefined
|
|
823
|
+
&& request.deploymentTarget !== undefined
|
|
824
|
+
&& settled.payload.deploymentTarget !== request.deploymentTarget)) {
|
|
825
|
+
return assertVerifyPurchaseResponse({
|
|
826
|
+
verified: false,
|
|
827
|
+
alreadyProcessed: false,
|
|
828
|
+
disposition: 'rejected',
|
|
829
|
+
reason: 'LEDGER_GRANT_IDENTITY_MISMATCH',
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (await isEntitlementGrantReversed(context.store, settled.ledgerEntryId)) {
|
|
833
|
+
// `settled` is an open verifier contract; a reversed grant never settles
|
|
834
|
+
// a purchase, whichever verifier named it.
|
|
835
|
+
return assertVerifyPurchaseResponse({
|
|
836
|
+
verified: false,
|
|
837
|
+
alreadyProcessed: false,
|
|
838
|
+
disposition: 'rejected',
|
|
839
|
+
reason: 'LEDGER_GRANT_REVERSED',
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const finalization = await finalizeExistingPurchaseGrant(request, settled, context);
|
|
843
|
+
return assertVerifyPurchaseResponse({
|
|
844
|
+
verified: true,
|
|
845
|
+
ledgerEntryId: settled.ledgerEntryId,
|
|
846
|
+
alreadyProcessed: true,
|
|
847
|
+
...(finalization === undefined ? {} : { finalization }),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
617
850
|
async function finalizeExistingPurchaseGrant(request, transaction, context) {
|
|
618
851
|
if (context.purchaseGrantFinalizer === undefined) {
|
|
619
852
|
return undefined;
|
|
@@ -630,6 +863,35 @@ async function finalizeExistingPurchaseGrant(request, transaction, context) {
|
|
|
630
863
|
|| platformProductId === undefined
|
|
631
864
|
|| typeof evidenceVerificationId !== 'string'
|
|
632
865
|
|| evidenceVerificationId.length === 0) {
|
|
866
|
+
// With a registry, a store nothing finalizes on the server (client,
|
|
867
|
+
// host, or none finish) has no pending finalization to report: the
|
|
868
|
+
// registry decides from the grant's recorded store and schema, never from
|
|
869
|
+
// the stand-in product. The v1 single finalizer keeps answering pending.
|
|
870
|
+
if (context.registry !== undefined) {
|
|
871
|
+
const recordedSchema = transaction.payload.evidenceSchema;
|
|
872
|
+
const supported = context.registry.finalizer.supportsPurchaseGrant?.({
|
|
873
|
+
request: typeof recordedSchema === 'string' && recordedSchema.length > 0
|
|
874
|
+
? { ...finalizationRequest, evidence: { schema: recordedSchema, payload: {} } }
|
|
875
|
+
: finalizationRequest,
|
|
876
|
+
product: product ?? {
|
|
877
|
+
id: request.productId,
|
|
878
|
+
type: 'consumable',
|
|
879
|
+
grant: transaction.grant ?? { type: 'entitlement', entitlement: request.productId },
|
|
880
|
+
platformProductIds: {},
|
|
881
|
+
},
|
|
882
|
+
platformProductId: platformProductId ?? '',
|
|
883
|
+
evidenceVerificationId: typeof evidenceVerificationId === 'string'
|
|
884
|
+
? evidenceVerificationId
|
|
885
|
+
: '',
|
|
886
|
+
evidencePayload: transaction.payload,
|
|
887
|
+
ledgerEntryId: transaction.ledgerEntryId,
|
|
888
|
+
alreadyProcessed: true,
|
|
889
|
+
timeoutMs: context.purchaseGrantFinalizationTimeoutMs,
|
|
890
|
+
});
|
|
891
|
+
if (supported === false) {
|
|
892
|
+
return undefined;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
633
895
|
return assertPurchaseGrantFinalization({
|
|
634
896
|
status: 'pending',
|
|
635
897
|
alreadyCompleted: false,
|
|
@@ -806,7 +1068,10 @@ async function claimAdRewardForRetry(request, retryIdentity, context) {
|
|
|
806
1068
|
disposition: verification.status === 'pending' ? 'pending' : 'rejected',
|
|
807
1069
|
reason: verification.status === 'pending'
|
|
808
1070
|
? (verification.reason ?? 'EVIDENCE_PENDING')
|
|
809
|
-
: verification.
|
|
1071
|
+
: verification.status === 'settled'
|
|
1072
|
+
// Only purchase verification reads a settled ledger entry.
|
|
1073
|
+
? 'EVIDENCE_VERIFIER_ERROR'
|
|
1074
|
+
: verification.reason,
|
|
810
1075
|
});
|
|
811
1076
|
}
|
|
812
1077
|
// The client-reported impression id only becomes part of the globally unique platform
|
|
@@ -946,6 +1211,10 @@ function assertEvidenceVerificationDecision(decision) {
|
|
|
946
1211
|
}
|
|
947
1212
|
return decision;
|
|
948
1213
|
}
|
|
1214
|
+
if (decision.status === 'settled') {
|
|
1215
|
+
assertNonEmptyDecisionString(decision.ledgerEntryId, 'ledgerEntryId');
|
|
1216
|
+
return decision;
|
|
1217
|
+
}
|
|
949
1218
|
if (decision.status === 'pending') {
|
|
950
1219
|
if (decision.reason !== undefined) {
|
|
951
1220
|
assertNonEmptyDecisionString(decision.reason, 'reason');
|
|
@@ -1136,7 +1405,16 @@ function matchesEntitlementTransactionIdentity(transaction, identity) {
|
|
|
1136
1405
|
&& transaction.playerId === identity.playerId
|
|
1137
1406
|
&& transaction.grantId === identity.grantId
|
|
1138
1407
|
&& transaction.payload.target === identity.target
|
|
1139
|
-
|
|
1408
|
+
// Two stores sharing one ledger target never share a grant: a key reused
|
|
1409
|
+
// with another store is a conflict, while a grant recorded before the
|
|
1410
|
+
// store was written meets any store.
|
|
1411
|
+
&& (identity.store === undefined
|
|
1412
|
+
|| transaction.payload.store === undefined
|
|
1413
|
+
|| transaction.payload.store === identity.store)
|
|
1414
|
+
// A grant written without a deployment target (a host fulfillment path)
|
|
1415
|
+
// meets the request bound to one; two bound targets must agree.
|
|
1416
|
+
&& (transaction.payload.deploymentTarget === undefined
|
|
1417
|
+
|| transaction.payload.deploymentTarget === identity.deploymentTarget);
|
|
1140
1418
|
}
|
|
1141
1419
|
function resolveEvidenceVerificationTimeout(timeoutMs) {
|
|
1142
1420
|
const resolved = timeoutMs ?? 10_000;
|
|
@@ -1166,6 +1444,16 @@ async function routeGameServicesRequest(endpoint, body, backend) {
|
|
|
1166
1444
|
switch (endpoint) {
|
|
1167
1445
|
case gameServicesBackendEndpoints.verifyPurchase:
|
|
1168
1446
|
return okResponse(await backend.purchases.verifyPurchase(body));
|
|
1447
|
+
case gameServicesBackendEndpoints.getPurchaseLedgerStatus:
|
|
1448
|
+
if (backend.purchases.getPurchaseLedgerStatus === undefined) {
|
|
1449
|
+
return errorResponse(404, 'UNKNOWN_ENDPOINT');
|
|
1450
|
+
}
|
|
1451
|
+
return okResponse(await backend.purchases.getPurchaseLedgerStatus(body));
|
|
1452
|
+
case gameServicesBackendEndpoints.getCommerceCapabilities:
|
|
1453
|
+
if (backend.purchases.getCommerceCapabilities === undefined) {
|
|
1454
|
+
return errorResponse(404, 'UNKNOWN_ENDPOINT');
|
|
1455
|
+
}
|
|
1456
|
+
return okResponse(await backend.purchases.getCommerceCapabilities());
|
|
1169
1457
|
case gameServicesBackendEndpoints.claimAdReward:
|
|
1170
1458
|
return okResponse(await backend.adRewards.claimAdReward(body));
|
|
1171
1459
|
case gameServicesBackendEndpoints.recordLeaderboardScore:
|
|
@@ -1219,6 +1507,16 @@ function createEntitlementEvidenceKey(grant) {
|
|
|
1219
1507
|
function createEntitlementPlatformEvidenceKey(identity) {
|
|
1220
1508
|
return createCompositeKey([identity.source, identity.target, identity.platformEvidenceId]);
|
|
1221
1509
|
}
|
|
1510
|
+
function createRevocationIdempotencyKey(revocation) {
|
|
1511
|
+
return createCompositeKey(['revoke', revocation.playerId, revocation.idempotencyKey]);
|
|
1512
|
+
}
|
|
1513
|
+
function createRevocationLedgerEntryId(revocation) {
|
|
1514
|
+
return [
|
|
1515
|
+
'revoke',
|
|
1516
|
+
encodeIdSegment(revocation.playerId),
|
|
1517
|
+
encodeIdSegment(revocation.idempotencyKey),
|
|
1518
|
+
].join('_');
|
|
1519
|
+
}
|
|
1222
1520
|
function createEntitlementLedgerEntryId(grant) {
|
|
1223
1521
|
return [
|
|
1224
1522
|
'ledger',
|