@mpgd/game-services 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.js +2 -0
- package/dist/evidence-verification.d.ts +34 -0
- package/dist/evidence-verification.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/platform-order.d.ts +22 -0
- package/dist/platform-order.js +65 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +426 -6
- package/dist/types.d.ts +7 -2
- package/dist/types.js +13 -8
- package/dist/verified-leaderboard-conformance.js +77 -0
- package/dist/verified-leaderboard-transport.js +27 -2
- package/dist/verified-leaderboard.d.ts +9 -0
- package/dist/verified-leaderboard.js +49 -0
- package/package.json +13 -5
package/dist/server.js
CHANGED
|
@@ -3,9 +3,12 @@ import { RPCHandler } from '@orpc/server/fetch';
|
|
|
3
3
|
import { createAnalyticsReporter } from '@mpgd/analytics';
|
|
4
4
|
import { gameServicesBackendEndpoints, } from './client';
|
|
5
5
|
import { gameServicesContract } from './contract';
|
|
6
|
+
import { createRejectingGameServicesEvidenceVerifier, } from './evidence-verification';
|
|
6
7
|
import { assertClaimAdRewardRequest, assertClaimAdRewardResponse, assertEntitlementLedgerGrant, assertEntitlementLedgerResult, assertLeaderboardScoreTransaction, assertProductGrantTransaction, assertRecordLeaderboardScoreRequest, assertRecordLeaderboardScoreResponse, assertVerifyPurchaseRequest, assertVerifyPurchaseResponse, } from './types';
|
|
7
8
|
export class InMemoryGameServicesStore {
|
|
8
9
|
entitlementTransactionsByKey = new Map();
|
|
10
|
+
entitlementTransactionsByEvidence = new Map();
|
|
11
|
+
entitlementTransactionsByPlatformEvidence = new Map();
|
|
9
12
|
entitlementTransactionsById = new Map();
|
|
10
13
|
leaderboardTransactionsByRun = new Map();
|
|
11
14
|
leaderboardTransactionsById = new Map();
|
|
@@ -19,14 +22,46 @@ export class InMemoryGameServicesStore {
|
|
|
19
22
|
alreadyProcessed: true,
|
|
20
23
|
});
|
|
21
24
|
}
|
|
25
|
+
if (grant.evidenceVerificationId !== undefined) {
|
|
26
|
+
const evidenceKey = createEntitlementEvidenceKey({
|
|
27
|
+
source: grant.source,
|
|
28
|
+
evidenceVerificationId: grant.evidenceVerificationId,
|
|
29
|
+
});
|
|
30
|
+
if (this.entitlementTransactionsByEvidence.has(evidenceKey)) {
|
|
31
|
+
throw new EvidenceAlreadyProcessedError();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const platformEvidenceIdentity = getEntitlementPlatformEvidenceIdentity(grant);
|
|
35
|
+
if (platformEvidenceIdentity !== undefined
|
|
36
|
+
&& this.entitlementTransactionsByPlatformEvidence.has(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity))) {
|
|
37
|
+
throw new EvidenceAlreadyProcessedError();
|
|
38
|
+
}
|
|
22
39
|
const transaction = createEntitlementTransaction(grant);
|
|
23
40
|
this.entitlementTransactionsByKey.set(key, transaction);
|
|
24
41
|
this.entitlementTransactionsById.set(transaction.ledgerEntryId, transaction);
|
|
42
|
+
if (grant.evidenceVerificationId !== undefined) {
|
|
43
|
+
this.entitlementTransactionsByEvidence.set(createEntitlementEvidenceKey({
|
|
44
|
+
source: grant.source,
|
|
45
|
+
evidenceVerificationId: grant.evidenceVerificationId,
|
|
46
|
+
}), transaction);
|
|
47
|
+
}
|
|
48
|
+
if (platformEvidenceIdentity !== undefined) {
|
|
49
|
+
this.entitlementTransactionsByPlatformEvidence.set(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity), transaction);
|
|
50
|
+
}
|
|
25
51
|
return assertEntitlementLedgerResult({
|
|
26
52
|
ledgerEntryId: transaction.ledgerEntryId,
|
|
27
53
|
alreadyProcessed: false,
|
|
28
54
|
});
|
|
29
55
|
}
|
|
56
|
+
async findEntitlementTransactionByIdempotency(input) {
|
|
57
|
+
return this.entitlementTransactionsByKey.get(createEntitlementIdempotencyKey(input));
|
|
58
|
+
}
|
|
59
|
+
async findEntitlementTransactionByEvidenceVerificationId(input) {
|
|
60
|
+
return this.entitlementTransactionsByEvidence.get(createEntitlementEvidenceKey(input));
|
|
61
|
+
}
|
|
62
|
+
async findEntitlementTransactionByPlatformEvidence(input) {
|
|
63
|
+
return this.entitlementTransactionsByPlatformEvidence.get(createEntitlementPlatformEvidenceKey(input));
|
|
64
|
+
}
|
|
30
65
|
async getEntitlementTransaction(ledgerEntryId) {
|
|
31
66
|
return this.entitlementTransactionsById.get(ledgerEntryId);
|
|
32
67
|
}
|
|
@@ -79,6 +114,12 @@ export class InMemoryGameServicesStore {
|
|
|
79
114
|
});
|
|
80
115
|
}
|
|
81
116
|
}
|
|
117
|
+
export class EvidenceAlreadyProcessedError extends Error {
|
|
118
|
+
constructor() {
|
|
119
|
+
super('Evidence verification identity has already been processed.');
|
|
120
|
+
this.name = 'EvidenceAlreadyProcessedError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
82
123
|
export function createInMemoryGameServicesStore() {
|
|
83
124
|
return new InMemoryGameServicesStore();
|
|
84
125
|
}
|
|
@@ -86,6 +127,8 @@ export function createGameServicesBackend(input) {
|
|
|
86
127
|
const store = input.store ?? createInMemoryGameServicesStore();
|
|
87
128
|
const now = input.now ?? (() => new Date().toISOString());
|
|
88
129
|
const version = input.version ?? '0.0.0';
|
|
130
|
+
const evidenceVerifier = input.evidenceVerifier ?? createRejectingGameServicesEvidenceVerifier();
|
|
131
|
+
const evidenceVerificationTimeoutMs = resolveEvidenceVerificationTimeout(input.evidenceVerificationTimeoutMs);
|
|
89
132
|
const analytics = createAnalyticsReporter({
|
|
90
133
|
target: 'server',
|
|
91
134
|
sessionId: input.analyticsSessionId ?? 'game-services',
|
|
@@ -100,6 +143,8 @@ export function createGameServicesBackend(input) {
|
|
|
100
143
|
catalog: input.catalog,
|
|
101
144
|
store,
|
|
102
145
|
now,
|
|
146
|
+
evidenceVerifier,
|
|
147
|
+
evidenceVerificationTimeoutMs,
|
|
103
148
|
});
|
|
104
149
|
await analytics.track({
|
|
105
150
|
name: verification.verified ? 'purchase_granted' : 'purchase_rejected',
|
|
@@ -121,6 +166,8 @@ export function createGameServicesBackend(input) {
|
|
|
121
166
|
placements: input.placements,
|
|
122
167
|
store,
|
|
123
168
|
now,
|
|
169
|
+
evidenceVerifier,
|
|
170
|
+
evidenceVerificationTimeoutMs,
|
|
124
171
|
});
|
|
125
172
|
await analytics.track({
|
|
126
173
|
name: claim.granted ? 'rewarded_ad_granted' : 'rewarded_ad_rejected',
|
|
@@ -259,6 +306,28 @@ export function createGameServicesHttpFetchHandler(handler, options = {}) {
|
|
|
259
306
|
}
|
|
260
307
|
async function verifyPurchaseWithStore(input, context) {
|
|
261
308
|
const request = assertVerifyPurchaseRequest(input);
|
|
309
|
+
const retryIdentity = {
|
|
310
|
+
source: 'purchase',
|
|
311
|
+
playerId: request.playerId,
|
|
312
|
+
idempotencyKey: request.idempotencyKey,
|
|
313
|
+
grantId: request.productId,
|
|
314
|
+
target: request.target,
|
|
315
|
+
};
|
|
316
|
+
const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
|
|
317
|
+
if (existing !== undefined) {
|
|
318
|
+
if (!matchesEntitlementRetry(existing, retryIdentity)) {
|
|
319
|
+
return assertVerifyPurchaseResponse({
|
|
320
|
+
verified: false,
|
|
321
|
+
alreadyProcessed: false,
|
|
322
|
+
reason: 'IDEMPOTENCY_KEY_CONFLICT',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return assertVerifyPurchaseResponse({
|
|
326
|
+
verified: true,
|
|
327
|
+
ledgerEntryId: existing.ledgerEntryId,
|
|
328
|
+
alreadyProcessed: true,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
262
331
|
const product = context.catalog.products.find((entry) => entry.id === request.productId);
|
|
263
332
|
if (product === undefined) {
|
|
264
333
|
return assertVerifyPurchaseResponse({
|
|
@@ -275,29 +344,88 @@ async function verifyPurchaseWithStore(input, context) {
|
|
|
275
344
|
reason: 'PRODUCT_NOT_AVAILABLE_ON_TARGET',
|
|
276
345
|
});
|
|
277
346
|
}
|
|
278
|
-
const
|
|
347
|
+
const verification = await verifyEvidence((signal) => {
|
|
348
|
+
return context.evidenceVerifier.verifyPurchase({
|
|
349
|
+
request,
|
|
350
|
+
product,
|
|
351
|
+
platformProductId,
|
|
352
|
+
signal,
|
|
353
|
+
timeoutMs: context.evidenceVerificationTimeoutMs,
|
|
354
|
+
});
|
|
355
|
+
}, context.evidenceVerificationTimeoutMs);
|
|
356
|
+
if (verification.status !== 'verified') {
|
|
357
|
+
return assertVerifyPurchaseResponse({
|
|
358
|
+
verified: false,
|
|
359
|
+
alreadyProcessed: false,
|
|
360
|
+
reason: verification.status === 'pending'
|
|
361
|
+
? (verification.reason ?? 'EVIDENCE_PENDING')
|
|
362
|
+
: verification.reason,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
const grant = await recordEntitlementGrantWithEvidence(context.store, {
|
|
279
366
|
playerId: request.playerId,
|
|
280
367
|
grantId: product.id,
|
|
281
368
|
source: 'purchase',
|
|
282
369
|
idempotencyKey: request.idempotencyKey,
|
|
283
370
|
grantedAt: context.now(),
|
|
284
371
|
grant: product.grant,
|
|
372
|
+
evidenceVerificationId: verification.verificationId,
|
|
285
373
|
payload: {
|
|
374
|
+
...verification.payload,
|
|
286
375
|
target: request.target,
|
|
287
376
|
productId: request.productId,
|
|
288
377
|
platformProductId,
|
|
289
378
|
platformTransactionId: request.platformTransactionId,
|
|
290
379
|
purchasedAt: request.purchasedAt,
|
|
380
|
+
evidenceVerificationId: verification.verificationId,
|
|
381
|
+
evidenceVerifiedAt: verification.verifiedAt,
|
|
291
382
|
},
|
|
292
383
|
});
|
|
384
|
+
if (grant.status === 'evidence_already_processed') {
|
|
385
|
+
return assertVerifyPurchaseResponse({
|
|
386
|
+
verified: false,
|
|
387
|
+
alreadyProcessed: false,
|
|
388
|
+
reason: 'EVIDENCE_ALREADY_PROCESSED',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
if (grant.result.alreadyProcessed
|
|
392
|
+
&& !await recordedEntitlementMatchesRetry(context.store, grant.result, retryIdentity)) {
|
|
393
|
+
return assertVerifyPurchaseResponse({
|
|
394
|
+
verified: false,
|
|
395
|
+
alreadyProcessed: false,
|
|
396
|
+
reason: 'IDEMPOTENCY_KEY_CONFLICT',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
293
399
|
return assertVerifyPurchaseResponse({
|
|
294
400
|
verified: true,
|
|
295
|
-
ledgerEntryId: grant.ledgerEntryId,
|
|
296
|
-
alreadyProcessed: grant.alreadyProcessed,
|
|
401
|
+
ledgerEntryId: grant.result.ledgerEntryId,
|
|
402
|
+
alreadyProcessed: grant.result.alreadyProcessed,
|
|
297
403
|
});
|
|
298
404
|
}
|
|
299
405
|
async function claimAdRewardWithStore(input, context) {
|
|
300
406
|
const request = assertClaimAdRewardRequest(input);
|
|
407
|
+
const retryIdentity = {
|
|
408
|
+
source: 'ad_reward',
|
|
409
|
+
playerId: request.playerId,
|
|
410
|
+
idempotencyKey: request.idempotencyKey,
|
|
411
|
+
grantId: request.placementId,
|
|
412
|
+
target: request.target,
|
|
413
|
+
};
|
|
414
|
+
const existing = await findEntitlementTransactionByIdempotency(context.store, retryIdentity);
|
|
415
|
+
if (existing !== undefined) {
|
|
416
|
+
if (!matchesEntitlementRetry(existing, retryIdentity)) {
|
|
417
|
+
return assertClaimAdRewardResponse({
|
|
418
|
+
granted: false,
|
|
419
|
+
alreadyProcessed: false,
|
|
420
|
+
reason: 'IDEMPOTENCY_KEY_CONFLICT',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return assertClaimAdRewardResponse({
|
|
424
|
+
granted: true,
|
|
425
|
+
ledgerEntryId: existing.ledgerEntryId,
|
|
426
|
+
alreadyProcessed: true,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
301
429
|
const placement = context.placements.placements.find((entry) => entry.id === request.placementId);
|
|
302
430
|
if (placement === undefined || placement.type !== 'rewarded' || placement.reward === undefined) {
|
|
303
431
|
return assertClaimAdRewardResponse({
|
|
@@ -306,12 +434,34 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
306
434
|
reason: placement === undefined ? 'UNKNOWN_PLACEMENT' : 'NOT_REWARDED_PLACEMENT',
|
|
307
435
|
});
|
|
308
436
|
}
|
|
437
|
+
const platformPlacementId = placement.platformPlacementIds[request.target];
|
|
438
|
+
const verification = await verifyEvidence((signal) => {
|
|
439
|
+
return context.evidenceVerifier.verifyAdReward({
|
|
440
|
+
request,
|
|
441
|
+
placement,
|
|
442
|
+
...(platformPlacementId === undefined ? {} : { platformPlacementId }),
|
|
443
|
+
signal,
|
|
444
|
+
timeoutMs: context.evidenceVerificationTimeoutMs,
|
|
445
|
+
});
|
|
446
|
+
}, context.evidenceVerificationTimeoutMs);
|
|
447
|
+
if (verification.status !== 'verified') {
|
|
448
|
+
return assertClaimAdRewardResponse({
|
|
449
|
+
granted: false,
|
|
450
|
+
alreadyProcessed: false,
|
|
451
|
+
reason: verification.status === 'pending'
|
|
452
|
+
? (verification.reason ?? 'EVIDENCE_PENDING')
|
|
453
|
+
: verification.reason,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
309
456
|
const payload = {
|
|
457
|
+
...verification.payload,
|
|
310
458
|
target: request.target,
|
|
311
459
|
placementId: request.placementId,
|
|
312
460
|
rewardType: placement.reward.type,
|
|
313
461
|
amount: placement.reward.amount,
|
|
314
462
|
completedAt: request.completedAt,
|
|
463
|
+
evidenceVerificationId: verification.verificationId,
|
|
464
|
+
evidenceVerifiedAt: verification.verifiedAt,
|
|
315
465
|
};
|
|
316
466
|
if (placement.reward.type === 'currency') {
|
|
317
467
|
payload.currency = placement.reward.currency;
|
|
@@ -319,20 +469,281 @@ async function claimAdRewardWithStore(input, context) {
|
|
|
319
469
|
if (request.platformImpressionId !== undefined) {
|
|
320
470
|
payload.platformImpressionId = request.platformImpressionId;
|
|
321
471
|
}
|
|
322
|
-
const result = await context.store
|
|
472
|
+
const result = await recordEntitlementGrantWithEvidence(context.store, {
|
|
323
473
|
playerId: request.playerId,
|
|
324
474
|
grantId: request.placementId,
|
|
325
475
|
source: 'ad_reward',
|
|
326
476
|
idempotencyKey: request.idempotencyKey,
|
|
327
477
|
grantedAt: context.now(),
|
|
478
|
+
evidenceVerificationId: verification.verificationId,
|
|
328
479
|
payload,
|
|
329
480
|
});
|
|
481
|
+
if (result.status === 'evidence_already_processed') {
|
|
482
|
+
return assertClaimAdRewardResponse({
|
|
483
|
+
granted: false,
|
|
484
|
+
alreadyProcessed: false,
|
|
485
|
+
reason: 'EVIDENCE_ALREADY_PROCESSED',
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (result.result.alreadyProcessed
|
|
489
|
+
&& !await recordedEntitlementMatchesRetry(context.store, result.result, retryIdentity)) {
|
|
490
|
+
return assertClaimAdRewardResponse({
|
|
491
|
+
granted: false,
|
|
492
|
+
alreadyProcessed: false,
|
|
493
|
+
reason: 'IDEMPOTENCY_KEY_CONFLICT',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
330
496
|
return assertClaimAdRewardResponse({
|
|
331
497
|
granted: true,
|
|
332
|
-
ledgerEntryId: result.ledgerEntryId,
|
|
333
|
-
alreadyProcessed: result.alreadyProcessed,
|
|
498
|
+
ledgerEntryId: result.result.ledgerEntryId,
|
|
499
|
+
alreadyProcessed: result.result.alreadyProcessed,
|
|
334
500
|
});
|
|
335
501
|
}
|
|
502
|
+
async function verifyEvidence(verify, timeoutMs) {
|
|
503
|
+
const controller = new AbortController();
|
|
504
|
+
const timeoutDecision = {
|
|
505
|
+
status: 'rejected',
|
|
506
|
+
reason: 'EVIDENCE_VERIFIER_TIMEOUT',
|
|
507
|
+
};
|
|
508
|
+
let timedOut = false;
|
|
509
|
+
let timeout;
|
|
510
|
+
try {
|
|
511
|
+
return await Promise.race([
|
|
512
|
+
verify(controller.signal)
|
|
513
|
+
.then(assertEvidenceVerificationDecision)
|
|
514
|
+
.catch((error) => {
|
|
515
|
+
if (timedOut) {
|
|
516
|
+
return timeoutDecision;
|
|
517
|
+
}
|
|
518
|
+
throw error;
|
|
519
|
+
}),
|
|
520
|
+
new Promise((resolve) => {
|
|
521
|
+
timeout = setTimeout(() => {
|
|
522
|
+
timedOut = true;
|
|
523
|
+
resolve(timeoutDecision);
|
|
524
|
+
controller.abort();
|
|
525
|
+
}, timeoutMs);
|
|
526
|
+
}),
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return {
|
|
531
|
+
status: 'rejected',
|
|
532
|
+
reason: 'EVIDENCE_VERIFIER_ERROR',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
if (timeout !== undefined) {
|
|
537
|
+
clearTimeout(timeout);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function assertEvidenceVerificationDecision(decision) {
|
|
542
|
+
if (decision.status === 'verified') {
|
|
543
|
+
assertNonEmptyDecisionString(decision.verificationId, 'verificationId');
|
|
544
|
+
assertNonEmptyDecisionString(decision.verifiedAt, 'verifiedAt');
|
|
545
|
+
if (!Number.isFinite(Date.parse(decision.verifiedAt))) {
|
|
546
|
+
throw new Error('Evidence verification verifiedAt must be a valid timestamp.');
|
|
547
|
+
}
|
|
548
|
+
if (decision.payload !== undefined) {
|
|
549
|
+
if (typeof decision.payload !== 'object'
|
|
550
|
+
|| decision.payload === null
|
|
551
|
+
|| Array.isArray(decision.payload)) {
|
|
552
|
+
throw new Error('Evidence verification payload must be a record.');
|
|
553
|
+
}
|
|
554
|
+
for (const [key, value] of Object.entries(decision.payload)) {
|
|
555
|
+
if (typeof value !== 'string'
|
|
556
|
+
&& (typeof value !== 'number' || !Number.isFinite(value))
|
|
557
|
+
&& typeof value !== 'boolean') {
|
|
558
|
+
throw new Error(`Evidence verification payload.${key} must be primitive.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return decision;
|
|
563
|
+
}
|
|
564
|
+
if (decision.status === 'pending') {
|
|
565
|
+
if (decision.reason !== undefined) {
|
|
566
|
+
assertNonEmptyDecisionString(decision.reason, 'reason');
|
|
567
|
+
}
|
|
568
|
+
return decision;
|
|
569
|
+
}
|
|
570
|
+
if (decision.status === 'rejected') {
|
|
571
|
+
assertNonEmptyDecisionString(decision.reason, 'reason');
|
|
572
|
+
return decision;
|
|
573
|
+
}
|
|
574
|
+
throw new Error('Unknown evidence verification status.');
|
|
575
|
+
}
|
|
576
|
+
async function recordEntitlementGrantWithEvidence(store, grant) {
|
|
577
|
+
const evidenceVerificationId = grant.evidenceVerificationId;
|
|
578
|
+
if (evidenceVerificationId !== undefined) {
|
|
579
|
+
const evidenceIdentity = {
|
|
580
|
+
source: grant.source,
|
|
581
|
+
evidenceVerificationId,
|
|
582
|
+
};
|
|
583
|
+
const platformEvidenceIdentity = getEntitlementPlatformEvidenceIdentity(grant);
|
|
584
|
+
const lockKeys = [createEntitlementEvidenceKey(evidenceIdentity)];
|
|
585
|
+
if (platformEvidenceIdentity !== undefined) {
|
|
586
|
+
lockKeys.push(createEntitlementPlatformEvidenceKey(platformEvidenceIdentity));
|
|
587
|
+
}
|
|
588
|
+
return withEntitlementEvidenceLocks(store, lockKeys, async () => {
|
|
589
|
+
const authorityMatch = await findEntitlementTransactionByEvidenceVerificationId(store, {
|
|
590
|
+
source: grant.source,
|
|
591
|
+
evidenceVerificationId,
|
|
592
|
+
});
|
|
593
|
+
const existing = authorityMatch ?? (platformEvidenceIdentity === undefined
|
|
594
|
+
? undefined
|
|
595
|
+
: await findEntitlementTransactionByPlatformEvidence(store, platformEvidenceIdentity));
|
|
596
|
+
if (existing !== undefined) {
|
|
597
|
+
if (existing.source === grant.source
|
|
598
|
+
&& existing.playerId === grant.playerId
|
|
599
|
+
&& existing.idempotencyKey === grant.idempotencyKey) {
|
|
600
|
+
return {
|
|
601
|
+
status: 'recorded',
|
|
602
|
+
result: assertEntitlementLedgerResult({
|
|
603
|
+
ledgerEntryId: existing.ledgerEntryId,
|
|
604
|
+
alreadyProcessed: true,
|
|
605
|
+
}),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return { status: 'evidence_already_processed' };
|
|
609
|
+
}
|
|
610
|
+
return recordEntitlementGrantUnchecked(store, grant);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return recordEntitlementGrantUnchecked(store, grant);
|
|
614
|
+
}
|
|
615
|
+
async function recordEntitlementGrantUnchecked(store, grant) {
|
|
616
|
+
try {
|
|
617
|
+
return {
|
|
618
|
+
status: 'recorded',
|
|
619
|
+
result: await store.recordEntitlementGrant(grant),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
if (error instanceof EvidenceAlreadyProcessedError) {
|
|
624
|
+
return { status: 'evidence_already_processed' };
|
|
625
|
+
}
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const entitlementEvidenceLocks = new WeakMap();
|
|
630
|
+
async function withEntitlementEvidenceLocks(store, lockKeys, task) {
|
|
631
|
+
const orderedLockKeys = [...new Set(lockKeys)].sort((left, right) => left.localeCompare(right));
|
|
632
|
+
async function runWithLock(index) {
|
|
633
|
+
const lockKey = orderedLockKeys[index];
|
|
634
|
+
return lockKey === undefined
|
|
635
|
+
? task()
|
|
636
|
+
: withEntitlementEvidenceLockKey(store, lockKey, () => runWithLock(index + 1));
|
|
637
|
+
}
|
|
638
|
+
return runWithLock(0);
|
|
639
|
+
}
|
|
640
|
+
async function withEntitlementEvidenceLockKey(store, lockKey, task) {
|
|
641
|
+
const storeLocks = entitlementEvidenceLocks.get(store) ?? new Map();
|
|
642
|
+
entitlementEvidenceLocks.set(store, storeLocks);
|
|
643
|
+
const previous = storeLocks.get(lockKey) ?? Promise.resolve();
|
|
644
|
+
const gate = createPromiseGate();
|
|
645
|
+
const current = previous.then(() => gate.promise);
|
|
646
|
+
storeLocks.set(lockKey, current);
|
|
647
|
+
await previous;
|
|
648
|
+
try {
|
|
649
|
+
return await task();
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
gate.release();
|
|
653
|
+
if (storeLocks.get(lockKey) === current) {
|
|
654
|
+
storeLocks.delete(lockKey);
|
|
655
|
+
if (storeLocks.size === 0) {
|
|
656
|
+
entitlementEvidenceLocks.delete(store);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function createPromiseGate() {
|
|
662
|
+
let release = () => { };
|
|
663
|
+
const promise = new Promise((resolve) => {
|
|
664
|
+
release = resolve;
|
|
665
|
+
});
|
|
666
|
+
return { promise, release };
|
|
667
|
+
}
|
|
668
|
+
async function findEntitlementTransactionByIdempotency(store, identity) {
|
|
669
|
+
if (store.findEntitlementTransactionByIdempotency !== undefined) {
|
|
670
|
+
return store.findEntitlementTransactionByIdempotency(identity);
|
|
671
|
+
}
|
|
672
|
+
const transactions = await store.listEntitlementTransactions();
|
|
673
|
+
return transactions.find((transaction) => {
|
|
674
|
+
return transaction.source === identity.source
|
|
675
|
+
&& transaction.playerId === identity.playerId
|
|
676
|
+
&& transaction.idempotencyKey === identity.idempotencyKey;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
async function findEntitlementTransactionByEvidenceVerificationId(store, input) {
|
|
680
|
+
if (store.findEntitlementTransactionByEvidenceVerificationId !== undefined) {
|
|
681
|
+
return store.findEntitlementTransactionByEvidenceVerificationId(input);
|
|
682
|
+
}
|
|
683
|
+
const transactions = await store.listEntitlementTransactions();
|
|
684
|
+
return transactions.find((transaction) => {
|
|
685
|
+
const storedEvidenceVerificationId = transaction.evidenceVerificationId
|
|
686
|
+
?? transaction.payload.evidenceVerificationId;
|
|
687
|
+
return transaction.source === input.source
|
|
688
|
+
&& storedEvidenceVerificationId === input.evidenceVerificationId;
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
async function findEntitlementTransactionByPlatformEvidence(store, input) {
|
|
692
|
+
if (store.findEntitlementTransactionByPlatformEvidence !== undefined) {
|
|
693
|
+
return store.findEntitlementTransactionByPlatformEvidence(input);
|
|
694
|
+
}
|
|
695
|
+
const transactions = await store.listEntitlementTransactions();
|
|
696
|
+
return transactions.find((transaction) => {
|
|
697
|
+
const identity = getEntitlementPlatformEvidenceIdentity(transaction);
|
|
698
|
+
return identity !== undefined
|
|
699
|
+
&& identity.source === input.source
|
|
700
|
+
&& identity.target === input.target
|
|
701
|
+
&& identity.platformEvidenceId === input.platformEvidenceId;
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
function getEntitlementPlatformEvidenceIdentity(transaction) {
|
|
705
|
+
if (transaction.source !== 'purchase' && transaction.source !== 'ad_reward') {
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
const target = transaction.payload.target;
|
|
709
|
+
const platformEvidenceId = transaction.source === 'purchase'
|
|
710
|
+
? transaction.payload.platformTransactionId
|
|
711
|
+
: transaction.payload.platformImpressionId;
|
|
712
|
+
if (typeof target !== 'string'
|
|
713
|
+
|| target.length === 0
|
|
714
|
+
|| typeof platformEvidenceId !== 'string'
|
|
715
|
+
|| platformEvidenceId.length === 0) {
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
source: transaction.source,
|
|
720
|
+
target,
|
|
721
|
+
platformEvidenceId,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
async function recordedEntitlementMatchesRetry(store, result, identity) {
|
|
725
|
+
const transaction = await store.getEntitlementTransaction(result.ledgerEntryId);
|
|
726
|
+
return transaction !== undefined && matchesEntitlementRetry(transaction, identity);
|
|
727
|
+
}
|
|
728
|
+
function matchesEntitlementRetry(transaction, identity) {
|
|
729
|
+
return transaction.source === identity.source
|
|
730
|
+
&& transaction.playerId === identity.playerId
|
|
731
|
+
&& transaction.idempotencyKey === identity.idempotencyKey
|
|
732
|
+
&& transaction.grantId === identity.grantId
|
|
733
|
+
&& transaction.payload.target === identity.target;
|
|
734
|
+
}
|
|
735
|
+
function resolveEvidenceVerificationTimeout(timeoutMs) {
|
|
736
|
+
const resolved = timeoutMs ?? 10000;
|
|
737
|
+
if (!Number.isFinite(resolved) || resolved <= 0) {
|
|
738
|
+
throw new Error('evidenceVerificationTimeoutMs must be a positive finite number.');
|
|
739
|
+
}
|
|
740
|
+
return resolved;
|
|
741
|
+
}
|
|
742
|
+
function assertNonEmptyDecisionString(value, label) {
|
|
743
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
744
|
+
throw new Error(`Evidence verification ${label} must be a non-empty string.`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
336
747
|
async function recordLeaderboardScoreWithStore(input, context) {
|
|
337
748
|
return context.store.recordLeaderboardScore(assertRecordLeaderboardScoreRequest(input), {
|
|
338
749
|
recordedAt: context.now(),
|
|
@@ -379,12 +790,21 @@ function createEntitlementTransaction(grant) {
|
|
|
379
790
|
idempotencyKey: grant.idempotencyKey,
|
|
380
791
|
grantedAt: grant.grantedAt,
|
|
381
792
|
payload: grant.payload,
|
|
793
|
+
...(grant.evidenceVerificationId === undefined
|
|
794
|
+
? {}
|
|
795
|
+
: { evidenceVerificationId: grant.evidenceVerificationId }),
|
|
382
796
|
};
|
|
383
797
|
return assertProductGrantTransaction(grant.grant === undefined ? baseTransaction : { ...baseTransaction, grant: grant.grant });
|
|
384
798
|
}
|
|
385
799
|
function createEntitlementIdempotencyKey(grant) {
|
|
386
800
|
return createCompositeKey([grant.source, grant.playerId, grant.idempotencyKey]);
|
|
387
801
|
}
|
|
802
|
+
function createEntitlementEvidenceKey(grant) {
|
|
803
|
+
return createCompositeKey([grant.source, grant.evidenceVerificationId]);
|
|
804
|
+
}
|
|
805
|
+
function createEntitlementPlatformEvidenceKey(identity) {
|
|
806
|
+
return createCompositeKey([identity.source, identity.target, identity.platformEvidenceId]);
|
|
807
|
+
}
|
|
388
808
|
function createEntitlementLedgerEntryId(grant) {
|
|
389
809
|
return [
|
|
390
810
|
'ledger',
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ProductGrant } from '@mpgd/catalog';
|
|
2
|
-
import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformTarget } from '@mpgd/platform';
|
|
2
|
+
import type { LeaderboardScoreInput, LogicalAdPlacementId, LogicalProductId, PlatformEvidenceEnvelope, PlatformTarget } from '@mpgd/platform';
|
|
3
|
+
export type { PlatformEvidenceEnvelope } from '@mpgd/platform';
|
|
3
4
|
export type GameServicesStoreTarget = Extract<PlatformTarget, 'android' | 'ios' | 'ait'>;
|
|
4
5
|
export type GameServicesLedgerTarget = Extract<PlatformTarget, 'browser' | 'android' | 'ios' | 'ait' | 'reddit'>;
|
|
5
6
|
export type PurchaseIdempotencyKey = string;
|
|
@@ -14,6 +15,7 @@ export interface VerifyPurchaseRequest {
|
|
|
14
15
|
readonly platformTransactionId: string;
|
|
15
16
|
readonly idempotencyKey: PurchaseIdempotencyKey;
|
|
16
17
|
readonly purchasedAt: string;
|
|
18
|
+
readonly evidence?: PlatformEvidenceEnvelope;
|
|
17
19
|
}
|
|
18
20
|
export interface VerifyPurchaseResponse {
|
|
19
21
|
readonly verified: boolean;
|
|
@@ -28,12 +30,13 @@ export interface ClaimAdRewardRequest {
|
|
|
28
30
|
readonly platformImpressionId?: string;
|
|
29
31
|
readonly idempotencyKey: AdRewardIdempotencyKey;
|
|
30
32
|
readonly completedAt: string;
|
|
33
|
+
readonly evidence?: PlatformEvidenceEnvelope;
|
|
31
34
|
}
|
|
32
35
|
export interface ClaimAdRewardResponse {
|
|
33
36
|
readonly granted: boolean;
|
|
34
37
|
readonly ledgerEntryId?: string;
|
|
35
38
|
readonly alreadyProcessed: boolean;
|
|
36
|
-
readonly reason?:
|
|
39
|
+
readonly reason?: string;
|
|
37
40
|
}
|
|
38
41
|
export interface RecordLeaderboardScoreRequest extends LeaderboardScoreInput {
|
|
39
42
|
readonly target: GameServicesLedgerTarget;
|
|
@@ -54,6 +57,7 @@ export interface EntitlementLedgerGrant {
|
|
|
54
57
|
readonly grantedAt: string;
|
|
55
58
|
readonly grant?: ProductGrant;
|
|
56
59
|
readonly payload: EntitlementLedgerPayload;
|
|
60
|
+
readonly evidenceVerificationId?: string;
|
|
57
61
|
}
|
|
58
62
|
export interface EntitlementLedgerResult {
|
|
59
63
|
readonly ledgerEntryId: string;
|
|
@@ -68,6 +72,7 @@ export interface ProductGrantTransaction {
|
|
|
68
72
|
readonly grantedAt: string;
|
|
69
73
|
readonly grant?: ProductGrant;
|
|
70
74
|
readonly payload: EntitlementLedgerPayload;
|
|
75
|
+
readonly evidenceVerificationId?: string;
|
|
71
76
|
}
|
|
72
77
|
export interface LeaderboardScoreTransaction extends RecordLeaderboardScoreRequest {
|
|
73
78
|
readonly ledgerEntryId: string;
|
package/dist/types.js
CHANGED
|
@@ -6,6 +6,7 @@ export function assertVerifyPurchaseRequest(input) {
|
|
|
6
6
|
assertNonEmptyString(input.platformTransactionId, 'platformTransactionId');
|
|
7
7
|
assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
|
|
8
8
|
assertNonEmptyString(input.purchasedAt, 'purchasedAt');
|
|
9
|
+
assertOptionalEvidenceEnvelope(input.evidence);
|
|
9
10
|
return input;
|
|
10
11
|
}
|
|
11
12
|
export function assertVerifyPurchaseResponse(input) {
|
|
@@ -24,6 +25,7 @@ export function assertClaimAdRewardRequest(input) {
|
|
|
24
25
|
assertOptionalNonEmptyString(input.platformImpressionId, 'platformImpressionId');
|
|
25
26
|
assertNonEmptyString(input.idempotencyKey, 'idempotencyKey');
|
|
26
27
|
assertNonEmptyString(input.completedAt, 'completedAt');
|
|
28
|
+
assertOptionalEvidenceEnvelope(input.evidence);
|
|
27
29
|
return input;
|
|
28
30
|
}
|
|
29
31
|
export function assertClaimAdRewardResponse(input) {
|
|
@@ -31,7 +33,7 @@ export function assertClaimAdRewardResponse(input) {
|
|
|
31
33
|
assertBoolean(input.granted, 'granted');
|
|
32
34
|
assertOptionalNonEmptyString(input.ledgerEntryId, 'ledgerEntryId');
|
|
33
35
|
assertBoolean(input.alreadyProcessed, 'alreadyProcessed');
|
|
34
|
-
|
|
36
|
+
assertOptionalNonEmptyString(input.reason, 'reason');
|
|
35
37
|
return input;
|
|
36
38
|
}
|
|
37
39
|
export function assertRecordLeaderboardScoreRequest(input) {
|
|
@@ -64,6 +66,7 @@ export function assertEntitlementLedgerGrant(input) {
|
|
|
64
66
|
assertProductGrant(input.grant);
|
|
65
67
|
}
|
|
66
68
|
assertPayload(input.payload);
|
|
69
|
+
assertOptionalNonEmptyString(input.evidenceVerificationId, 'evidenceVerificationId');
|
|
67
70
|
return input;
|
|
68
71
|
}
|
|
69
72
|
export function assertEntitlementLedgerResult(input) {
|
|
@@ -84,6 +87,7 @@ export function assertProductGrantTransaction(input) {
|
|
|
84
87
|
assertProductGrant(input.grant);
|
|
85
88
|
}
|
|
86
89
|
assertPayload(input.payload);
|
|
90
|
+
assertOptionalNonEmptyString(input.evidenceVerificationId, 'evidenceVerificationId');
|
|
87
91
|
return input;
|
|
88
92
|
}
|
|
89
93
|
export function assertLeaderboardScoreTransaction(input) {
|
|
@@ -151,13 +155,6 @@ function assertProductGrant(input) {
|
|
|
151
155
|
}
|
|
152
156
|
throw new Error('grant.type must be currency or entitlement.');
|
|
153
157
|
}
|
|
154
|
-
function assertOptionalAdRewardReason(input, label) {
|
|
155
|
-
if (input !== undefined
|
|
156
|
-
&& input !== 'UNKNOWN_PLACEMENT'
|
|
157
|
-
&& input !== 'NOT_REWARDED_PLACEMENT') {
|
|
158
|
-
throw new Error(`${label} must be UNKNOWN_PLACEMENT or NOT_REWARDED_PLACEMENT.`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
158
|
function assertPayload(input) {
|
|
162
159
|
assertRecord(input, 'payload');
|
|
163
160
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -167,3 +164,11 @@ function assertPayload(input) {
|
|
|
167
164
|
}
|
|
168
165
|
}
|
|
169
166
|
}
|
|
167
|
+
function assertOptionalEvidenceEnvelope(input) {
|
|
168
|
+
if (input === undefined) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
assertRecord(input, 'evidence');
|
|
172
|
+
assertNonEmptyString(input.schema, 'evidence.schema');
|
|
173
|
+
assertPayload(input.payload);
|
|
174
|
+
}
|