@mpgd/game-services 0.28.1 → 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.
- package/README.md +18 -3
- package/dist/account-deletion.d.ts +306 -0
- package/dist/account-deletion.js +491 -0
- package/dist/app-store-recovery.d.ts +6 -0
- package/dist/app-store-recovery.js +26 -16
- package/dist/app-store-server-notifications.d.ts +158 -0
- package/dist/app-store-server-notifications.js +270 -0
- package/dist/app-store-server.d.ts +8 -0
- package/dist/app-store-server.js +135 -37
- package/dist/app-store-verifier.d.ts +34 -0
- package/dist/app-store-verifier.js +161 -31
- package/dist/commerce-ledger.d.ts +55 -0
- package/dist/commerce-ledger.js +198 -0
- package/dist/commerce-reconciler.js +5 -0
- package/dist/evidence-verification.d.ts +19 -0
- package/dist/google-play-publisher-client.d.ts +2 -1
- package/dist/google-play-publisher-client.js +80 -0
- package/dist/google-play-purchase.d.ts +7 -0
- package/dist/google-play-purchase.js +32 -7
- package/dist/google-play-store-server.d.ts +160 -0
- package/dist/google-play-store-server.js +297 -0
- package/dist/guest-session.d.ts +112 -2
- package/dist/guest-session.js +196 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/notifications/delivery-provider.d.ts +7 -0
- package/dist/notifications/fcm.d.ts +30 -3
- package/dist/notifications/fcm.js +72 -13
- package/dist/notifications/service.js +33 -5
- package/dist/notifications/store-conformance.js +7 -1
- package/dist/notifications/store.d.ts +9 -0
- package/dist/notifications/store.js +41 -0
- package/dist/operation-results.d.ts +7 -0
- package/dist/purchase-sharing.d.ts +41 -0
- package/dist/purchase-sharing.js +37 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +13 -0
- package/dist/server.d.ts +60 -1
- package/dist/server.js +536 -42
- package/dist/types.d.ts +30 -0
- package/dist/types.js +22 -0
- package/dist/verified-leaderboard.d.ts +13 -0
- package/dist/verified-leaderboard.js +31 -0
- 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
|
-
|
|
50
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
860
|
-
|
|
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))) {
|