@mpgd/game-services 0.28.0 → 0.29.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.
Files changed (44) hide show
  1. package/README.md +18 -3
  2. package/dist/account-deletion.d.ts +306 -0
  3. package/dist/account-deletion.js +491 -0
  4. package/dist/app-store-recovery.d.ts +6 -0
  5. package/dist/app-store-recovery.js +26 -16
  6. package/dist/app-store-server-notifications.d.ts +158 -0
  7. package/dist/app-store-server-notifications.js +270 -0
  8. package/dist/app-store-server.d.ts +8 -0
  9. package/dist/app-store-server.js +135 -37
  10. package/dist/app-store-verifier.d.ts +34 -0
  11. package/dist/app-store-verifier.js +161 -31
  12. package/dist/commerce-ledger.d.ts +55 -0
  13. package/dist/commerce-ledger.js +198 -0
  14. package/dist/commerce-reconciler.js +5 -0
  15. package/dist/evidence-verification.d.ts +19 -0
  16. package/dist/google-play-publisher-client.d.ts +2 -1
  17. package/dist/google-play-publisher-client.js +80 -0
  18. package/dist/google-play-purchase.d.ts +7 -0
  19. package/dist/google-play-purchase.js +32 -7
  20. package/dist/google-play-store-server.d.ts +160 -0
  21. package/dist/google-play-store-server.js +297 -0
  22. package/dist/guest-session.d.ts +112 -2
  23. package/dist/guest-session.js +196 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/notifications/delivery-provider.d.ts +7 -0
  27. package/dist/notifications/fcm.d.ts +30 -3
  28. package/dist/notifications/fcm.js +72 -13
  29. package/dist/notifications/service.js +33 -5
  30. package/dist/notifications/store-conformance.js +7 -1
  31. package/dist/notifications/store.d.ts +9 -0
  32. package/dist/notifications/store.js +41 -0
  33. package/dist/operation-results.d.ts +7 -0
  34. package/dist/purchase-sharing.d.ts +41 -0
  35. package/dist/purchase-sharing.js +37 -0
  36. package/dist/runtime.d.ts +6 -0
  37. package/dist/runtime.js +13 -0
  38. package/dist/server.d.ts +60 -1
  39. package/dist/server.js +536 -42
  40. package/dist/types.d.ts +30 -0
  41. package/dist/types.js +22 -0
  42. package/dist/verified-leaderboard.d.ts +13 -0
  43. package/dist/verified-leaderboard.js +31 -0
  44. package/package.json +17 -5
@@ -39,6 +39,12 @@ export interface AppStoreTransactionPayload {
39
39
  readonly expiresDate?: number;
40
40
  readonly revocationDate?: number;
41
41
  readonly isUpgraded?: boolean;
42
+ /**
43
+ * Apple's `inAppOwnershipType`: `PURCHASED` for the buyer's own
44
+ * transaction, `FAMILY_SHARED` for a family member's access. Only
45
+ * `PURCHASED` (or an absent value) is granted.
46
+ */
47
+ readonly inAppOwnershipType?: string;
42
48
  }
