@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/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',
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 { ClaimAdRewardResponse, PurchaseGrantFinalization, VerifyPurchaseResponse } from './operation-results.js';
4
- export type { ClaimAdRewardResponse, PurchaseGrantFinalization, PurchaseGrantFinalizationAction, VerifyPurchaseResponse, } from './operation-results.js';
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
- export type GameServicesStoreTarget = Extract<PlatformTarget, 'microsoft-store' | 'android' | 'ios' | 'ait' | 'oneplay'>;
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;