@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 CHANGED
@@ -20,6 +20,36 @@ installed by game clients, and no other subpath imports it. It moved here from
20
20
  `@mpgd/adapter-capacitor/app-store-server`, which stays as a deprecated
21
21
  re-export for one release.
22
22
 
23
+ ## Commerce v2 reconciler
24
+
25
+ `createCommerceReconciler` from `@mpgd/game-services/commerce-reconciler` is
26
+ the RFC "Reconciler" over any commerce v2 `CommerceProvider`: it journals every
27
+ purchase as a `commerce-purchase` record of the `MonetizationOperationStore`,
28
+ sends the saved verify request to the backend, finishes a purchase only after
29
+ the ledger grant, and never grants on the client. `createGameServicesClient`
30
+ purchases through it whenever `gateway.commerceV2` exists and exposes it as
31
+ `commerceReconciler`; `createRecoverableMonetizationClient` and
32
+ `createGameServicesRuntime` run it from `monetizationRecovery.reconcile()` and
33
+ `restorePurchases()` over the durable journal. See
34
+ [docs/MONETIZATION_RECOVERY.md](../../docs/MONETIZATION_RECOVERY.md).
35
+
36
+ ## Commerce evidence registry
37
+
38
+ `createCommerceEvidenceRegistry` from
39
+ `@mpgd/game-services/commerce-evidence-registry` is the RFC "Evidence
40
+ registry": the backend routes every verify request by `evidence.schema` to the
41
+ registered verifier and finalizer, publishes the stores it accepts through
42
+ `getCommerceCapabilities` (`CommerceServiceCapabilities`), and rejects an
43
+ unknown schema (`EVIDENCE_SCHEMA_UNSUPPORTED`) or one registered for another
44
+ store (`EVIDENCE_SCHEMA_STORE_MISMATCH`) before any verifier runs. Reference
45
+ entries wrap the Play, App Store, Microsoft Store, and Apps in Toss verifiers,
46
+ and the server-push stores (`devvit.order.v1`, `verse8.agent8-event.v1`) get
47
+ lookup verifiers that answer the grant the host's fulfillment path already
48
+ wrote, never the client envelope. `@mpgd/game-services/commerce-ledger` adds
49
+ the `revoke` entry write path (`revokeEntitlementGrant`), the ledger status
50
+ read behind `getPurchaseLedgerStatus`, and `listActiveEntitlementTransactions`.
51
+ See [docs/GAME_SERVICES_BACKEND.md](../../docs/GAME_SERVICES_BACKEND.md).
52
+
23
53
  ## Registered rewarded-ad evidence
24
54
 
25
55
  `createGameServicesClient`, `createRecoverableMonetizationClient`, and
@@ -241,7 +241,9 @@ function fixedPublicKeySource(publicKeys) {
241
241
  };
242
242
  }
243
243
  function assertDecision(decision, status, reason) {
244
- const receivedReason = decision.status === 'verified' ? undefined : decision.reason;
244
+ const receivedReason = decision.status === 'verified' || decision.status === 'settled'
245
+ ? undefined
246
+ : decision.reason;
245
247
  assert(decision.status === status && receivedReason === reason, `expected ${status}/${reason}, received ${decision.status}/${String(receivedReason)}`);
246
248
  }
