@mpgd/game-services 0.21.1 → 0.23.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/notification-delivery.d.ts +9 -1
- package/dist/notification-delivery.js +45 -5
- 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.d.ts +7 -0
- package/dist/runtime.js +13 -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/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',
|
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import type { ProductGrant } from '@mpgd/catalog';
|
|
2
2
|
import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformEvidenceEnvelope, PlatformTarget } from '@mpgd/platform';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type { StoreId } from '@mpgd/platform/commerce';
|
|
4
|
+
import type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, VerifyPurchaseResponse } from './operation-results.js';
|
|
5
|
+
export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, PurchaseLedgerStatus, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, VerifyPurchaseResponse, } from './operation-results.js';
|
|
5
6
|
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The closed v1 purchase target list. The deployment target of a v1 verify
|
|
9
|
+
* request names its store implicitly (`android` is Google Play, `ios` the App
|
|
10
|
+
* Store, `ait` Apps in Toss); `resolveLegacyCommerceStoreId` is that mapping.
|
|
11
|
+
*
|
|
12
|
+
* @deprecated `VerifyPurchaseRequest.target` is open since commerce v2 C2
|
|
13
|
+
* (`GameServicesStoreTarget` is `StoreId`); C4 removes this alias.
|
|
14
|
+
*/
|
|
15
|
+
export type GameServicesLegacyStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait' | 'oneplay'>;
|
|
16
|
+
/**
|
|
17
|
+
* An open store or deployment target identifier (RFC "Open store ids"). The
|
|
18
|
+
* request validator bounds its shape; the backend's commerce evidence
|
|
19
|
+
* registry decides which stores it serves.
|
|
20
|
+
*/
|
|
21
|
+
export type GameServicesStoreTarget = StoreId;
|
|
7
22
|
export type GameServicesAdRewardTarget = GameServicesLedgerTarget;
|
|
8
23
|
export type GameServicesLeaderboardTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit'>;
|
|
9
24
|
export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'microsoft-store' | 'android' | 'ios' | 'ait' | 'reddit' | 'verse8' | 'oneplay'>;
|
|
@@ -13,7 +28,17 @@ export type EntitlementIdempotencyKey = PurchaseIdempotencyKey | AdRewardIdempot
|
|
|
13
28
|
export type EntitlementLedgerSource = 'purchase' | 'ad_reward' | 'admin';
|
|
14
29
|
export type EntitlementLedgerPayload = Record<string, string | number | boolean>;
|
|
15
30
|
export interface VerifyPurchaseRequest {
|
|
31
|
+
/** The ledger (deployment platform) target; see `store` for the store the purchase was made in. */
|
|
16
32
|
readonly target: GameServicesStoreTarget;
|
|
33
|
+
/**
|
|
34
|
+
* The open store id the purchase was made in (RFC "Open store ids"), for
|
|
35
|
+
* example `google-play` or `credit-wallet`. A v1 request without it names
|
|
36
|
+
* its store through `target`: `android` is `google-play`, `ios` is
|
|
37
|
+
* `app-store`, `ait` is `apps-in-toss`, `reddit` is `devvit`, and every
|
|
38
|
+
* other target (`microsoft-store`, `verse8`, `oneplay`) is its own store id
|
|
39
|
+
* (`resolveLegacyCommerceStoreId`).
|
|
40
|
+
*/
|
|
41
|
+
readonly store?: StoreId;
|
|
17
42
|
/** Deployment config key used to resolve game-owned platform product identifiers. */
|
|
18
43
|
readonly deploymentTarget?: string;
|
|
19
44
|
readonly playerId: string;
|
|
@@ -62,6 +87,44 @@ export interface EntitlementLedgerResult {
|
|
|
62
87
|
readonly ledgerEntryId: string;
|
|
63
88
|
readonly alreadyProcessed: boolean;
|
|
64
89
|
}
|
|
90
|
+
/** Why a grant was reversed (RFC "Revocation and expiry entries"). */
|
|
91
|
+
export type EntitlementRevocationReason = 'refund' | 'chargeback' | 'voided' | 'admin';
|
|
92
|
+
/**
|
|
93
|
+
* A `revoke` ledger entry: it references the grant it reverses and never
|
|
94
|
+
* removes it, so the ledger stays append-only. Store notification handlers
|
|
95
|
+
* (a Play voided purchase, an App Store refund, an Apps in Toss or Devvit
|
|
96
|
+
* refunded order) write one through `revokeEntitlementGrant`; the entry's
|
|
97
|
+
* `idempotencyKey` makes the notification replay-safe.
|
|
98
|
+
*/
|
|
99
|
+
export interface EntitlementLedgerRevocation {
|
|
100
|
+
readonly playerId: string;
|
|
101
|
+
/** The `ledgerEntryId` of the grant this entry reverses. */
|
|
102
|
+
readonly revokesLedgerEntryId: string;
|
|
103
|
+
readonly idempotencyKey: string;
|
|
104
|
+
readonly reason: EntitlementRevocationReason;
|
|
105
|
+
readonly revokedAt: string;
|
|
106
|
+
readonly payload: EntitlementLedgerPayload;
|
|
107
|
+
/** The notification's own authority identity, when the handler has one. */
|
|
108
|
+
readonly evidenceVerificationId?: string;
|
|
109
|
+
}
|
|
110
|
+
export interface EntitlementRevocationTransaction extends EntitlementLedgerRevocation {
|
|
111
|
+
readonly ledgerEntryId: string;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A subscription `period` ledger entry: it extends an earlier grant with a
|
|
115
|
+
* renewal period and its expiry. This is the shape only; no reference store
|
|
116
|
+
* verifier writes one until a subscription verifier exists.
|
|
117
|
+
*/
|
|
118
|
+
export interface EntitlementLedgerPeriod {
|
|
119
|
+
readonly playerId: string;
|
|
120
|
+
/** The `ledgerEntryId` of the subscription grant this period extends. */
|
|
121
|
+
readonly extendsLedgerEntryId: string;
|
|
122
|
+
readonly idempotencyKey: string;
|
|
123
|
+
readonly periodIndex: number;
|
|
124
|
+
readonly startedAt: string;
|
|
125
|
+
readonly expiresAt: string;
|
|
126
|
+
readonly payload: EntitlementLedgerPayload;
|
|
127
|
+
}
|
|
65
128
|
export interface ProductGrantTransaction {
|
|
66
129
|
readonly ledgerEntryId: string;
|
|
67
130
|
readonly playerId: string;
|
|
@@ -83,6 +146,14 @@ export interface LeaderboardScoreTransaction extends RecordLeaderboardScoreReque
|
|
|
83
146
|
*/
|
|
84
147
|
export declare function assertVerifyPurchaseRequest(input: VerifyPurchaseRequest): VerifyPurchaseRequest;
|
|
85
148
|
export declare function assertVerifyPurchaseResponse(input: VerifyPurchaseResponse): VerifyPurchaseResponse;
|
|
149
|
+
/** Bounds one ledger status read; a client asks for the grants of one journal window. */
|
|
150
|
+
export declare const maxPurchaseLedgerStatusEntries = 256;
|
|
151
|
+
/**
|
|
152
|
+
* Validates an untrusted ledger status read. Failures throw
|
|
153
|
+
* `GameServicesRequestValidationError` so transports can answer with a stable 400.
|
|
154
|
+
*/
|
|
155
|
+
export declare function assertPurchaseLedgerStatusRequest(input: PurchaseLedgerStatusRequest): PurchaseLedgerStatusRequest;
|
|
156
|
+
export declare function assertPurchaseLedgerStatusResponse(input: PurchaseLedgerStatusResponse): PurchaseLedgerStatusResponse;
|
|
86
157
|
export declare function assertPurchaseGrantFinalization(input: PurchaseGrantFinalization): PurchaseGrantFinalization;
|
|
87
158
|
/**
|
|
88
159
|
* Validates an untrusted ad reward claim. Failures throw
|
|
@@ -98,6 +169,11 @@ export declare function assertRecordLeaderboardScoreRequest(input: RecordLeaderb
|
|
|
98
169
|
export declare function assertRecordLeaderboardScoreResponse(input: RecordLeaderboardScoreResponse): RecordLeaderboardScoreResponse;
|
|
99
170
|
export declare function assertEntitlementLedgerGrant(input: EntitlementLedgerGrant): EntitlementLedgerGrant;
|
|
100
171
|
export declare function assertEntitlementLedgerResult(input: EntitlementLedgerResult): EntitlementLedgerResult;
|
|
172
|
+
export declare function assertEntitlementLedgerRevocation(input: EntitlementLedgerRevocation): EntitlementLedgerRevocation;
|
|
173
|
+
export declare function assertEntitlementRevocationTransaction(input: EntitlementRevocationTransaction): EntitlementRevocationTransaction;
|
|
174
|
+
export declare function assertEntitlementLedgerPeriod(input: EntitlementLedgerPeriod): EntitlementLedgerPeriod;
|
|
101
175
|
export declare function assertProductGrantTransaction(input: ProductGrantTransaction): ProductGrantTransaction;
|
|
102
176
|
export declare function assertLeaderboardScoreTransaction(input: LeaderboardScoreTransaction): LeaderboardScoreTransaction;
|
|
177
|
+
/** A store id is a bounded kebab-case identifier such as `google-play` or `credit-wallet`. */
|
|
178
|
+
export declare function assertCommerceStoreId(input: unknown, label?: string): asserts input is StoreId;
|
|
103
179
|
export declare function assertGameServicesDeploymentTarget(input: unknown): asserts input is string;
|