@mpgd/game-services 0.28.1 → 0.30.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 +47 -7
  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
package/dist/server.js CHANGED
@@ -4,10 +4,12 @@ import { createAnalyticsReporter } from '@mpgd/analytics';
4
4
  import { resolveAdPlacementPlatformId, resolveCatalogCurrencies, resolveProductPlatformId, } from '@mpgd/catalog';
5
5
  import { gameServicesBackendEndpoints, } from './client.js';
6
6
  import { resolveCommerceStoreId, } from './commerce-evidence-registry.js';
7
- import { isEntitlementGrantReversed, readPurchaseLedgerStatuses, RevocationKeyConflictError, } from './commerce-ledger.js';
7
+ import { createPurchaseHolderEvidenceVerificationId, findEntitlementRevocationsChunked, findPurchaseWideRevocation, holderRevocationEvidenceId, isEntitlementGrantReversed, isRestoredAcrossPlayers, listPurchaseHolders, readPurchaseLedgerStatuses, readPurchaseVerificationId, RevocationKeyConflictError, revokeEntitlementGrant, } from './commerce-ledger.js';
8
+ import { assertPlayerDataErasureInput, createRetainedLedgerEntryId, detachRetainedLedgerPayload, isRetainedPlayerId, } from './account-deletion.js';
8
9
  import { gameServicesContract } from './contract.js';
9
10
  import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification.js';