247
249
  function assert(condition, message) {
@@ -177,6 +177,11 @@ async function verifyRestoredNonConsumable(input, request, options) {
177
177
  if (decision.status === 'rejected') {
178
178
  return rejected(decision.reason);
179
179
  }
180
+ if (decision.status === 'settled') {
181
+ // Restored non-consumables are re-verified against Apple, never settled
182
+ // by a ledger lookup.
183
+ return rejected('APP_STORE_RESTORE_EVIDENCE_MISMATCH');
184
+ }
180
185
  const originalTransactionId = decision.payload?.appStoreOriginalTransactionId;
181
186
  const environment = decision.payload?.appStoreEnvironment;
182
187
  const bundleId = decision.payload?.appStoreBundleId;
package/dist/client.d.ts CHANGED
@@ -1,13 +1,30 @@
1
1
  import { type AnalyticsSink } from '@mpgd/analytics';
2
2
  import type { LeaderboardScoreInput, PlatformGateway, PlatformTarget } from '@mpgd/platform';
3
+ import type { CommerceServiceCapabilities } from '@mpgd/platform/commerce';
3
4
  import { type ClientRewardEvidenceRegistry } from './client-reward-evidence.js';
5
+ import { type CommerceReconciler, type CommerceReconcilerEvent, type CommerceReconcilerProduct } from './commerce-reconciler.js';
6
+ import type { MonetizationOperationStore } from './monetization-recovery.js';
4
7
  import type { GameServicesOperationClient } from './operations.js';
5
8
  export type { GameServicesOperationClient, GameServicesPurchaseInput, GameServicesPurchaseResult, GameServicesRewardedAdInput, GameServicesRewardedAdResult, } from './operations.js';
6
9
  import type { GameServicesContractClient } from './contract.js';
7
- import type { ClaimAdRewardRequest, ClaimAdRewardResponse, GameServicesLedgerTarget, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
10
+ import type { ClaimAdRewardRequest, ClaimAdRewardResponse, GameServicesLedgerTarget, PurchaseLedgerStatusRequest, PurchaseLedgerStatusResponse, RecordLeaderboardScoreRequest, RecordLeaderboardScoreResponse, VerifyPurchaseRequest, VerifyPurchaseResponse } from './types.js';
8
11
  export type { GameServicesOperationKind, GameServicesOperationLocation, GameServicesOperationOptions, GameServicesOperationProgress, GameServicesPurchaseProgress, GameServicesRewardedAdProgress, } from './operation-progress.js';
9
12
  export interface PurchaseVerificationApi {
10
13
  verifyPurchase(input: VerifyPurchaseRequest): Promise<VerifyPurchaseResponse>;
14
+ /**
15
+ * Whether earlier ledger grants are still active (RFC "Revocation and
16
+ * expiry entries"). The commerce reconciler runs its revocation pass only
17
+ * when a backend offers this read; the reference server offers it since C2.
18
+ */
19
+ getPurchaseLedgerStatus?(input: PurchaseLedgerStatusRequest): Promise<PurchaseLedgerStatusResponse>;
20
+ /**
21
+ * What the backend accepts (RFC "Provider capabilities endpoint"): the
22
+ * stores its commerce evidence registry serves, their schemas, and who
23
+ * finishes. The commerce reconciler compares it with its provider at start
24
+ * when a backend offers it; the reference server offers it when it was
25
+ * created with a `commerceEvidenceRegistry`.
26
+ */
27
+ getCommerceCapabilities?(): Promise<CommerceServiceCapabilities>;
11
28
  }
12
29
  export interface AdRewardClaimApi {
13
30
  claimAdReward(input: ClaimAdRewardRequest): Promise<ClaimAdRewardResponse>;
@@ -23,6 +40,8 @@ export interface GameServicesBackendApi {
23
40
  }
24
41
  export declare const gameServicesBackendEndpoints: {
25
42
  readonly verifyPurchase: '/game-services/purchases/verify';
43
+ readonly getPurchaseLedgerStatus: '/game-services/purchases/ledger-status';
44
+ readonly getCommerceCapabilities: '/game-services/purchases/capabilities';
26
45
  readonly claimAdReward: '/game-services/ad-rewards/claim';
27
46
  readonly recordLeaderboardScore: '/game-services/leaderboard/record';
28
47
  };
@@ -89,6 +108,12 @@ export declare function resolveGameServicesRequestHeaders(input: {
89
108
  }): Promise<Readonly<Record<string, string>>>;
90
109
  export interface GameServicesClient extends GameServicesOperationClient {
91
110
  submitLeaderboardScore(input: GameServicesLeaderboardInput): Promise<GameServicesLeaderboardResult>;
111
+ /**
112
+ * The commerce v2 reconciler behind `purchase` when the gateway exposes
113
+ * `commerceV2`. Call `start()` at runtime start and `reconcile()` on app
114
+ * resume; `purchase` starts it on first use.
115
+ */
116
+ readonly commerceReconciler?: CommerceReconciler;
92
117
  }
93
118
  export interface CreateGameServicesClientInput {
94
119
  readonly gateway: PlatformGateway;
@@ -103,6 +128,19 @@ export interface CreateGameServicesClientInput {
103
128
  readonly requestNow?: () => string;
104
129
  /** Explicit client decoders only; server verification is configured independently. */
105
130
  readonly rewardEvidenceRegistry?: ClientRewardEvidenceRegistry;
131
+ /**
132
+ * Journal of the commerce v2 reconciler used when `gateway.commerceV2`
133
+ * exists. Without one the journal lives in memory for this session only:
134
+ * duplicates are then caught by the backend ledger, not by the client.
135
+ */
136
+ readonly operationStore?: MonetizationOperationStore;
137
+ /** A reconciler shared with another client, for example the recovery client's. */
138
+ readonly commerceReconciler?: CommerceReconciler;
139
+ /** Receives the one-time warning about a session-only commerce journal; defaults to `console.warn`. */
140
+ readonly warn?: (message: string) => void;
141
+ /** Catalog kinds and SKUs the reconciler resolves without a store read. */
142
+ readonly commerceProducts?: readonly CommerceReconcilerProduct[];
143
+ readonly onCommerceEvent?: (event: CommerceReconcilerEvent) => void;
106
144
  }
107
145
  export interface GameServicesLeaderboardInput extends LeaderboardScoreInput {
108
146
  }
package/dist/client.js CHANGED
@@ -3,10 +3,14 @@ import { RPCLink } from '@orpc/client/fetch';
3
3
  import { createAnalyticsReporter } from '@mpgd/analytics';
4
4
  import { findAdapterSettledGrant } from './authoritative-purchase.js';
5
5
  import { resolveClientRewardClaim, } from './client-reward-evidence.js';
6
+ import { createCommerceReconciler, toGameServicesPurchaseResult, } from './commerce-reconciler.js';
6
7
  import { createDefaultClientRewardEvidenceRegistry } from './default-client-reward-evidence.js';
8
+ import { createInMemoryMonetizationOperationStore } from './monetization-operation-store.js';
7
9
  import { observeGameServicesOperation } from './operation-progress.js';
8
10
  export const gameServicesBackendEndpoints = {
9
11
  verifyPurchase: '/game-services/purchases/verify',
12
+ getPurchaseLedgerStatus: '/game-services/purchases/ledger-status',
13
+ getCommerceCapabilities: '/game-services/purchases/capabilities',
10
14
  claimAdReward: '/game-services/ad-rewards/claim',
11
15
  recordLeaderboardScore: '/game-services/leaderboard/record',
12
16
  };
@@ -66,8 +70,190 @@ export function createGameServicesClient(input) {
66
70
  now,
67
71
  ...(input.analytics === undefined ? {} : { sink: input.analytics }),
68
72
  });
73
+ const commerceProvider = input.gateway.commerceV2;
74
+ let commerceReconciler;
75
+ if (commerceProvider !== undefined) {
76
+ if (input.commerceReconciler === undefined && input.operationStore === undefined) {
77
+ (input.warn ?? console.warn)('@mpgd/game-services: the commerce v2 reconciler is journaling in memory for this session '
78
+ + 'because no operationStore was given; purchases are not recoverable across restarts and '
79
+ + 'the backend ledger is the only duplicate backstop. Pass a durable MonetizationOperationStore.');
80
+ }
81
+ commerceReconciler = input.commerceReconciler ?? createCommerceReconciler({
82
+ provider: commerceProvider,
83
+ backend: input.backend.purchases,
84
+ store: input.operationStore ?? createInMemoryMonetizationOperationStore(),
85
+ playerId: input.playerId,
86
+ target: input.target,
87
+ ...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
88
+ ...(input.commerceProducts === undefined ? {} : { products: input.commerceProducts }),
89
+ now: requestNow,
90
+ });
91
+ if (input.onCommerceEvent !== undefined) {
92
+ commerceReconciler.subscribe(input.onCommerceEvent);
93
+ }
94
+ }
95
+ /** Keys of purchase calls in flight, and those whose backend verdict the observer tracked meanwhile. */
96
+ const inFlightKeys = new Set();
97
+ const trackedVerdicts = new Set();
98
+ const markTracked = (key) => {
99
+ if (inFlightKeys.has(key)) {
100
+ trackedVerdicts.add(key);
101
+ }
102
+ };
103
+ if (commerceReconciler !== undefined && input.commerceReconciler === undefined) {
104
+ // Verdicts arrive as events, inside or after a purchase call; each is tracked once.
105
+ commerceReconciler.subscribe((event) => {
106
+ if (event.type !== 'transition' || event.previous === undefined) {
107
+ return;
108
+ }
109
+ const { record } = event;
110
+ const productId = record.logicalId;
111
+ if (event.previous === 'verifying' && record.state === 'granted') {
112
+ markTracked(record.idempotencyKey);
113
+ analytics.track({
114
+ name: 'purchase_granted',
115
+ properties: {
116
+ productId,
117
+ status: 'granted',
118
+ ledgerEntryId: record.ledgerEntryId,
119
+ alreadyProcessed: record.response?.alreadyProcessed,
120
+ },
121
+ }).catch(() => { });
122
+ }
123
+ else if (event.previous === 'verifying' && record.state === 'rejected') {
124
+ markTracked(record.idempotencyKey);
125
+ analytics.track({
126
+ name: 'purchase_rejected',
127
+ properties: { productId, status: 'rejected', reason: record.response?.reason },
128
+ }).catch(() => { });
129
+ }
130
+ else if (event.previous === 'verifying' && record.state === 'pending') {
131
+ markTracked(record.idempotencyKey);
132
+ analytics.track({
133
+ name: 'purchase_pending',
134
+ properties: {
135
+ productId,
136
+ status: 'pending',
137
+ reason: record.response?.reason ?? 'purchase_pending',
138
+ },
139
+ }).catch(() => { });
140
+ }
141
+ else if (record.state === 'cancelled' || record.state === 'failed') {
142
+ markTracked(record.idempotencyKey);
143
+ analytics.track({
144
+ name: 'purchase_rejected',
145
+ properties: { productId, status: record.state, reason: record.error?.code },
146
+ }).catch(() => { });
147
+ }
148
+ });
149
+ }
69
150
  return {
151
+ ...(commerceReconciler === undefined ? {} : { commerceReconciler }),
70
152
  async purchase(purchaseInput, options) {
153
+ const reconciler = commerceReconciler;
154
+ if (reconciler !== undefined) {
155
+ return observeGameServicesOperation('purchase', options, async (progress) => {
156
+ const key = purchaseInput.idempotencyKey;
157
+ trackedVerdicts.delete(key);
158
+ inFlightKeys.add(key);
159
+ if (!reconciler.started) {
160
+ await reconciler.start();
161
+ }
162
+ // The store phase ends when the journal records what the store answered;
163
+ // the reconcile pass inside requestPurchase then reports the server phases.
164
+ let platformReported = false;
165
+ let serverRequested = false;
166
+ let serverResulted = false;
167
+ const reportPlatform = (status) => {
168
+ if (!platformReported) {
169
+ platformReported = true;
170
+ progress.platformResult(status);
171
+ }
172
+ };
173
+ const unsubscribe = reconciler.subscribe((event) => {
174
+ if (event.type === 'failure' || event.record.idempotencyKey !== key) {
175
+ return;
176
+ }
177
+ if (event.type === 'transition') {
178
+ const { state } = event.record;
179
+ if (state === 'purchased') {
180
+ reportPlatform('completed');
181
+ }
182
+ else if (state === 'pending' && event.previous === 'launched') {
183
+ reportPlatform('pending');
184
+ }
185
+ else if ((state === 'cancelled' || state === 'failed') && event.previous !== 'verifying') {
186
+ reportPlatform(state);
187
+ }
188
+ }
189
+ else if (event.type === 'verify-requested') {
190
+ reportPlatform('completed');
191
+ // A server-owned finish may re-read finalization in the same
192
+ // pass; the server phase is reported once per call.
193
+ if (!serverRequested) {
194
+ serverRequested = true;
195
+ progress.serverRequested();
196
+ }
197
+ }
198
+ else if (!serverResulted) {
199
+ serverResulted = true;
200
+ progress.serverResult(event.response.verified);
201
+ }
202
+ });
203
+ try {
204
+ progress.platformRequested();
205
+ const outcome = await reconciler.requestPurchase({
206
+ logicalId: purchaseInput.productId,
207
+ idempotencyKey: key,
208
+ source: purchaseInput.source,
209
+ });
210
+ reportPlatform(outcome.outcome === 'launched'
211
+ ? 'pending'
212
+ : outcome.outcome === 'purchased'
213
+ ? (outcome.purchase.state === 'purchased' ? 'completed' : 'pending')
214
+ : outcome.outcome);
215
+ const record = await reconciler.read(key);
216
+ const result = record === undefined
217
+ ? {
218
+ status: 'failed',
219
+ purchase: {
220
+ status: 'failed',
221
+ entitlementIds: [],
222
+ ...(outcome.outcome === 'cancelled' || outcome.outcome === 'failed'
223
+ ? { diagnostic: { code: outcome.error.code, retryable: outcome.error.retryable } }
224
+ : {}),
225
+ },
226
+ }
227
+ : toGameServicesPurchaseResult(record);
228
+ if (!trackedVerdicts.has(key)) {
229
+ // The call ended without a backend verdict (the store answers later,
230
+ // or a journaled attempt was replayed); report the status it returns.
231
+ await analytics.track({
232
+ name: result.status === 'granted'
233
+ ? 'purchase_granted'
234
+ : result.status === 'pending'
235
+ ? 'purchase_pending'
236
+ : 'purchase_rejected',
237
+ properties: {
238
+ productId: purchaseInput.productId,
239
+ status: result.status,
240
+ ledgerEntryId: result.ledgerEntryId,
241
+ alreadyProcessed: result.verification?.alreadyProcessed,
242
+ reason: result.status === 'pending'
243
+ ? 'purchase_pending'
244
+ : (result.verification?.reason ?? record?.error?.code),
245
+ },
246
+ });
247
+ }
248
+ return result;
249
+ }
250
+ finally {
251
+ unsubscribe();
252
+ inFlightKeys.delete(key);
253
+ trackedVerdicts.delete(key);
254
+ }
255
+ });
256
+ }
71
257
  return observeGameServicesOperation('purchase', options, async (progress) => {
72
258
  const target = input.target;
73
259
  if (!isGameServicesCommerceTarget(target)) {
@@ -313,6 +499,12 @@ export function createGameServicesHttpBackendApi(input) {
313
499
  async verifyPurchase(body) {
314
500
  return sendGameServicesBackendRequest(input.transport, gameServicesBackendEndpoints.verifyPurchase, body);
315
501
  },
502
+ async getPurchaseLedgerStatus(body) {
503
+ return sendGameServicesBackendRequest(input.transport, gameServicesBackendEndpoints.getPurchaseLedgerStatus, body);
504
+ },
505
+ async getCommerceCapabilities() {
506
+ return sendGameServicesBackendRequest(input.transport, gameServicesBackendEndpoints.getCommerceCapabilities, {});
507
+ },
316
508
  },
317
509
  adRewards: {
318
510
  async claimAdReward(body) {
@@ -368,6 +560,12 @@ export function createGameServicesOrpcBackendApi(client) {
368
560
  verifyPurchase(input) {
369
561
  return client.commerce.verifyPurchase(input);
370
562
  },
563
+ getPurchaseLedgerStatus(input) {
564
+ return client.commerce.getPurchaseLedgerStatus(input);
565
+ },
566
+ getCommerceCapabilities() {
567
+ return client.commerce.getCapabilities();
568
+ },
371
569
  },
372
570
  adRewards: {
373
571
  claimAdReward(input) {