@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/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
- ...(input.purchaseGrantFinalizer === undefined
239
+ ...(purchaseGrantFinalizer === undefined
157
240
  ? {}
158
- : { purchaseGrantFinalizer: input.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[request.target] ?? request.target;
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.reason,
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
- && transaction.payload.deploymentTarget === identity.deploymentTarget;
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',