43
49
  export type AppStoreSignedTransactionVerificationResult = {
44
50
  readonly status: 'verified';
@@ -86,7 +92,35 @@ export interface CreateAppStoreEvidenceVerifierOptions {
86
92
  * 'ignore' }` fetches by id for every request.
87
93
  */
88
94
  readonly signedTransactionEvidence?: AppStoreSignedTransactionEvidencePolicy;
95
+ /**
96
+ * Whether a Production deployment grants transactions Apple signed in the
97
+ * sandbox (App Review, TestFlight, and sandbox testers buy in the sandbox
98
+ * against the production build). Defaults to `{ mode: 'reject' }`, which
99
+ * answers `APP_STORE_ENVIRONMENT_MISMATCH`. `{ mode: 'grant', ... }` follows
100
+ * Apple's production-first order: the transaction is verified against
101
+ * production, and only when the verified client JWS says `Sandbox` (or,
102
+ * for a request without a client JWS, production answers
103
+ * `APP_STORE_TRANSACTION_NOT_FOUND`) is it verified again with the sandbox
104
+ * verifier and Server API. A client JWS the production verifier accepted
105
+ * stays a production transaction: a production miss for it is pending, not
106
+ * a sandbox lookup. The grant's verification id and ledger payload carry
107
+ * `Sandbox`, so it stays apart from production revenue. Only valid on a
108
+ * `Production` verifier.
109
+ */
110
+ readonly sandboxPurchases?: AppStoreSandboxPurchasePolicy;
89
111
  }
112
+ export type AppStoreSandboxPurchasePolicy = {
113
+ readonly mode: 'reject';
114
+ } | {
115
+ readonly mode: 'grant';
116
+ /**
117
+ * A verifier scoped to the sandbox environment and the same bundle, for
118
+ * example `createAppleSignedTransactionVerifier({ environment: 'Sandbox', ... })`.
119
+ */
120
+ readonly signedTransactionVerifier: AppStoreSignedTransactionVerifier;
121
+ /** Defaults to the verifier's `serverApi`, which selects the sandbox host per call. */
122
+ readonly serverApi?: AppStoreServerApiClient;
123
+ };
90
124
  export type AppStoreSignedTransactionEvidencePolicy = {
91
125
  readonly mode: 'ignore';
92
126
  } | {
@@ -133,6 +133,7 @@ export function createAppStoreGameServicesEvidenceVerifier(options) {
133
133
  assertNonEmptyString(options.bundleId, 'bundleId');
134
134
  assertAppStoreEnvironment(options.environment);
135
135
  assertSignedTransactionEvidencePolicy(options.signedTransactionEvidence);
136
+ assertSandboxPurchasePolicy(options.sandboxPurchases, options.environment);
136
137
  const clockSkewMs = readNonNegativeSafeInteger(options.clockSkewMs ?? 5 * 60 * 1000, 'clockSkewMs');
137
138
  const now = options.now ?? (() => new Date().toISOString());
138
139
  return {
@@ -167,9 +168,17 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
167
168
  if (evidence.status === 'rejected') {
168
169
  return { status: 'rejected', reason: evidence.reason };
169
170
  }
171
+ const sandbox = readSandboxGrantPolicy(options);
172
+ let environment = options.environment;
170
173
  let offlineTransaction;
171
174
  if (evidence.status === 'present') {
172
- const offline = await verifySignedTransaction(evidence.signedTransaction, input, options);
175
+ let offline = await verifySignedTransaction(evidence.signedTransaction, input, options, environment);
176
+ if (sandbox !== undefined && isSandboxSignal(offline)) {
177
+ // Production first: only a JWS the production verifier places in the
178
+ // sandbox is verified again, in full, by the sandbox verifier.
179
+ environment = 'Sandbox';
180
+ offline = await verifySignedTransaction(evidence.signedTransaction, input, options, environment);
181
+ }
173
182
  if (offline.status !== 'verified') {
174
183
  return offline;
175
184
  }
@@ -187,30 +196,23 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
187
196
  else {
188
197
  // A JWS is a snapshot: the current status (refund, revocation) is read
189
198
  // from the Server API before any grant.
190
- let transactionResult;
191
- try {
192
- transactionResult = await options.serverApi.getTransactionInfo({
193
- transactionId: input.request.platformTransactionId,
194
- environment: options.environment,
195
- signal: input.signal,
196
- });
197
- }
198
- catch (error) {
199
- if (!(error instanceof AppStoreDependencyUnavailableError)) {
200
- throw error;
201
- }
202
- return { status: 'pending', reason: 'APP_STORE_SERVER_API_UNAVAILABLE' };
203
- }
204
- try {
205
- assertAppStoreServerApiTransactionResult(transactionResult);
206
- }
207
- catch {
208
- return { status: 'pending', reason: 'APP_STORE_SERVER_API_INVALID_RESPONSE' };
199
+ let transactionResult = await fetchTransactionInfo(input, options, environment);
200
+ if (sandbox !== undefined
201
+ && environment === 'Production'
202
+ // A client JWS already verified in production is a production
203
+ // transaction: a miss there is replication lag, not the sandbox.
204
+ && offlineTransaction === undefined
205
+ && transactionResult.status === 'pending'
206
+ && transactionResult.reason === 'APP_STORE_TRANSACTION_NOT_FOUND') {
207
+ // Apple keeps sandbox transaction ids out of production: a production
208
+ // miss is looked up in the sandbox before it is reported as pending.
209
+ environment = 'Sandbox';
210
+ transactionResult = await fetchTransactionInfo(input, options, environment);
209
211
  }
210
212
  if (transactionResult.status !== 'found') {
211
213
  return { status: transactionResult.status, reason: transactionResult.reason };
212
214
  }
213
- const fresh = await verifySignedTransaction(transactionResult.signedTransactionInfo, input, options);
215
+ const fresh = await verifySignedTransaction(transactionResult.signedTransactionInfo, input, options, environment);
214
216
  if (fresh.status !== 'verified') {
215
217
  return fresh;
216
218
  }
@@ -218,6 +220,12 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
218
220
  && fresh.payload.transactionId !== offlineTransaction.transactionId) {
219
221
  return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_ID_MISMATCH' };
220
222
  }
223
+ if (offlineTransaction !== undefined
224
+ && fresh.payload.environment !== offlineTransaction.environment) {
225
+ // Transaction ids are unique per environment only: the decision is
226
+ // taken in the environment the client JWS was verified in.
227
+ return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_ENVIRONMENT_MISMATCH' };
228
+ }
221
229
  transaction = fresh.payload;
222
230
  }
223
231
  const expectedAccountToken = await resolveExpectedAppAccountToken(input, options);
@@ -236,12 +244,26 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
236
244
  // not fresh enough to decide a grant on.
237
245
  return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_STALE' };
238
246
  }
239
- const rejection = validateTransaction(input, transaction, options, expectedAccountToken.value, nowMs, clockSkewMs);
247
+ let rejection = validateTransaction(input, transaction, options.bundleId, environment, expectedAccountToken.value, nowMs, clockSkewMs);
248
+ // A restored non-consumable bound to an earlier player's appAccountToken
249
+ // (a new device, an erased device, a logout) is reported, not refused,
250
+ // when every other check passes: the backend's restore sharing policy
251
+ // decides (RFC "Restore sharing"). The client's StoreKit-verified JWS is
252
+ // required, so a transaction id alone never shares a purchase.
253
+ const foreignBinding = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH'
254
+ && input.request.intent === 'restore'
255
+ && input.product.type === 'non_consumable'
256
+ && offlineTransaction !== undefined;
257
+ if (foreignBinding) {
258
+ rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
259
+ }
240
260
  if (rejection !== undefined) {
241
261
  return { status: 'rejected', reason: rejection };
242
262
  }
243
263
  return {
244
- status: 'verified',
264
+ ...(foreignBinding
265
+ ? { status: 'restorable', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH' }
266
+ : { status: 'verified' }),
245
267
  verificationId: createVerificationId(transaction),
246
268
  verifiedAt: new Date(nowMs).toISOString(),
247
269
  payload: {
@@ -258,6 +280,93 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
258
280
  const defaultSignedTransactionEvidencePolicy = {
259
281
  mode: 'server-api',
260
282
  };
283
+ function readSandboxGrantPolicy(options) {
284
+ return options.environment === 'Production' && options.sandboxPurchases?.mode === 'grant'
285
+ ? options.sandboxPurchases
286
+ : undefined;
287
+ }
288
+ /**
289
+ * The verifier and Server API client for one environment: the configured
290
+ * pair for the deployment's own environment, the sandbox policy's pair for
291
+ * the sandbox of a production deployment that opted in, and no verifier
292
+ * otherwise. The only place a sandbox port is selected.
293
+ */
294
+ function resolveEnvironmentPorts(options, environment) {
295
+ if (environment === options.environment) {
296
+ return {
297
+ signedTransactionVerifier: options.signedTransactionVerifier,
298
+ serverApi: options.serverApi,
299
+ };
300
+ }
301
+ const sandbox = environment === 'Sandbox' ? readSandboxGrantPolicy(options) : undefined;
302
+ return sandbox === undefined
303
+ ? { serverApi: options.serverApi }
304
+ : {
305
+ signedTransactionVerifier: sandbox.signedTransactionVerifier,
306
+ serverApi: sandbox.serverApi ?? options.serverApi,
307
+ };
308
+ }
309
+ /**
310
+ * Whether the production verifier placed a JWS in the sandbox: Apple's
311
+ * adapter refuses it with `APP_STORE_ENVIRONMENT_MISMATCH` after the
312
+ * signature check, and a custom verifier may return the decoded `Sandbox`
313
+ * payload. Either way it is verified again by the sandbox verifier before
314
+ * anything is read from it.
315
+ */
316
+ function isSandboxSignal(result) {
317
+ return result.status === 'verified'
318
+ ? result.payload.environment === 'Sandbox'
319
+ : result.reason === 'APP_STORE_ENVIRONMENT_MISMATCH';
320
+ }
321
+ async function fetchTransactionInfo(input, options, environment) {
322
+ const { serverApi } = resolveEnvironmentPorts(options, environment);
323
+ let transactionResult;
324
+ try {
325
+ transactionResult = await serverApi.getTransactionInfo({
326
+ transactionId: input.request.platformTransactionId,
327
+ environment,
328
+ signal: input.signal,
329
+ });
330
+ }
331
+ catch (error) {
332
+ if (!(error instanceof AppStoreDependencyUnavailableError)) {
333
+ throw error;
334
+ }
335
+ return { status: 'pending', reason: 'APP_STORE_SERVER_API_UNAVAILABLE' };
336
+ }
337
+ try {
338
+ assertAppStoreServerApiTransactionResult(transactionResult);
339
+ }
340
+ catch {
341
+ return { status: 'pending', reason: 'APP_STORE_SERVER_API_INVALID_RESPONSE' };
342
+ }
343
+ return transactionResult;
344
+ }
345
+ function assertSandboxPurchasePolicy(input, environment) {
346
+ if (input === undefined) {
347
+ return;
348
+ }
349
+ assertRecord(input, 'sandboxPurchases');
350
+ if (input.mode === 'reject') {
351
+ return;
352
+ }
353
+ if (input.mode !== 'grant') {
354
+ throw new Error('sandboxPurchases.mode must be reject or grant.');
355
+ }
356
+ if (environment !== 'Production') {
357
+ throw new Error('sandboxPurchases applies only to a Production verifier.');
358
+ }
359
+ assertRecord(input.signedTransactionVerifier, 'sandboxPurchases.signedTransactionVerifier');
360
+ if (typeof input.signedTransactionVerifier.verifyAndDecode !== 'function') {
361
+ throw new Error('sandboxPurchases.signedTransactionVerifier must verify sandbox JWS.');
362
+ }
363
+ if (input.serverApi !== undefined) {
364
+ assertRecord(input.serverApi, 'sandboxPurchases.serverApi');
365
+ if (typeof input.serverApi.getTransactionInfo !== 'function') {
366
+ throw new Error('sandboxPurchases.serverApi must implement getTransactionInfo.');
367
+ }
368
+ }
369
+ }
261
370
  function assertSignedTransactionEvidencePolicy(input) {
262
371
  if (input === undefined) {
263
372
  return;
@@ -273,12 +382,16 @@ function assertSignedTransactionEvidencePolicy(input) {
273
382
  throw new Error('signedTransactionEvidence.mode must be ignore, server-api, or offline-only.');
274
383
  }
275
384
  /** Verifies one JWS through the signature port and validates the decoded payload shape. */
276
- async function verifySignedTransaction(signedTransaction, input, options) {
385
+ async function verifySignedTransaction(signedTransaction, input, options, environment) {
386
+ const { signedTransactionVerifier: verifier } = resolveEnvironmentPorts(options, environment);
387
+ if (verifier === undefined) {
388
+ return { status: 'rejected', reason: 'APP_STORE_ENVIRONMENT_MISMATCH' };
389
+ }
277
390
  let signatureResult;
278
391
  try {
279
- signatureResult = await options.signedTransactionVerifier.verifyAndDecode({
392
+ signatureResult = await verifier.verifyAndDecode({
280
393
  signedTransaction,
281
- environment: options.environment,
394
+ environment,
282
395
  bundleId: options.bundleId,
283
396
  signal: input.signal,
284
397
  });
@@ -315,21 +428,35 @@ async function resolveExpectedAppAccountToken(input, options) {
315
428
  return { status: 'pending', reason: 'APP_STORE_ACCOUNT_BINDING_UNAVAILABLE' };
316
429
  }
317
430
  }
318
- function validateTransaction(input, transaction, options, expectedAccountToken, nowMs, clockSkewMs) {
431
+ function validateTransaction(input, transaction, bundleId, environment,
432
+ /** Undefined skips the account binding check (a restore across players). */
433
+ expectedAccountToken, nowMs, clockSkewMs) {
319
434
  if (transaction.transactionId !== input.request.platformTransactionId) {
320
435
  return 'APP_STORE_TRANSACTION_ID_MISMATCH';
321
436
  }
322
- if (transaction.bundleId !== options.bundleId) {
437
+ if (transaction.bundleId !== bundleId) {
323
438
  return 'APP_STORE_BUNDLE_ID_MISMATCH';
324
439
  }
325
440
  if (transaction.productId !== input.platformProductId) {
326
441
  return 'APP_STORE_PRODUCT_ID_MISMATCH';
327
442
  }
328
- if (transaction.environment !== options.environment) {
443
+ if (transaction.environment !== environment) {
329
444
  return 'APP_STORE_ENVIRONMENT_MISMATCH';
330
445
  }
331
- if (transaction.appAccountToken === undefined
332
- || normalizeAppAccountToken(transaction.appAccountToken) !== expectedAccountToken) {
446
+ if (transaction.inAppOwnershipType === 'FAMILY_SHARED') {
447
+ // A family member's access to the organizer's non-consumable: it carries
448
+ // no app account token of this player, and refusing it explicitly keeps
449
+ // one purchase from granting several players. Leave Family Sharing off
450
+ // for the game's In-App Purchases in App Store Connect.
451
+ return 'APP_STORE_FAMILY_SHARED_UNSUPPORTED';
452
+ }
453
+ if (transaction.inAppOwnershipType !== undefined
454
+ && transaction.inAppOwnershipType !== 'PURCHASED') {
455
+ return 'APP_STORE_OWNERSHIP_UNSUPPORTED';
456
+ }
457
+ if (expectedAccountToken !== undefined
458
+ && (transaction.appAccountToken === undefined
459
+ || normalizeAppAccountToken(transaction.appAccountToken) !== expectedAccountToken)) {
333
460
  return 'APP_STORE_ACCOUNT_TOKEN_MISMATCH';
334
461
  }
335
462
  if (toCatalogProductType(transaction.type) !== input.product.type) {
@@ -467,6 +594,9 @@ export function assertAppStoreTransactionPayload(input) {
467
594
  assertOptionalNonNegativeSafeInteger(input.expiresDate, 'expiresDate');
468
595
  assertOptionalNonNegativeSafeInteger(input.revocationDate, 'revocationDate');
469
596
  assertOptionalBoolean(input.isUpgraded, 'isUpgraded');
597
+ if (input.inAppOwnershipType !== undefined) {
598
+ assertNonEmptyString(input.inAppOwnershipType, 'inAppOwnershipType');
599
+ }
470
600
  return input;
471
601
  }
472
602
  async function readBoundedJson(response, maxResponseBytes, signal) {
@@ -52,6 +52,61 @@ export type RevokeEntitlementGrantResult = {
52
52
  * authority identity reused for another grant is a conflict, not a success.
53
53
  */
54
54
  export declare function revokeEntitlementGrant(store: GameServicesStore, input: EntitlementLedgerRevocation): Promise<RevokeEntitlementGrantResult>;
55
+ /**
56
+ * The purchase grant a provider verification id names (the
57
+ * `evidenceVerificationId` the purchase verifier recorded), through the
58
+ * store's index when it has one. Store notification handlers start here:
59
+ * a refund names the store's transaction, never a ledger entry.
60
+ */
61
+ export declare function findPurchaseGrantByEvidenceVerificationId(store: GameServicesStore, evidenceVerificationId: string): Promise<ProductGrantTransaction | undefined>;
62
+ /** Longest run of holder slots one purchase may have; more is a ledger error. */
63
+ export declare const maxPurchaseHolderSlots = 10000;
64
+ /**
65
+ * Every grant of one purchase (RFC "Restore sharing"): `first`, the grant its
66
+ * store verification id names, and `shared`, the grants verified restores
67
+ * wrote for other players, in slot order. A share is written at `nextSlot`,
68
+ * one past the highest slot read, so two concurrent writers that read the
69
+ * same holders collide on one evidence id and the holder cap holds. Slots
70
+ * can have gaps (the retention purge deletes a deleted player's record after
71
+ * its period); a store with `findEntitlementTransactionsByEvidencePrefix`
72
+ * lists them in one indexed read, gaps included.
73
+ */
74
+ export interface PurchaseHolders {
75
+ readonly first?: ProductGrantTransaction;
76
+ readonly shared: readonly ProductGrantTransaction[];
77
+ readonly nextSlot: number;
78
+ }
79
+ export declare function listPurchaseHolders(store: GameServicesStore, purchaseVerificationId: string): Promise<PurchaseHolders>;
80
+ /**
81
+ * The evidence id of a purchase's `slot`-th shared grant (1 for the first
82
+ * share): unique per slot, so a slot is written once. It starts with the
83
+ * purchase's verification id, so a store lists a purchase's shares with one
84
+ * indexed prefix read.
85
+ */
86
+ export declare function createPurchaseHolderEvidenceVerificationId(purchaseVerificationId: string, slot: number): string;
87
+ /**
88
+ * The store verification id of the purchase a grant records: the purchase's
89
+ * own id for a shared grant (whose ledger evidence id names its slot),
90
+ * otherwise the grant's evidence id. Finalizers (a Play acknowledge) and
91
+ * refunds are keyed by it.
92
+ */
93
+ export declare function readPurchaseVerificationId(transaction: ProductGrantTransaction): string | undefined;
94
+ /**
95
+ * Whether a verified restore across players wrote this grant: a shared
96
+ * grant, or a purchase's first grant written for a player the store did not
97
+ * bind it to (`restoredAcrossAccounts`).
98
+ */
99
+ export declare function isRestoredAcrossPlayers(transaction: ProductGrantTransaction): boolean;
100
+ /** The revoke reasons a store event writes: they end the purchase for every holder. */
101
+ export declare const purchaseWideRevocationReasons: ReadonlySet<string>;
102
+ /**
103
+ * The store event (refund, chargeback, voided purchase) that ended a
104
+ * purchase, read from its holders' revoke entries, the first grant's first;
105
+ * undefined while the purchase is live. An `admin` entry ends one holder only.
106
+ */
107
+ export declare function findPurchaseWideRevocation(store: GameServicesStore, holders: readonly ProductGrantTransaction[]): Promise<EntitlementRevocationTransaction | undefined>;
108
+ /** The evidence id of the entry that reverses `holder` for a store event whose own id is `eventId`. */
109
+ export declare function holderRevocationEvidenceId(eventId: string, holder: ProductGrantTransaction): string;
55
110
  /** Whether a grant was reversed by a `revoke` entry; false on a store without revocations. */
56
111
  export declare function isEntitlementGrantReversed(store: GameServicesStore, ledgerEntryId: string): Promise<boolean>;
57
112
  /**
@@ -109,6 +109,21 @@ export async function revokeEntitlementGrant(store, input) {
109
109
  }
110
110
  try {
111
111
  const result = await store.recordEntitlementRevocation(revocation);
112
+ if (grant.source === 'purchase' && purchaseWideRevocationReasons.has(revocation.reason)) {
113
+ // A store event (refund, chargeback, voided purchase) is about the
114
+ // purchase: every player a verified restore shared it with loses it
115
+ // too (RFC "Restore sharing"). Replays finish a fan-out an earlier
116
+ // attempt left part-way. An admin entry stays on the grant it names.
117
+ try {
118
+ await revokeOtherPurchaseHolders(store, grant, revocation);
119
+ }
120
+ catch (error) {
121
+ // The named grant is reversed; the other holders are not yet. A plain
122
+ // error makes the store event's handler retry (a redelivery or the
123
+ // next sweep), never report the event settled as a conflict.
124
+ throw new Error('Revoking the other holders of the purchase failed.', { cause: error });
125
+ }
126
+ }
112
127
  return {
113
128
  status: 'revoked',
114
129
  ledgerEntryId: result.ledgerEntryId,
@@ -125,6 +140,189 @@ export async function revokeEntitlementGrant(store, input) {
125
140
  throw error;
126
141
  }
127
142
  }
143
+ /**
144
+ * The purchase grant a provider verification id names (the
145
+ * `evidenceVerificationId` the purchase verifier recorded), through the
146
+ * store's index when it has one. Store notification handlers start here:
147
+ * a refund names the store's transaction, never a ledger entry.
148
+ */
149
+ export async function findPurchaseGrantByEvidenceVerificationId(store, evidenceVerificationId) {
150
+ const grant = await findPurchaseGrantRecord(store, evidenceVerificationId);
151
+ if (grant !== undefined || store.findEntitlementTransactionsByEvidencePrefix === undefined) {
152
+ return grant;
153
+ }
154
+ // The first grant can be gone (the retention purge deleted a deleted
155
+ // player's record) while restore sharing's shares of the purchase remain:
156
+ // a store event then reaches them through one of those (RFC "Restore
157
+ // sharing"), and a refund's fan-out through it reaches the rest.
158
+ return (await listPurchaseHolders(store, evidenceVerificationId)).shared[0];
159
+ }
160
+ async function findPurchaseGrantRecord(store, evidenceVerificationId) {
161
+ const grant = store.findEntitlementTransactionByEvidenceVerificationId === undefined
162
+ ? (await store.listEntitlementTransactions()).find((transaction) => {
163
+ return transaction.source === 'purchase'
164
+ && (transaction.evidenceVerificationId
165
+ ?? transaction.payload.evidenceVerificationId) === evidenceVerificationId;
166
+ })
167
+ : await store.findEntitlementTransactionByEvidenceVerificationId({
168
+ source: 'purchase',
169
+ evidenceVerificationId,
170
+ });
171
+ return grant?.source === 'purchase' ? grant : undefined;
172
+ }
173
+ /** Longest run of holder slots one purchase may have; more is a ledger error. */
174
+ export const maxPurchaseHolderSlots = 10_000;
175
+ export async function listPurchaseHolders(store, purchaseVerificationId) {
176
+ // Shares first, the first grant after: a share written after this read
177
+ // takes the slot this caller would take (and collides), and a first grant
178
+ // written meanwhile is seen, so a writer's holder count is exact.
179
+ const holderPrefix = purchaseHolderEvidencePrefix(purchaseVerificationId);
180
+ const listByPrefix = store.findEntitlementTransactionsByEvidencePrefix;
181
+ const shared = [];
182
+ if (listByPrefix !== undefined) {
183
+ const found = await listByPrefix.call(store, {
184
+ source: 'purchase',
185
+ prefix: holderPrefix,
186
+ limit: maxPurchaseHolderSlots + 1,
187
+ });
188
+ if (found.length > maxPurchaseHolderSlots) {
189
+ throw new Error('A purchase has more holder slots than maxPurchaseHolderSlots.');
190
+ }
191
+ for (const grant of found) {
192
+ const slot = readPurchaseHolderSlot(grant.evidenceVerificationId, holderPrefix);
193
+ if (grant.source === 'purchase' && slot !== undefined) {
194
+ shared.push({ slot, grant });
195
+ }
196
+ }
197
+ }
198
+ else {
199
+ // Without the indexed read, slots are read one by one up to the first
200
+ // free one: correct while no slot is purged (the retention purge runs
201
+ // only on stores that implement the indexed read).
202
+ for (let slot = 1; slot <= maxPurchaseHolderSlots + 1; slot += 1) {
203
+ if (slot > maxPurchaseHolderSlots) {
204
+ throw new Error('A purchase has more holder slots than maxPurchaseHolderSlots.');
205
+ }
206
+ const grant = await findPurchaseGrantRecord(store, createPurchaseHolderEvidenceVerificationId(purchaseVerificationId, slot));
207
+ if (grant === undefined) {
208
+ break;
209
+ }
210
+ shared.push({ slot, grant });
211
+ }
212
+ }
213
+ shared.sort((left, right) => left.slot - right.slot);
214
+ const first = await findPurchaseGrantRecord(store, purchaseVerificationId);
215
+ return {
216
+ ...(first === undefined ? {} : { first }),
217
+ shared: shared.map((entry) => entry.grant),
218
+ nextSlot: (shared.at(-1)?.slot ?? 0) + 1,
219
+ };
220
+ }
221
+ function purchaseHolderEvidencePrefix(purchaseVerificationId) {
222
+ return `${purchaseVerificationId}#mpgd-holder:`;
223
+ }
224
+ function readPurchaseHolderSlot(evidenceVerificationId, prefix) {
225
+ if (evidenceVerificationId === undefined || !evidenceVerificationId.startsWith(prefix)) {
226
+ return undefined;
227
+ }
228
+ const text = evidenceVerificationId.slice(prefix.length);
229
+ return /^[1-9][0-9]{0,8}$/u.test(text) ? Number(text) : undefined;
230
+ }
231
+ /**
232
+ * The evidence id of a purchase's `slot`-th shared grant (1 for the first
233
+ * share): unique per slot, so a slot is written once. It starts with the
234
+ * purchase's verification id, so a store lists a purchase's shares with one
235
+ * indexed prefix read.
236
+ */
237
+ export function createPurchaseHolderEvidenceVerificationId(purchaseVerificationId, slot) {
238
+ if (!Number.isSafeInteger(slot) || slot < 1 || slot > 999_999_999) {
239
+ throw new RangeError('A holder slot is a positive integer.');
240
+ }
241
+ return `${purchaseHolderEvidencePrefix(purchaseVerificationId)}${String(slot)}`;
242
+ }
243
+ /**
244
+ * The store verification id of the purchase a grant records: the purchase's
245
+ * own id for a shared grant (whose ledger evidence id names its slot),
246
+ * otherwise the grant's evidence id. Finalizers (a Play acknowledge) and
247
+ * refunds are keyed by it.
248
+ */
249
+ export function readPurchaseVerificationId(transaction) {
250
+ const shared = transaction.payload.holderOfEvidenceVerificationId;
251
+ if (typeof shared === 'string' && shared.length > 0) {
252
+ return shared;
253
+ }
254
+ const recorded = transaction.evidenceVerificationId ?? transaction.payload.evidenceVerificationId;
255
+ return typeof recorded === 'string' && recorded.length > 0 ? recorded : undefined;
256
+ }
257
+ /**
258
+ * Whether a verified restore across players wrote this grant: a shared
259
+ * grant, or a purchase's first grant written for a player the store did not
260
+ * bind it to (`restoredAcrossAccounts`).
261
+ */
262
+ export function isRestoredAcrossPlayers(transaction) {
263
+ const shared = transaction.payload.holderOfEvidenceVerificationId;
264
+ return (typeof shared === 'string' && shared.length > 0)
265
+ || transaction.payload.restoredAcrossAccounts === true;
266
+ }
267
+ /** The revoke reasons a store event writes: they end the purchase for every holder. */
268
+ export const purchaseWideRevocationReasons = new Set([
269
+ 'refund',
270
+ 'chargeback',
271
+ 'voided',
272
+ ]);
273
+ /**
274
+ * The store event (refund, chargeback, voided purchase) that ended a
275
+ * purchase, read from its holders' revoke entries, the first grant's first;
276
+ * undefined while the purchase is live. An `admin` entry ends one holder only.
277
+ */
278
+ export async function findPurchaseWideRevocation(store, holders) {
279
+ const order = new Map(holders.map((holder, index) => [holder.ledgerEntryId, index]));
280
+ return (await findEntitlementRevocationsChunked(store, holders.map((holder) => holder.ledgerEntryId)))
281
+ .filter((entry) => purchaseWideRevocationReasons.has(entry.reason))
282
+ .sort((left, right) => (order.get(left.revokesLedgerEntryId) ?? 0) - (order.get(right.revokesLedgerEntryId) ?? 0))[0];
283
+ }
284
+ /** The evidence id of the entry that reverses `holder` for a store event whose own id is `eventId`. */
285
+ export function holderRevocationEvidenceId(eventId, holder) {
286
+ return `${eventId}#${holder.evidenceVerificationId ?? holder.ledgerEntryId}`;
287
+ }
288
+ async function revokeOtherPurchaseHolders(store, named, revocation) {
289
+ const purchaseVerificationId = readPurchaseVerificationId(named);
290
+ const record = store.recordEntitlementRevocation;
291
+ if (purchaseVerificationId === undefined || record === undefined) {
292
+ return;
293
+ }
294
+ // Read the holders again after each pass: a restore that wrote a share
295
+ // while this pass ran either sees an entry this pass wrote (and revokes
296
+ // itself) or is found by the next read. A pass that writes nothing ends.
297
+ for (let pass = 0; pass < 10; pass += 1) {
298
+ const holders = await listPurchaseHolders(store, purchaseVerificationId);
299
+ const others = [...(holders.first === undefined ? [] : [holders.first]), ...holders.shared]
300
+ .filter((holder) => holder.ledgerEntryId !== named.ledgerEntryId);
301
+ const reversed = new Set((await findEntitlementRevocationsChunked(store, others.map((holder) => holder.ledgerEntryId)))
302
+ .map((entry) => entry.revokesLedgerEntryId));
303
+ const active = others.filter((holder) => !reversed.has(holder.ledgerEntryId));
304
+ if (active.length === 0) {
305
+ return;
306
+ }
307
+ for (const holder of active) {
308
+ await record.call(store, {
309
+ ...revocation,
310
+ playerId: holder.playerId,
311
+ revokesLedgerEntryId: holder.ledgerEntryId,
312
+ // The store event's own identity names one entry; each holder's is
313
+ // derived from it and the holder's evidence id (never its ledger id,
314
+ // which embeds the player id account deletion detaches), so a replay
315
+ // finds the same entries.
316
+ ...(revocation.evidenceVerificationId === undefined
317
+ ? {}
318
+ : {
319
+ evidenceVerificationId: holderRevocationEvidenceId(revocation.evidenceVerificationId, holder),
320
+ }),
321
+ });
322
+ }
323
+ }
324
+ throw new Error('The purchase kept gaining holders while its refund was applied.');
325
+ }
128
326
  /** Whether a grant was reversed by a `revoke` entry; false on a store without revocations. */