10
- import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertEntitlementLedgerRevocation, assertEntitlementRevocationTransaction, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
11
+ import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerNote, assertEntitlementLedgerResult, assertEntitlementLedgerRevocation, assertEntitlementRevocationTransaction, assertGameServicesDeploymentTarget, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertPurchaseGrantFinalization, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types.js';
12
+ import { resolvePurchaseRestoreSharingPolicy, } from './purchase-sharing.js';
11
13
  import { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
12
14
  export { gameServicesRequestLimits, GameServicesRequestValidationError } from './validation.js';
13
15
  export { listActiveEntitlementTransactions, readPurchaseLedgerStatuses, revokeEntitlementGrant, RevocationKeyConflictError, } from './commerce-ledger.js';
@@ -19,6 +21,7 @@ export class InMemoryGameServicesStore {
19
21
  revocationsByKey = new Map();
20
22
  revocationsByGrant = new Map();
21
23
  revocationsByEvidence = new Map();
24
+ notesById = new Map();
22
25
  leaderboardTransactionsByRun = new Map();
23
26
  leaderboardTransactionsById = new Map();
24
27
  async recordEntitlementGrant(input) {
@@ -46,17 +49,8 @@ export class InMemoryGameServicesStore {
46
49
  throw new EvidenceAlreadyProcessedError();
47
50
  }
48
51
  const transaction = createEntitlementTransaction(grant);
49
- this.entitlementTransactionsByKey.set(key, transaction);
50
- this.entitlementTransactionsById.set(transaction.ledgerEntryId, transaction);
51
- if (grant.evidenceVerificationId !== undefined) {
52
- this.entitlementTransactionsByEvidence.set(createEntitlementEvidenceKey({
53
- source: grant.source,
54
- evidenceVerificationId: grant.evidenceVerificationId,
55
- }), transaction);
56
- }
57
- if (platformEvidenceIdentity !== undefined) {
58
- this.entitlementTransactionsByPlatformEvidence.set(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity), transaction);
59
- }
52
+ // Indexes it by idempotency key, ledger entry id, and evidence identities.
53
+ this.rememberEntitlementTransaction(transaction);
60
54
  return assertEntitlementLedgerResult({
61
55
  ledgerEntryId: transaction.ledgerEntryId,
62
56
  alreadyProcessed: false,
@@ -68,6 +62,19 @@ export class InMemoryGameServicesStore {
68
62
  async findEntitlementTransactionByEvidenceVerificationId(input) {
69
63
  return this.entitlementTransactionsByEvidence.get(createEntitlementEvidenceKey(input));
70
64
  }
65
+ async findEntitlementTransactionsByEvidencePrefix(input) {
66
+ const found = [];
67
+ for (const transaction of this.entitlementTransactionsById.values()) {
68
+ if (transaction.source === input.source
69
+ && transaction.evidenceVerificationId?.startsWith(input.prefix) === true) {
70
+ found.push(transaction);
71
+ if (found.length >= input.limit) {
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ return found;
77
+ }
71
78
  async findEntitlementTransactionByPlatformEvidence(input) {
72
79
  return this.entitlementTransactionsByPlatformEvidence.get(createEntitlementPlatformEvidenceKey(input));
73
80
  }
@@ -119,11 +126,7 @@ export class InMemoryGameServicesStore {
119
126
  ...revocation,
120
127
  ledgerEntryId: createRevocationLedgerEntryId(revocation),
121
128
  });
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
- }
129
+ this.rememberEntitlementRevocation(transaction);
127
130
  return assertEntitlementLedgerResult({
128
131
  ledgerEntryId: transaction.ledgerEntryId,
129
132
  alreadyProcessed: false,
@@ -135,6 +138,23 @@ export class InMemoryGameServicesStore {
135
138
  .filter((revocation) => wanted.has(revocation.revokesLedgerEntryId))
136
139
  .sort((left, right) => left.revokedAt.localeCompare(right.revokedAt));
137
140
  }
141
+ async recordEntitlementNote(input) {
142
+ const note = assertEntitlementLedgerNote(input);
143
+ if (this.notesById.has(note.noteId)) {
144
+ return { alreadyRecorded: true };
145
+ }
146
+ if (!this.entitlementTransactionsById.has(note.ledgerEntryId)) {
147
+ throw new Error('Entitlement note references an unknown grant.');
148
+ }
149
+ this.notesById.set(note.noteId, note);
150
+ return { alreadyRecorded: false };
151
+ }
152
+ async findEntitlementNotes(input) {
153
+ const wanted = new Set(input.ledgerEntryIds);
154
+ return [...this.notesById.values()]
155
+ .filter((note) => wanted.has(note.ledgerEntryId))
156
+ .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
157
+ }
138
158
  async recordLeaderboardScore(input, options = {}) {
139
159
  const request = assertRecordLeaderboardScoreRequest(input);
140
160
  const runKey = createLeaderboardRunKey(request);
@@ -167,6 +187,148 @@ export class InMemoryGameServicesStore {
167
187
  async listLeaderboardTransactions() {
168
188
  return this.sortedLeaderboardTransactions();
169
189
  }
190
+ async erasePlayerData(input) {
191
+ const { playerId, retainedPlayerId } = assertPlayerDataErasureInput(input);
192
+ let deleted = 0;
193
+ let retained = 0;
194
+ const grants = [...this.entitlementTransactionsById.values()]
195
+ .filter((transaction) => transaction.playerId === playerId);
196
+ const grantIds = new Set(grants.map((grant) => grant.ledgerEntryId));
197
+ const keptGrantIds = new Map();
198
+ for (const grant of grants) {
199
+ this.forgetEntitlementTransaction(grant);
200
+ if (grant.source !== 'purchase') {
201
+ deleted += 1;
202
+ continue;
203
+ }
204
+ const ledgerEntryId = createRetainedLedgerEntryId();
205
+ keptGrantIds.set(grant.ledgerEntryId, ledgerEntryId);
206
+ this.rememberEntitlementTransaction(assertProductGrantTransaction({
207
+ ...grant,
208
+ ledgerEntryId,
209
+ playerId: retainedPlayerId,
210
+ idempotencyKey: ledgerEntryId,
211
+ payload: detachRetainedLedgerPayload(grant.payload),
212
+ }));
213
+ retained += 1;
214
+ }
215
+ const keptRevocationIds = new Map();
216
+ const deletedRevocationIds = new Set();
217
+ for (const revocation of [...this.revocationsByGrant.values()]) {
218
+ if (revocation.playerId !== playerId && !grantIds.has(revocation.revokesLedgerEntryId)) {
219
+ continue;
220
+ }
221
+ this.forgetEntitlementRevocation(revocation);
222
+ // A revocation is kept with the grant it reverses, whoever wrote it.
223
+ const keptGrantId = keptGrantIds.get(revocation.revokesLedgerEntryId)
224
+ ?? (this.entitlementTransactionsById.has(revocation.revokesLedgerEntryId)
225
+ ? revocation.revokesLedgerEntryId
226
+ : undefined);
227
+ if (keptGrantId === undefined) {
228
+ deletedRevocationIds.add(revocation.ledgerEntryId);
229
+ deleted += 1;
230
+ continue;
231
+ }
232
+ const ledgerEntryId = createRetainedLedgerEntryId();
233
+ keptRevocationIds.set(revocation.ledgerEntryId, ledgerEntryId);
234
+ const kept = assertEntitlementRevocationTransaction({
235
+ ...revocation,
236
+ ledgerEntryId,
237
+ playerId: retainedPlayerId,
238
+ revokesLedgerEntryId: keptGrantId,
239
+ idempotencyKey: ledgerEntryId,
240
+ payload: detachRetainedLedgerPayload(revocation.payload),
241
+ });
242
+ this.rememberEntitlementRevocation(kept);
243
+ retained += 1;
244
+ }
245
+ for (const note of [...this.notesById.values()]) {
246
+ // Also a note (of another player) pointing at a revocation rewritten
247
+ // above, as the D1 eraser selects it.
248
+ const aboutKeptRevocation = note.revocationLedgerEntryId !== undefined
249
+ && keptRevocationIds.has(note.revocationLedgerEntryId);
250
+ if (note.revocationLedgerEntryId !== undefined && deletedRevocationIds.has(note.revocationLedgerEntryId)) {
251
+ // It points at a revocation deleted with the player's other grant.
252
+ this.notesById.delete(note.noteId);
253
+ deleted += 1;
254
+ continue;
255
+ }
256
+ if (note.playerId !== playerId && !grantIds.has(note.ledgerEntryId) && !aboutKeptRevocation) {
257
+ continue;
258
+ }
259
+ const keptGrantId = keptGrantIds.get(note.ledgerEntryId)
260
+ ?? (this.entitlementTransactionsById.has(note.ledgerEntryId) ? note.ledgerEntryId : undefined);
261
+ if (keptGrantId === undefined) {
262
+ this.notesById.delete(note.noteId);
263
+ deleted += 1;
264
+ continue;
265
+ }
266
+ const revocationLedgerEntryId = note.revocationLedgerEntryId === undefined
267
+ ? undefined
268
+ : (keptRevocationIds.get(note.revocationLedgerEntryId) ?? note.revocationLedgerEntryId);
269
+ this.notesById.set(note.noteId, assertEntitlementLedgerNote({
270
+ noteId: note.noteId,
271
+ kind: note.kind,
272
+ playerId: retainedPlayerId,
273
+ ledgerEntryId: keptGrantId,
274
+ ...(revocationLedgerEntryId === undefined ? {} : { revocationLedgerEntryId }),
275
+ recordedAt: note.recordedAt,
276
+ payload: detachRetainedLedgerPayload(note.payload),
277
+ }));
278
+ retained += 1;
279
+ }
280
+ for (const transaction of [...this.leaderboardTransactionsById.values()]) {
281
+ if (transaction.playerId !== playerId) {
282
+ continue;
283
+ }
284
+ this.leaderboardTransactionsById.delete(transaction.ledgerEntryId);
285
+ this.leaderboardTransactionsByRun.delete(createLeaderboardRunKey(transaction));
286
+ deleted += 1;
287
+ }
288
+ return { deleted, retained };
289
+ }
290
+ rememberEntitlementRevocation(revocation) {
291
+ this.revocationsByKey.set(createRevocationIdempotencyKey(revocation), revocation);
292
+ this.revocationsByGrant.set(revocation.revokesLedgerEntryId, revocation);
293
+ if (revocation.evidenceVerificationId !== undefined) {
294
+ this.revocationsByEvidence.set(revocation.evidenceVerificationId, revocation);
295
+ }
296
+ }
297
+ forgetEntitlementRevocation(revocation) {
298
+ this.revocationsByKey.delete(createRevocationIdempotencyKey(revocation));
299
+ this.revocationsByGrant.delete(revocation.revokesLedgerEntryId);
300
+ if (revocation.evidenceVerificationId !== undefined) {
301
+ this.revocationsByEvidence.delete(revocation.evidenceVerificationId);
302
+ }
303
+ }
304
+ forgetEntitlementTransaction(transaction) {
305
+ this.entitlementTransactionsByKey.delete(createEntitlementIdempotencyKey(transaction));
306
+ this.entitlementTransactionsById.delete(transaction.ledgerEntryId);
307
+ if (transaction.evidenceVerificationId !== undefined) {
308
+ this.entitlementTransactionsByEvidence.delete(createEntitlementEvidenceKey({
309
+ source: transaction.source,
310
+ evidenceVerificationId: transaction.evidenceVerificationId,
311
+ }));
312
+ }
313
+ const platformEvidenceIdentity = getEntitlementPlatformEvidenceIdentity(transaction);
314
+ if (platformEvidenceIdentity !== undefined) {
315
+ this.entitlementTransactionsByPlatformEvidence.delete(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity));
316
+ }
317
+ }
318
+ rememberEntitlementTransaction(transaction) {
319
+ this.entitlementTransactionsByKey.set(createEntitlementIdempotencyKey(transaction), transaction);
320
+ this.entitlementTransactionsById.set(transaction.ledgerEntryId, transaction);
321
+ if (transaction.evidenceVerificationId !== undefined) {
322
+ this.entitlementTransactionsByEvidence.set(createEntitlementEvidenceKey({
323
+ source: transaction.source,
324
+ evidenceVerificationId: transaction.evidenceVerificationId,
325
+ }), transaction);
326
+ }
327
+ const platformEvidenceIdentity = getEntitlementPlatformEvidenceIdentity(transaction);
328
+ if (platformEvidenceIdentity !== undefined) {
329
+ this.entitlementTransactionsByPlatformEvidence.set(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity), transaction);
330
+ }
331
+ }
170
332
  rankFor(transaction) {
171
333
  return (this.sortedLeaderboardTransactions()
172
334
  .filter((candidate) => candidate.leaderboardId === transaction.leaderboardId)
@@ -199,6 +361,7 @@ export function createGameServicesBackend(input) {
199
361
  const evidenceVerificationTimeoutMs = resolveEvidenceVerificationTimeout(input.evidenceVerificationTimeoutMs);
200
362
  const purchaseGrantFinalizationTimeoutMs = resolvePurchaseGrantFinalizationTimeout(input.purchaseGrantFinalizationTimeoutMs);
201
363
  const deploymentTargetBindings = resolveDeploymentTargetBindings(input.deploymentTargetBindings);
364
+ const restoreSharing = resolvePurchaseRestoreSharingPolicy(input.restoreSharing);
202
365
  const analytics = createAnalyticsReporter({
203
366
  target: 'server',
204
367
  sessionId: input.analyticsSessionId ?? 'game-services',
@@ -236,6 +399,7 @@ export function createGameServicesBackend(input) {
236
399
  evidenceVerificationTimeoutMs,
237
400
  purchaseGrantFinalizationTimeoutMs,
238
401
  deploymentTargetBindings,
402
+ restoreSharing,
239
403
  ...(purchaseGrantFinalizer === undefined
240
404
  ? {}
241
405
  : { purchaseGrantFinalizer }),
@@ -256,6 +420,7 @@ export function createGameServicesBackend(input) {
256
420
  finalizationAction: verification.finalization?.action,
257
421
  finalizationAlreadyCompleted: verification.finalization?.alreadyCompleted,
258
422
  finalizationReason: verification.finalization?.reason,
423
+ restoredAcrossPlayers: verification.restoredAcrossPlayers,
259
424
  },
260
425
  });
261
426
  });
@@ -644,9 +809,12 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
644
809
  ledgerEntryId: transaction.ledgerEntryId,
645
810
  alreadyProcessed: true,
646
811
  ...(finalization === undefined ? {} : { finalization }),
812
+ ...(isRestoredAcrossPlayers(transaction) ? { restoredAcrossPlayers: true } : {}),
647
813
  });
648
814
  };
649
815
  const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
816
+ // A key names one grant: a resend under the key of a reversed grant
817
+ // answers it as reversed, never as a new restore.
650
818
  if (existing !== undefined) {
651
819
  return completeExistingRetry(existing);
652
820
  }
@@ -681,6 +849,26 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
681
849
  // the requesting player, product, and target.
682
850
  return completeSettledPurchase(request, verification, context);
683
851
  }
852
+ // The store routed to, as for a grant below.
853
+ const routedEntry = context.registry?.route(request);
854
+ const verifiedStore = routedEntry?.status === 'routed'
855
+ ? routedEntry.entry.store
856
+ : resolveCommerceStoreId(request);
857
+ if (verification.status === 'restorable') {
858
+ // A verified restore of a purchase the store binds to another player.
859
+ return shareRestoredPurchase(request, {
860
+ product,
861
+ platformProductId,
862
+ verification,
863
+ verifiedStore,
864
+ refusal: assertVerifyPurchaseResponse({
865
+ verified: false,
866
+ alreadyProcessed: false,
867
+ disposition: 'rejected',
868
+ reason: verification.reason,
869
+ }),
870
+ }, context);
871
+ }
684
872
  if (verification.status !== 'verified') {
685
873
  // A matching grant can land while provider verification is in flight. Recheck
686
874
  // before returning a stale provider state such as an already-consumed token.
@@ -699,14 +887,31 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
699
887
  }
700
888
  const verificationPayload = createVerificationPayload(verification, 'platformTransactionId');
701
889
  const platformTransactionId = resolvePlatformEvidenceId(verification, request.platformTransactionId);
702
- const evidenceSchema = request.evidence?.schema;
890
+ if (context.restoreSharing.mode === 'share'
891
+ && product.type === 'non_consumable'
892
+ && context.store.findEntitlementTransactionsByEvidencePrefix !== undefined) {
893
+ // The retention purge can delete a purchase's first grant while its
894
+ // shares remain: the buyer then takes a holder's place under the limit
895
+ // like any other player, never a replacement first grant past it.
896
+ const holders = await listPurchaseHolders(context.store, verification.verificationId);
897
+ if (holders.first === undefined && holders.shared.length > 0) {
898
+ return shareRestoredPurchase(request, {
899
+ product,
900
+ platformProductId,
901
+ verification,
902
+ verifiedStore,
903
+ bindingMatches: true,
904
+ refusal: assertVerifyPurchaseResponse({
905
+ verified: false,
906
+ alreadyProcessed: false,
907
+ reason: 'EVIDENCE_ALREADY_PROCESSED',
908
+ }),
909
+ }, context);
910
+ }
911
+ }
703
912
  // The store written is the one the registry routed to (an entry's own
704
913
  // store, or a declared fallback store); a fallback serving undeclared
705
914
  // 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);
710
915
  const grant = await recordEntitlementGrantWithEvidence(context.store, {
711
916
  playerId: request.playerId,
712
917
  grantId: product.id,
@@ -715,25 +920,39 @@ async function verifyPurchaseForRetry(request, retryIdentity, context) {
715
920
  grantedAt: context.now(),
716
921
  grant: product.grant,
717
922
  evidenceVerificationId: verification.verificationId,
718
- payload: {
719
- ...verificationPayload,
720
- target: request.target,
721
- store: verifiedStore,
722
- ...(request.deploymentTarget === undefined
723
- ? {}
724
- : { deploymentTarget: request.deploymentTarget }),
725
- productId: request.productId,
726
- productType: product.type,
923
+ payload: createPurchaseGrantPayload(request, {
924
+ product,
727
925
  platformProductId,
926
+ verification,
927
+ verifiedStore,
728
928
  ...(platformTransactionId === undefined ? {} : { platformTransactionId }),
729
- purchasedAt: request.purchasedAt,
730
- ...(evidenceSchema === undefined ? {} : { evidenceSchema }),
731
- evidenceVerificationId: verification.verificationId,
732
- evidenceVerifiedAt: verification.verifiedAt,
733
- },
929
+ }),
734
930
  });
735
931
  if (grant.status === 'evidence_already_processed') {
736
932
  const existingEvidence = await findEntitlementTransactionByEvidenceVerificationId(context.store, { source: 'purchase', evidenceVerificationId: verification.verificationId });
933
+ if (existingEvidence !== undefined
934
+ && context.restoreSharing.mode === 'share'
935
+ && product.type === 'non_consumable'
936
+ && existingEvidence.playerId !== request.playerId
937
+ && (request.intent === 'restore' || existingEvidence.payload.restoredAcrossAccounts === true)) {
938
+ // The store binds the purchase to this player, but another player's
939
+ // grant names it: a restore of this player's, or (when that grant came
940
+ // from another guest's restore before this player's own verify reached
941
+ // the ledger) the buyer's checkout. The binding match is the proof, so
942
+ // this player gets its own share.
943
+ return shareRestoredPurchase(request, {
944
+ product,
945
+ platformProductId,
946
+ verification,
947
+ verifiedStore,
948
+ bindingMatches: true,
949
+ refusal: assertVerifyPurchaseResponse({
950
+ verified: false,
951
+ alreadyProcessed: false,
952
+ reason: 'EVIDENCE_ALREADY_PROCESSED',
953
+ }),
954
+ }, context);
955
+ }
737
956
  if (existingEvidence !== undefined
738
957
  && matchesPurchaseEvidenceRecovery(existingEvidence, {
739
958
  playerId: request.playerId,
@@ -847,6 +1066,278 @@ async function completeSettledPurchase(request, decision, context) {
847
1066
  ...(finalization === undefined ? {} : { finalization }),
848
1067
  });
849
1068
  }
1069
+ /** The ledger payload of a purchase grant: the verifier's payload and the request's identity. */
1070
+ function createPurchaseGrantPayload(request, input) {
1071
+ const evidenceSchema = request.evidence?.schema;
1072
+ return {
1073
+ ...createVerificationPayload(input.verification, 'platformTransactionId'),
1074
+ target: request.target,
1075
+ store: input.verifiedStore,
1076
+ ...(request.deploymentTarget === undefined
1077
+ ? {}
1078
+ : { deploymentTarget: request.deploymentTarget }),
1079
+ productId: request.productId,
1080
+ productType: input.product.type,
1081
+ platformProductId: input.platformProductId,
1082
+ ...(input.platformTransactionId === undefined
1083
+ ? {}
1084
+ : { platformTransactionId: input.platformTransactionId }),
1085
+ purchasedAt: request.purchasedAt,
1086
+ ...(evidenceSchema === undefined ? {} : { evidenceSchema }),
1087
+ evidenceVerificationId: input.verification.verificationId,
1088
+ evidenceVerifiedAt: input.verification.verifiedAt,
1089
+ };
1090
+ }
1091
+ /**
1092
+ * A verified restore of a non-consumable the store binds to another player
1093
+ * (RFC "Restore sharing"). Under the `share` policy, for a request with
1094
+ * `intent: 'restore'` of a non-consumable, the restoring player gets its own
1095
+ * grant linked to the purchase and earlier holders keep theirs; otherwise
1096
+ * `refusal` is answered. A purchase no grant holds yet is granted to the
1097
+ * restoring player as its first grant. A refunded or revoked purchase is
1098
+ * refused, a purchase already held by `maxHolders` players answers
1099
+ * `pending` (`PURCHASE_HOLDER_LIMIT_REACHED`) and evicts nobody, and a
1100
+ * player that already holds it gets its grant back.
1101
+ */
1102
+ async function shareRestoredPurchase(request, input, context) {
1103
+ const { store, restoreSharing: policy } = context;
1104
+ const { product, verification } = input;
1105
+ if (policy.mode !== 'share'
1106
+ || (request.intent !== 'restore' && input.bindingMatches !== true)
1107
+ || product.type !== 'non_consumable') {
1108
+ return input.refusal;
1109
+ }
1110
+ const purchaseVerificationId = verification.verificationId;
1111
+ const notGranted = (disposition, reason) => ({
1112
+ status: 'answer',
1113
+ response: assertVerifyPurchaseResponse({
1114
+ verified: false,
1115
+ alreadyProcessed: false,
1116
+ disposition,
1117
+ reason,
1118
+ }),
1119
+ });
1120
+ const payload = createPurchaseGrantPayload(request, input);
1121
+ const platformTransactionId = resolvePlatformEvidenceId(verification, request.platformTransactionId);
1122
+ const holderIdentity = {
1123
+ source: 'purchase',
1124
+ playerId: request.playerId,
1125
+ grantId: request.productId,
1126
+ target: request.target,
1127
+ store: input.verifiedStore,
1128
+ ...(request.deploymentTarget === undefined
1129
+ ? {}
1130
+ : { deploymentTarget: request.deploymentTarget }),
1131
+ };
1132
+ const outcome = await withEntitlementEvidenceLocks(store, [createEntitlementEvidenceKey({
1133
+ source: 'purchase',
1134
+ evidenceVerificationId: purchaseVerificationId,
1135
+ })], async () => {
1136
+ // A lost race for a slot (another isolate shared the purchase between
1137
+ // the read and the write) reads the holders again.
1138
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1139
+ const holders = await listPurchaseHolders(store, purchaseVerificationId);
1140
+ const first = holders.first;
1141
+ // The holders; the first grant may be gone (the retention purge
1142
+ // deleted a deleted player's record) while its shares remain.
1143
+ const all = [...(first === undefined ? [] : [first]), ...holders.shared];
1144
+ const anchor = all[0];
1145
+ if (anchor === undefined) {
1146
+ // No grant holds the purchase: it never reached this ledger (an app
1147
+ // closed before verifying, or bought outside the app). The store's
1148
+ // verification is the proof, as for a share.
1149
+ const recorded = await recordEntitlementGrantUnchecked(store, {
1150
+ playerId: request.playerId,
1151
+ grantId: product.id,
1152
+ source: 'purchase',
1153
+ idempotencyKey: request.idempotencyKey,
1154
+ grantedAt: context.now(),
1155
+ grant: product.grant,
1156
+ evidenceVerificationId: purchaseVerificationId,
1157
+ payload: {
1158
+ ...payload,
1159
+ ...(platformTransactionId === undefined ? {} : { platformTransactionId }),
1160
+ restoredAcrossAccounts: true,
1161
+ },
1162
+ });
1163
+ if (recorded.status !== 'recorded') {
1164
+ continue;
1165
+ }
1166
+ if (recorded.result.alreadyProcessed) {
1167
+ // The key already held a grant (another isolate wrote it after the
1168
+ // lookup): it is this restore only if it records this purchase.
1169
+ const keyed = await store.getEntitlementTransaction(recorded.result.ledgerEntryId);
1170
+ if (keyed === undefined
1171
+ || !matchesEntitlementRetry(keyed, {
1172
+ ...holderIdentity,
1173
+ idempotencyKey: request.idempotencyKey,
1174
+ })
1175
+ || readPurchaseVerificationId(keyed) !== purchaseVerificationId) {
1176
+ return notGranted('rejected', 'IDEMPOTENCY_KEY_CONFLICT');
1177
+ }
1178
+ if (await isEntitlementGrantReversed(store, keyed.ledgerEntryId)) {
1179
+ return notGranted('rejected', 'LEDGER_GRANT_REVERSED');
1180
+ }
1181
+ }
1182
+ return {
1183
+ status: 'written',
1184
+ ledgerEntryId: recorded.result.ledgerEntryId,
1185
+ alreadyProcessed: recorded.result.alreadyProcessed,
1186
+ };
1187
+ }
1188
+ if (anchor.source !== 'purchase'
1189
+ || anchor.grantId !== request.productId
1190
+ || anchor.payload.target !== request.target
1191
+ || (anchor.payload.store !== undefined && anchor.payload.store !== input.verifiedStore)
1192
+ // Two deployment targets sharing a ledger never share a grant, as
1193
+ // for a retry (`matchesEntitlementTransactionIdentity`).
1194
+ || (anchor.payload.deploymentTarget !== undefined
1195
+ && anchor.payload.deploymentTarget !== request.deploymentTarget)) {
1196
+ return notGranted('rejected', 'LEDGER_GRANT_IDENTITY_MISMATCH');
1197
+ }
1198
+ const revocations = await findEntitlementRevocationsChunked(store, all.map((grant) => grant.ledgerEntryId));
1199
+ const reversed = new Set(revocations.map((entry) => entry.revokesLedgerEntryId));
1200
+ if (await findPurchaseWideRevocation(store, all) !== undefined
1201
+ || all.every((grant) => reversed.has(grant.ledgerEntryId))) {
1202
+ // Refunded, charged back, or voided: the purchase ended for every
1203
+ // holder. An admin entry ends one holder only and frees its place,
1204
+ // but a purchase no holder keeps is never shared again (a refund
1205
+ // that arrives then finds no grant left to mark).
1206
+ return notGranted('rejected', 'LEDGER_GRANT_REVERSED');
1207
+ }
1208
+ const own = all.find((grant) => grant.playerId === request.playerId);
1209
+ if (own !== undefined && !matchesEntitlementTransactionIdentity(own, holderIdentity)) {
1210
+ return notGranted('rejected', 'LEDGER_GRANT_IDENTITY_MISMATCH');
1211
+ }
1212
+ if (own !== undefined) {
1213
+ return reversed.has(own.ledgerEntryId)
1214
+ ? notGranted('rejected', 'LEDGER_GRANT_REVERSED')
1215
+ : { status: 'held', grant: own };
1216
+ }
1217
+ const active = all.filter((grant) => !reversed.has(grant.ledgerEntryId) && !isRetainedPlayerId(grant.playerId)).length;
1218
+ if (active >= policy.maxHolders) {
1219
+ return notGranted('pending', 'PURCHASE_HOLDER_LIMIT_REACHED');
1220
+ }
1221
+ const holderSlot = holders.nextSlot;
1222
+ const holderEvidenceVerificationId = createPurchaseHolderEvidenceVerificationId(purchaseVerificationId, holderSlot);
1223
+ const recorded = await recordEntitlementGrantUnchecked(store, {
1224
+ playerId: request.playerId,
1225
+ grantId: product.id,
1226
+ source: 'purchase',
1227
+ // The request's key, so a retry under it answers this grant; a
1228
+ // player that already holds the purchase was answered above.
1229
+ idempotencyKey: request.idempotencyKey,
1230
+ grantedAt: context.now(),
1231
+ grant: product.grant,
1232
+ evidenceVerificationId: holderEvidenceVerificationId,
1233
+ // The store transaction id stays with the first grant (its platform
1234
+ // evidence is unique); a share names the purchase by its
1235
+ // verification id instead.
1236
+ payload: {
1237
+ ...payload,
1238
+ evidenceVerificationId: holderEvidenceVerificationId,
1239
+ holderOfEvidenceVerificationId: purchaseVerificationId,
1240
+ // No ledger id of another holder: it embeds that player's id,
1241
+ // which must not outlive that player's account deletion here.
1242
+ holderSlot,
1243
+ },
1244
+ });
1245
+ if (recorded.status !== 'recorded') {
1246
+ // The slot went to another restore; read the holders again.
1247
+ continue;
1248
+ }
1249
+ if (recorded.result.alreadyProcessed) {
1250
+ // The key held a grant already (this request's share landed from
1251
+ // another isolate, or the key names something else).
1252
+ const keyed = await store.getEntitlementTransaction(recorded.result.ledgerEntryId);
1253
+ if (keyed === undefined
1254
+ || !matchesEntitlementRetry(keyed, {
1255
+ ...holderIdentity,
1256
+ idempotencyKey: request.idempotencyKey,
1257
+ })
1258
+ || readPurchaseVerificationId(keyed) !== purchaseVerificationId) {
1259
+ return notGranted('rejected', 'IDEMPOTENCY_KEY_CONFLICT');
1260
+ }
1261
+ return { status: 'held', grant: keyed };
1262
+ }
1263
+ // A refund that landed between the read and this write may have
1264
+ // fanned out before the share existed: it then ends the share too.
1265
+ // A refund writes its first entry before it reads the holders, so
1266
+ // either it sees this share or this read sees its entry.
1267
+ // A refund that met only admin-revoked grants leaves no entry of its
1268
+ // own; the share then ends with them, as a purchase no holder keeps
1269
+ // is never shared again.
1270
+ const written = await store.getEntitlementTransaction(recorded.result.ledgerEntryId);
1271
+ const ended = await findPurchaseWideRevocation(store, written === undefined ? all : [...all, written]);
1272
+ const priorReversed = new Set((await findEntitlementRevocationsChunked(store, all.map((grant) => grant.ledgerEntryId)))
1273
+ .map((entry) => entry.revokesLedgerEntryId));
1274
+ const abandoned = all.every((grant) => priorReversed.has(grant.ledgerEntryId));
1275
+ if (ended !== undefined || abandoned) {
1276
+ if (written !== undefined && ended?.revokesLedgerEntryId !== written.ledgerEntryId) {
1277
+ await revokeEntitlementGrant(store, ended === undefined
1278
+ ? {
1279
+ playerId: written.playerId,
1280
+ revokesLedgerEntryId: written.ledgerEntryId,
1281
+ idempotencyKey: `mpgd-holder-closed:${purchaseVerificationId}`,
1282
+ reason: 'admin',
1283
+ revokedAt: context.now(),
1284
+ payload: { holderOfEvidenceVerificationId: purchaseVerificationId },
1285
+ }
1286
+ : {
1287
+ playerId: written.playerId,
1288
+ revokesLedgerEntryId: written.ledgerEntryId,
1289
+ idempotencyKey: ended.idempotencyKey,
1290
+ reason: ended.reason,
1291
+ revokedAt: ended.revokedAt,
1292
+ payload: ended.payload,
1293
+ ...(ended.evidenceVerificationId === undefined
1294
+ ? {}
1295
+ : {
1296
+ evidenceVerificationId: holderRevocationEvidenceId(ended.evidenceVerificationId, written),
1297
+ }),
1298
+ });
1299
+ }
1300
+ return notGranted('rejected', 'LEDGER_GRANT_REVERSED');
1301
+ }
1302
+ return {
1303
+ status: 'written',
1304
+ ledgerEntryId: recorded.result.ledgerEntryId,
1305
+ alreadyProcessed: false,
1306
+ };
1307
+ }
1308
+ return notGranted('pending', 'PURCHASE_HOLDER_CONFLICT');
1309
+ });
1310
+ if (outcome.status === 'answer') {
1311
+ return outcome.response;
1312
+ }
1313
+ if (outcome.status === 'held') {
1314
+ const finalization = await finalizeExistingPurchaseGrant(request, outcome.grant, context);
1315
+ return assertVerifyPurchaseResponse({
1316
+ verified: true,
1317
+ ledgerEntryId: outcome.grant.ledgerEntryId,
1318
+ alreadyProcessed: true,
1319
+ ...(finalization === undefined ? {} : { finalization }),
1320
+ ...(isRestoredAcrossPlayers(outcome.grant) ? { restoredAcrossPlayers: true } : {}),
1321
+ });
1322
+ }
1323
+ const finalization = await finalizePurchaseGrant(context.purchaseGrantFinalizer, {
1324
+ request,
1325
+ product,
1326
+ platformProductId: input.platformProductId,
1327
+ evidenceVerificationId: purchaseVerificationId,
1328
+ ...(verification.payload === undefined ? {} : { evidencePayload: verification.payload }),
1329
+ ledgerEntryId: outcome.ledgerEntryId,
1330
+ alreadyProcessed: outcome.alreadyProcessed,
1331
+ timeoutMs: context.purchaseGrantFinalizationTimeoutMs,
1332
+ });
1333
+ return assertVerifyPurchaseResponse({
1334
+ verified: true,
1335
+ ledgerEntryId: outcome.ledgerEntryId,
1336
+ alreadyProcessed: outcome.alreadyProcessed,
1337
+ ...(finalization === undefined ? {} : { finalization }),
1338
+ restoredAcrossPlayers: true,
1339
+ });
1340
+ }
850
1341
  async function finalizeExistingPurchaseGrant(request, transaction, context) {
851
1342
  if (context.purchaseGrantFinalizer === undefined) {
852
1343
  return undefined;
@@ -856,8 +1347,8 @@ async function finalizeExistingPurchaseGrant(request, transaction, context) {
856
1347
  const product = createStoredPurchaseProduct(request, transaction) ?? currentProduct;
857
1348
  const platformProductId = storedPlatformProductId
858
1349
  ?? currentProduct?.platformProductIds[request.target];
859
- const evidenceVerificationId = transaction.evidenceVerificationId
860
- ?? transaction.payload.evidenceVerificationId;
1350
+ // A shared grant is finalized as the purchase it records, not as its slot.
1351
+ const evidenceVerificationId = readPurchaseVerificationId(transaction);
861
1352
  const finalizationRequest = createStoredPurchaseRequest(request, transaction);
862
1353
  if (product === undefined
863
1354
  || platformProductId === undefined
@@ -1068,8 +1559,8 @@ async function claimAdRewardForRetry(request, retryIdentity, context) {
1068
1559
  disposition: verification.status === 'pending' ? 'pending' : 'rejected',
1069
1560
  reason: verification.status === 'pending'
1070
1561
  ? (verification.reason ?? 'EVIDENCE_PENDING')
1071
- : verification.status === 'settled'
1072
- // Only purchase verification reads a settled ledger entry.
1562
+ : verification.status === 'settled' || verification.status === 'restorable'
1563
+ // Only purchase verification reads a settled ledger entry or a restore.
1073
1564
  ? 'EVIDENCE_VERIFIER_ERROR'
1074
1565
  : verification.reason,
1075
1566
  });
@@ -1185,7 +1676,10 @@ function createVerificationPayload(verification, platformEvidenceField) {
1185
1676
  return payload;
1186
1677
  }
1187
1678
  function assertEvidenceVerificationDecision(decision) {
1188
- if (decision.status === 'verified') {
1679
+ if (decision.status === 'verified' || decision.status === 'restorable') {
1680
+ if (decision.status === 'restorable') {
1681
+ assertNonEmptyDecisionString(decision.reason, 'reason');
1682
+ }
1189
1683
  assertNonEmptyDecisionString(decision.verificationId, 'verificationId');
1190
1684
  assertNonEmptyDecisionString(decision.verifiedAt, 'verifiedAt');
1191
1685
  if (!Number.isFinite(Date.parse(decision.verifiedAt))) {