129
327
  export async function isEntitlementGrantReversed(store, ledgerEntryId) {
130
328
  const revocations = await findEntitlementRevocationsChunked(store, [ledgerEntryId]);
@@ -497,6 +497,11 @@ export function createCommerceReconciler(input) {
497
497
  idempotencyKey: record.idempotencyKey,
498
498
  purchasedAt: purchase.purchasedAt ?? now(),
499
499
  evidence: purchase.evidence,
500
+ // A purchase the store reported without this journal starting it (a
501
+ // reinstall, a new device, Restore Purchases) is a restore: the backend
502
+ // may grant this player a non-consumable bound to another guest (RFC
503
+ // "Restore sharing"). A purchase the game started never asks for it.
504
+ ...(record.origin === 'discovered' ? { intent: 'restore' } : {}),
500
505
  };
501
506
  };
502
507
  // Reconciler step 2: match a reported purchase to a journal record.
@@ -24,6 +24,25 @@ export type EvidenceVerificationDecision = {
24
24
  readonly ledgerEntryId: string;
25
25
  /** The ledger target the entry was written under when it is not the request's target. */
26
26
  readonly ledgerTarget?: string;
27
+ } | {
28
+ /**
29
+ * The store verified a restored non-consumable, but its account
30
+ * binding (Google Play `obfuscatedAccountId` / `obfuscatedProfileId`,
31
+ * StoreKit `appAccountToken`) names another account than the
32
+ * requesting player's, or none: the player reinstalled or moved to a
33
+ * new device and is a new guest (RFC "Restore sharing"). Every other
34
+ * check passed. The reference verifiers answer it only for a request
35
+ * with `intent: 'restore'` of a non-consumable; the backend then grants
36
+ * the player its own share under its `restoreSharing` policy, or
37
+ * answers `rejected` with `reason` (the binding mismatch) when the
38
+ * policy refuses. Fields are those of `verified`.
39
+ */
40
+ readonly status: 'restorable';
41
+ readonly reason: string;
42
+ readonly verificationId: string;
43
+ readonly verifiedAt: string;
44
+ readonly payload?: EntitlementLedgerPayload;
45
+ readonly platformEvidenceId?: string | null;
27
46
  } | {
28
47
  readonly status: 'pending';
29
48
  readonly reason?: string;
@@ -1,4 +1,5 @@
1
1
  import type { GooglePlayProductPurchaseClient } from './google-play-purchase.js';
2
+ import type { GooglePlayVoidedPurchasesClient } from './google-play-store-server.js';
2
3
  export interface GooglePlayPublisherClientOptions {
3
4
  /** Supplies an androidpublisher-scoped OAuth access token for each request. */
4
5
  readonly getAccessToken: (signal: AbortSignal) => Promise<string> | string;
@@ -8,4 +9,4 @@ export interface GooglePlayPublisherClientOptions {
8
9
  * Google Play purchase verification and post-ledger finalization transport.
9
10
  * The caller owns OAuth credential rotation and must never expose this client to the game.
10
11
  */
11
- export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient;
12
+ export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient & GooglePlayVoidedPurchasesClient;