@mpgd/game-services 0.30.5 → 0.32.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 +101 -0
- package/dist/another-account.d.ts +65 -0
- package/dist/another-account.js +107 -0
- package/dist/app-store-verifier.js +24 -5
- package/dist/client.d.ts +16 -2
- package/dist/client.js +33 -5
- package/dist/commerce-reconciler.d.ts +89 -4
- package/dist/commerce-reconciler.js +467 -35
- package/dist/contract.d.ts +6 -6
- package/dist/evidence-verification.d.ts +14 -2
- package/dist/google-play-purchase.js +22 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/monetization-recovery.js +1 -1
- package/dist/orpc-error-compat.d.ts +42 -0
- package/dist/orpc-error-compat.js +102 -0
- package/dist/request-headers.d.ts +22 -0
- package/dist/request-headers.js +34 -1
- package/dist/runtime.d.ts +82 -2
- package/dist/runtime.js +363 -10
- package/dist/server.d.ts +31 -7
- package/dist/server.js +125 -15
- package/package.json +9 -9
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, summarizeCommercePurchaseState, } from '@mpgd/platform/commerce';
|
|
2
|
+
import { isStoreAccountMismatchReason, readPurchaseBoundToAnotherAccount, } from './another-account.js';
|
|
2
3
|
import { compareCommerceServiceCapabilities, } from './commerce-evidence-registry.js';
|
|
3
4
|
import { chunkLedgerEntryIds } from './commerce-ledger.js';
|
|
4
5
|
/**
|
|
@@ -37,13 +38,37 @@ export function isCommercePurchaseOperationRecord(record) {
|
|
|
37
38
|
&& record !== null
|
|
38
39
|
&& record.kind === commercePurchaseOperationKind;
|
|
39
40
|
}
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Summary status of a record: the RFC mapping, with `revoked` kept distinct.
|
|
43
|
+
* A record parked as another account's purchase is `rejected` for its
|
|
44
|
+
* player: nothing was granted to it, and nothing will be.
|
|
45
|
+
*/
|
|
41
46
|
export function summarizeCommercePurchaseOperation(record) {
|
|
42
47
|
if (isRetryableBeforeLaunch(record)) {
|
|
43
48
|
return 'failed';
|
|
44
49
|
}
|
|
50
|
+
if (isParkedForAnotherAccount(record)) {
|
|
51
|
+
return 'rejected';
|
|
52
|
+
}
|
|
45
53
|
return summarizeCommercePurchaseState(record.state);
|
|
46
54
|
}
|
|
55
|
+
/** A `verifying` record the backend answered as another account's purchase. */
|
|
56
|
+
export function isParkedForAnotherAccount(record) {
|
|
57
|
+
return record.state === 'verifying' && record.anotherAccount !== undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether another player's record under a shared key leaves the key to this
|
|
61
|
+
* player: parked as another account's purchase, or (a journal written before
|
|
62
|
+
* that answer) `rejected` with a store binding mismatch reason. Either way
|
|
63
|
+
* the purchase was never its player's, and nothing on the backend holds it.
|
|
64
|
+
*/
|
|
65
|
+
function releasesSharedKey(record) {
|
|
66
|
+
return isParkedForAnotherAccount(record)
|
|
67
|
+
|| (record.state === 'rejected'
|
|
68
|
+
&& record.response !== undefined
|
|
69
|
+
&& !record.response.verified
|
|
70
|
+
&& isStoreAccountMismatchReason(record.response.reason));
|
|
71
|
+
}
|
|
47
72
|
function isRetryableBeforeLaunch(record) {
|
|
48
73
|
return record.state === 'requested' && record.error?.retryable === true
|
|
49
74
|
&& record.purchase === undefined && record.request === undefined;
|
|
@@ -54,7 +79,9 @@ function isRetryableBeforeLaunch(record) {
|
|
|
54
79
|
* finish allow-list of its own (the removed v1 driver paired transactions in
|
|
55
80
|
* `finishGrantedTransaction`), so this is the finish gate for every `client`
|
|
56
81
|
* and `host` owner: the ledger entry id comes from the journal revision the
|
|
57
|
-
* backend grant wrote.
|
|
82
|
+
* backend grant wrote. The one other finish call is a `finished` consumable a
|
|
83
|
+
* `client` store reports again, finished with the same journaled ledger entry
|
|
84
|
+
* (see `finishReportedAgain` in `createCommerceReconciler`).
|
|
58
85
|
*/
|
|
59
86
|
export function assertCommerceFinishAllowed(record) {
|
|
60
87
|
if (record.state !== 'granted' && record.state !== 'finishing') {
|
|
@@ -258,6 +285,30 @@ export function createCommerceReconciler(input) {
|
|
|
258
285
|
const trigger = () => {
|
|
259
286
|
triggerPending = true;
|
|
260
287
|
};
|
|
288
|
+
/** The session check's refusal, if any; a check that throws refuses too. */
|
|
289
|
+
const sessionRefusal = () => {
|
|
290
|
+
if (input.sessionCheck === undefined) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return input.sessionCheck();
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return new CommerceError('ACCOUNT_BINDING_REQUIRED', {
|
|
298
|
+
retryable: true,
|
|
299
|
+
message: `The session check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
/** True (and reported) when the reconciler must not act for its player now. */
|
|
304
|
+
const sessionBlocked = (idempotencyKey) => {
|
|
305
|
+
const refusal = sessionRefusal();
|
|
306
|
+
if (refusal === undefined) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
fail('session', refusal, idempotencyKey);
|
|
310
|
+
return true;
|
|
311
|
+
};
|
|
261
312
|
const ownsRecord = (record) => record.playerId === playerId
|
|
262
313
|
&& record.target === target
|
|
263
314
|
&& record.deploymentTarget === deploymentTarget
|
|
@@ -457,6 +508,27 @@ export function createCommerceReconciler(input) {
|
|
|
457
508
|
retryAt.delete(record.idempotencyKey);
|
|
458
509
|
return next;
|
|
459
510
|
};
|
|
511
|
+
/**
|
|
512
|
+
* Parks a `verifying` record the backend answered as another account's
|
|
513
|
+
* purchase, or (`marker` undefined) lets this player's Restore Purchases
|
|
514
|
+
* send it again. The state stays `verifying`: the record is not ended for
|
|
515
|
+
* the store transaction, which its account can still recover.
|
|
516
|
+
*/
|
|
517
|
+
const reviseAnotherAccount = async (record, marker, patch = {}) => {
|
|
518
|
+
const { anotherAccount: _previous, ...rest } = record;
|
|
519
|
+
const next = {
|
|
520
|
+
...rest,
|
|
521
|
+
...patch,
|
|
522
|
+
...(marker === undefined ? {} : { anotherAccount: marker }),
|
|
523
|
+
state: record.state,
|
|
524
|
+
revision: record.revision + 1,
|
|
525
|
+
updatedAt: now(),
|
|
526
|
+
};
|
|
527
|
+
await journal.replace(record.revision, next);
|
|
528
|
+
remember(next);
|
|
529
|
+
retryAt.delete(record.idempotencyKey);
|
|
530
|
+
return next;
|
|
531
|
+
};
|
|
460
532
|
/**
|
|
461
533
|
* Where this player's record of an idempotency key lives, and what that key
|
|
462
534
|
* holds now. A record is under the shared key, or under this player's
|
|
@@ -470,6 +542,11 @@ export function createCommerceReconciler(input) {
|
|
|
470
542
|
* the slot holds, and `recordIn` refuses it.
|
|
471
543
|
* - `probeFree`: when the shared key is free, a record under the scoped key
|
|
472
544
|
* still answers (retention may have dropped the other player's record).
|
|
545
|
+
*
|
|
546
|
+
* Another player's record that was answered as another account's purchase
|
|
547
|
+
* (`releasesSharedKey`) is never this player's to refuse: the scoped key is
|
|
548
|
+
* the slot for any kind, as with `takeHeld`, so the purchase's own account
|
|
549
|
+
* recovers it on the same install.
|
|
473
550
|
*/
|
|
474
551
|
const resolveSlot = async (idempotencyKey, options = {}) => {
|
|
475
552
|
const sharedKey = createCommercePurchaseOperationKey(target, idempotencyKey);
|
|
@@ -477,6 +554,7 @@ export function createCommerceReconciler(input) {
|
|
|
477
554
|
const heldByAnother = shared !== undefined && heldByAnotherPlayer(shared);
|
|
478
555
|
const probeScoped = heldByAnother
|
|
479
556
|
? options.takeHeld === true
|
|
557
|
+
|| (isCommercePurchaseOperationRecord(shared) && releasesSharedKey(shared))
|
|
480
558
|
: shared === undefined && options.probeFree === true;
|
|
481
559
|
if (!probeScoped) {
|
|
482
560
|
return { key: sharedKey, found: shared, heldByAnother };
|
|
@@ -506,9 +584,15 @@ export function createCommerceReconciler(input) {
|
|
|
506
584
|
*/
|
|
507
585
|
const readRecord = async (idempotencyKey) => recordIn(await resolveSlot(idempotencyKey), idempotencyKey);
|
|
508
586
|
const ownedRecordsOf = (listed) => listed.filter((record) => isCommercePurchaseOperationRecord(record) && ownsRecord(record));
|
|
587
|
+
/**
|
|
588
|
+
* The player's open records. One parked as another account's purchase is
|
|
589
|
+
* kept in memory, so a store report matches it without the journal's
|
|
590
|
+
* transaction index, but is not the player's to recover.
|
|
591
|
+
*/
|
|
509
592
|
const listRecoverable = async () => ownedRecordsOf(await journal.listRecoverable(playerId))
|
|
510
593
|
.filter((record) => !isTerminalCommercePurchaseState(record.state))
|
|
511
|
-
.map(remember)
|
|
594
|
+
.map(remember)
|
|
595
|
+
.filter((record) => !isParkedForAnotherAccount(record));
|
|
512
596
|
const listGranted = async () => {
|
|
513
597
|
const listed = journal.listGranted === undefined
|
|
514
598
|
? []
|
|
@@ -551,6 +635,11 @@ export function createCommerceReconciler(input) {
|
|
|
551
635
|
if (!capabilitiesNow.lifecycle.availablePurchases) {
|
|
552
636
|
return true;
|
|
553
637
|
}
|
|
638
|
+
if (sessionBlocked()) {
|
|
639
|
+
// Another player's store account may be current: nothing is read into
|
|
640
|
+
// this player's journal, and the next permitted pass reads again.
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
554
643
|
const read = async (withRestore) => {
|
|
555
644
|
const available = await provider.getAvailablePurchases({
|
|
556
645
|
includePending: capabilitiesNow.lifecycle.pending,
|
|
@@ -609,6 +698,153 @@ export function createCommerceReconciler(input) {
|
|
|
609
698
|
...(record.origin === 'discovered' ? { intent: 'restore' } : {}),
|
|
610
699
|
};
|
|
611
700
|
};
|
|
701
|
+
/** Whether a store purchase is bound to another account than this player's (`accountBinding`). */
|
|
702
|
+
const accountOf = async (purchase) => {
|
|
703
|
+
const bound = purchase.accountBinding;
|
|
704
|
+
if (input.accountBinding === undefined || bound?.value === undefined) {
|
|
705
|
+
return 'own';
|
|
706
|
+
}
|
|
707
|
+
let own;
|
|
708
|
+
try {
|
|
709
|
+
own = await input.accountBinding();
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
own = undefined;
|
|
713
|
+
}
|
|
714
|
+
if (own?.value === undefined) {
|
|
715
|
+
// This player's binding cannot be read now: the purchase cannot be shown to be theirs yet.
|
|
716
|
+
return 'unknown';
|
|
717
|
+
}
|
|
718
|
+
if (own.kind !== bound.kind) {
|
|
719
|
+
return 'own';
|
|
720
|
+
}
|
|
721
|
+
const normalize = (value) => bound.kind === 'app-account-token' ? value.toLowerCase() : value;
|
|
722
|
+
return normalize(own.value) === normalize(bound.value) ? 'own' : 'another';
|
|
723
|
+
};
|
|
724
|
+
/** Transactions `finishReportedAgain` already finished again this session. */
|
|
725
|
+
const finishedAgain = new Set();
|
|
726
|
+
/**
|
|
727
|
+
* A client-finish store reports a consumable only while it is unfinished,
|
|
728
|
+
* so one reported again after its record finished was never finished in
|
|
729
|
+
* the store: its finish was reported done without a native call (a lost
|
|
730
|
+
* acknowledgement the store's list could not tell apart from an
|
|
731
|
+
* unverifiable entry or a list read too early), or the store re-delivered
|
|
732
|
+
* it. It is finished again with the ledger entry the grant wrote, once per
|
|
733
|
+
* session (a call that failed in transport is tried again on the next
|
|
734
|
+
* report); it is never verified or granted again (the ledger replays a key
|
|
735
|
+
* and refuses the same evidence under another one). Durables, which a
|
|
736
|
+
* store keeps listing after their finish, and other finish owners are left
|
|
737
|
+
* alone. It needs the finished record: past the journal's retention the
|
|
738
|
+
* transaction takes the derived-key path, where the reference backend
|
|
739
|
+
* answers a `client`-finish consumable with this player's recorded grant
|
|
740
|
+
* (never a second one), and the record finishes it like any grant.
|
|
741
|
+
*/
|
|
742
|
+
const finishReportedAgain = async (record, kind) => {
|
|
743
|
+
const ledgerEntryId = record.ledgerEntryId;
|
|
744
|
+
const transactionId = record.purchase?.transactionId;
|
|
745
|
+
if ((record.productKind ?? kind) !== 'consumable'
|
|
746
|
+
|| capabilities?.lifecycle.finish !== 'client'
|
|
747
|
+
|| provider.finishTransaction === undefined
|
|
748
|
+
|| ledgerEntryId === undefined
|
|
749
|
+
|| ledgerEntryId.trim() === ''
|
|
750
|
+
|| transactionId === undefined
|
|
751
|
+
|| transactionId.trim() === ''
|
|
752
|
+
|| finishedAgain.has(transactionId)) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (sessionBlocked(record.idempotencyKey)) {
|
|
756
|
+
// The session changed while the report was matched: the next report asks again.
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
finishedAgain.add(transactionId);
|
|
760
|
+
try {
|
|
761
|
+
const { finished } = await provider.finishTransaction({
|
|
762
|
+
transactionId,
|
|
763
|
+
ledgerEntryId,
|
|
764
|
+
isConsumable: true,
|
|
765
|
+
});
|
|
766
|
+
if (!finished) {
|
|
767
|
+
// The store refused it (for StoreKit: revoked or another token); the
|
|
768
|
+
// next session asks again. The record stays finished.
|
|
769
|
+
fail('finish', new Error(`The store did not finish ${transactionId} it reported again.`), record.idempotencyKey);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
catch (error) {
|
|
773
|
+
// Transport-class failure: the next report of the transaction tries again.
|
|
774
|
+
finishedAgain.delete(transactionId);
|
|
775
|
+
fail('finish', error, record.idempotencyKey);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
/**
|
|
779
|
+
* A store report of a record the backend answered as another account's
|
|
780
|
+
* purchase. Only this player's Restore Purchases asks again (a restore
|
|
781
|
+
* sharing policy or a binding may have changed since), with the evidence
|
|
782
|
+
* the store reports now, so a stale saved request (an expired signed
|
|
783
|
+
* transaction) is not resent, and as a restore, which the player asked
|
|
784
|
+
* for (restore sharing may cover a durable the game started, too); other
|
|
785
|
+
* reports leave it parked. It stays parked until the backend answers
|
|
786
|
+
* something else: a transport failure (an offline Restore) changes
|
|
787
|
+
* nothing, so the record never becomes an open attempt that serializes the
|
|
788
|
+
* player's purchases or holds the shared key against the purchase's
|
|
789
|
+
* account. Another answer unparks it and settles it as a verify does; a
|
|
790
|
+
* grant is finished by the pass's recoverable records.
|
|
791
|
+
*
|
|
792
|
+
* Returns false when the journal dropped the record before Restore could
|
|
793
|
+
* rewrite it (another context's save applied the journal's retention after
|
|
794
|
+
* `rereadParked` read it): the record is forgotten and the caller matches
|
|
795
|
+
* the report as a purchase the journal does not know.
|
|
796
|
+
*/
|
|
797
|
+
const reportParked = async (record, purchase, restoring) => {
|
|
798
|
+
if (!restoring || purchase.state !== 'purchased') {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
const request = {
|
|
802
|
+
...buildRequest({ ...record, purchase }),
|
|
803
|
+
intent: 'restore',
|
|
804
|
+
};
|
|
805
|
+
let refreshed;
|
|
806
|
+
try {
|
|
807
|
+
refreshed = await reviseAnotherAccount(record, record.anotherAccount, {
|
|
808
|
+
purchase,
|
|
809
|
+
request,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
if (await rereadParked(record) === undefined) {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
throw error;
|
|
817
|
+
}
|
|
818
|
+
const answer = await sendVerify(refreshed, request);
|
|
819
|
+
if (answer === undefined) {
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
if (answer.kind === 'another-account') {
|
|
823
|
+
await parkForAnotherAccount(refreshed, request, answer);
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
await settleVerify(await reviseAnotherAccount(refreshed, undefined), request, answer.response);
|
|
827
|
+
return true;
|
|
828
|
+
};
|
|
829
|
+
/**
|
|
830
|
+
* The journal's current record of a parked record this session holds, or
|
|
831
|
+
* undefined (and forgotten) when the journal no longer holds it for this
|
|
832
|
+
* player: dropped, or the key reserved again for another purchase of the
|
|
833
|
+
* player, which this purchase's report must never be written onto.
|
|
834
|
+
*/
|
|
835
|
+
const rereadParked = async (record) => {
|
|
836
|
+
const current = await journal.read(record.key);
|
|
837
|
+
if (current !== undefined
|
|
838
|
+
&& isCommercePurchaseOperationRecord(current)
|
|
839
|
+
&& ownsRecord(current)
|
|
840
|
+
&& current.purchase !== undefined
|
|
841
|
+
&& record.purchase !== undefined
|
|
842
|
+
&& samePurchase(current.purchase, record.purchase)) {
|
|
843
|
+
return remember(current);
|
|
844
|
+
}
|
|
845
|
+
records.delete(record.idempotencyKey);
|
|
846
|
+
return undefined;
|
|
847
|
+
};
|
|
612
848
|
// Reconciler step 2: match a reported purchase to a journal record.
|
|
613
849
|
const ingestPurchase = async (purchase, restoring) => {
|
|
614
850
|
if (purchase.store !== storeId) {
|
|
@@ -638,6 +874,35 @@ export function createCommerceReconciler(input) {
|
|
|
638
874
|
record = remember(stored);
|
|
639
875
|
}
|
|
640
876
|
}
|
|
877
|
+
if (record !== undefined && isParkedForAnotherAccount(record) && restoring) {
|
|
878
|
+
// Restore Purchases writes the record, so it must still be the journal's:
|
|
879
|
+
// a journal's retention may have dropped a parked record this session
|
|
880
|
+
// still holds (RFC "Another account's purchase"). Then the report is a
|
|
881
|
+
// purchase the journal does not know, and is matched as one.
|
|
882
|
+
record = await rereadParked(record);
|
|
883
|
+
}
|
|
884
|
+
if (record !== undefined && isParkedForAnotherAccount(record)) {
|
|
885
|
+
if (await reportParked(record, purchase, restoring)) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
record = undefined;
|
|
889
|
+
}
|
|
890
|
+
const account = record !== undefined || (restoring && product.kind === 'durable')
|
|
891
|
+
? 'own'
|
|
892
|
+
: await accountOf(purchase);
|
|
893
|
+
if (account === 'unknown') {
|
|
894
|
+
// Not decided yet: the report waits for the next pass, which asks again.
|
|
895
|
+
defer({ type: 'purchaseUpdated', purchase });
|
|
896
|
+
fail('match', new Error(`Purchase ${purchase.transactionId} waits: this player's store account binding could not be read.`));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (account === 'another') {
|
|
900
|
+
// Another player's purchase (Play lists unacknowledged purchases of
|
|
901
|
+
// every app account on the device): neither attached to this player's
|
|
902
|
+
// open attempt nor journaled under a derived key.
|
|
903
|
+
fail('match', new Error(`Purchase ${purchase.transactionId} is bound to another store account; it is left for that player.`));
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
641
906
|
if (record === undefined) {
|
|
642
907
|
const open = openRecordsFor(product.logicalId);
|
|
643
908
|
if (open.length > 1) {
|
|
@@ -676,6 +941,16 @@ export function createCommerceReconciler(input) {
|
|
|
676
941
|
probeFree: durable,
|
|
677
942
|
});
|
|
678
943
|
record = recordIn(slot, idempotencyKey);
|
|
944
|
+
if (record !== undefined
|
|
945
|
+
&& isParkedForAnotherAccount(record)
|
|
946
|
+
&& record.logicalId === product.logicalId
|
|
947
|
+
&& record.purchase !== undefined
|
|
948
|
+
&& samePurchase(record.purchase, purchase)) {
|
|
949
|
+
// Found under its derived key only (a journal without the
|
|
950
|
+
// transaction index): the same rule as a transaction match.
|
|
951
|
+
await reportParked(record, purchase, restoring);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
679
954
|
if (record !== undefined
|
|
680
955
|
&& (record.logicalId !== product.logicalId
|
|
681
956
|
|| (record.purchase !== undefined && !samePurchase(record.purchase, purchase)))) {
|
|
@@ -697,6 +972,10 @@ export function createCommerceReconciler(input) {
|
|
|
697
972
|
return;
|
|
698
973
|
}
|
|
699
974
|
}
|
|
975
|
+
if (record.state === 'finished' && purchase.state === 'purchased') {
|
|
976
|
+
await finishReportedAgain(record, product.kind);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
700
979
|
if (purchase.state === 'pending' && record.state === 'launched') {
|
|
701
980
|
await write(record, 'pending', { purchase });
|
|
702
981
|
return;
|
|
@@ -735,18 +1014,71 @@ export function createCommerceReconciler(input) {
|
|
|
735
1014
|
retryable: false,
|
|
736
1015
|
message,
|
|
737
1016
|
});
|
|
1017
|
+
/**
|
|
1018
|
+
* The backend's answer: a response, another account's purchase (the
|
|
1019
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error, or a `rejected` receipt with a
|
|
1020
|
+
* store binding mismatch reason from a backend before it), or undefined
|
|
1021
|
+
* for a transport failure.
|
|
1022
|
+
*/
|
|
738
1023
|
const sendVerify = async (record, request) => {
|
|
739
1024
|
emit({ type: 'verify-requested', record });
|
|
1025
|
+
let response;
|
|
740
1026
|
try {
|
|
741
|
-
|
|
742
|
-
emit({ type: 'verify-result', record, response });
|
|
743
|
-
return response;
|
|
1027
|
+
response = await backend.verifyPurchase(request);
|
|
744
1028
|
}
|
|
745
1029
|
catch (error) {
|
|
1030
|
+
const anotherAccount = readPurchaseBoundToAnotherAccount(error);
|
|
1031
|
+
if (anotherAccount !== undefined) {
|
|
1032
|
+
// An answer, not a failure: the caller parks the record and reports it.
|
|
1033
|
+
return { kind: 'another-account', reason: anotherAccount.reason };
|
|
1034
|
+
}
|
|
746
1035
|
// Transport failure: the state is kept and the next pass resends the saved request.
|
|
747
1036
|
fail('verify', error, record.idempotencyKey);
|
|
748
1037
|
return undefined;
|
|
749
1038
|
}
|
|
1039
|
+
emit({ type: 'verify-result', record, response });
|
|
1040
|
+
if (!response.verified
|
|
1041
|
+
&& response.disposition !== 'pending'
|
|
1042
|
+
&& isStoreAccountMismatchReason(response.reason)) {
|
|
1043
|
+
return { kind: 'another-account', reason: response.reason, response };
|
|
1044
|
+
}
|
|
1045
|
+
return { kind: 'response', response };
|
|
1046
|
+
};
|
|
1047
|
+
/**
|
|
1048
|
+
* Parks a `verifying` record the backend answered as another account's
|
|
1049
|
+
* purchase and reports it with an `another-account` event (not a failure).
|
|
1050
|
+
*/
|
|
1051
|
+
const parkForAnotherAccount = async (record, request, answer) => {
|
|
1052
|
+
const parked = await reviseAnotherAccount(record, { reason: answer.reason, reportedAt: now() }, { request, ...(answer.response === undefined ? {} : { response: answer.response }) });
|
|
1053
|
+
emit({
|
|
1054
|
+
type: 'another-account',
|
|
1055
|
+
record: parked,
|
|
1056
|
+
reason: answer.reason,
|
|
1057
|
+
...(answer.response === undefined ? {} : { response: answer.response }),
|
|
1058
|
+
});
|
|
1059
|
+
};
|
|
1060
|
+
/**
|
|
1061
|
+
* Journals the backend's answer to a `verifying` record: `pending` or
|
|
1062
|
+
* `rejected`, or `granted` (returned) for a grant that carries its ledger
|
|
1063
|
+
* entry; a malformed grant is reported and the record kept.
|
|
1064
|
+
*/
|
|
1065
|
+
const settleVerify = async (record, request, response) => {
|
|
1066
|
+
if (!response.verified) {
|
|
1067
|
+
if (response.disposition === 'pending') {
|
|
1068
|
+
await write(record, 'pending', { request, response });
|
|
1069
|
+
retryLater(record.idempotencyKey);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
await write(record, 'rejected', { request, response });
|
|
1073
|
+
}
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const ledgerEntryId = response.ledgerEntryId;
|
|
1077
|
+
if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
|
|
1078
|
+
fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
|
|
1079
|
+
return undefined;
|
|
1080
|
+
}
|
|
1081
|
+
return write(record, 'granted', { request, response, ledgerEntryId });
|
|
750
1082
|
};
|
|
751
1083
|
// Reconciler steps 3 and 4: drive one record as far as the backend and the store allow.
|
|
752
1084
|
const drive = async (initial, capabilitiesNow, explicit) => {
|
|
@@ -761,6 +1093,10 @@ export function createCommerceReconciler(input) {
|
|
|
761
1093
|
}
|
|
762
1094
|
try {
|
|
763
1095
|
for (;;) {
|
|
1096
|
+
if (sessionBlocked(record.idempotencyKey)) {
|
|
1097
|
+
// The record stays as journaled for this player's next permitted pass.
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
764
1100
|
switch (record.state) {
|
|
765
1101
|
case 'requested':
|
|
766
1102
|
if (isRetryableBeforeLaunch(record)) {
|
|
@@ -804,29 +1140,30 @@ export function createCommerceReconciler(input) {
|
|
|
804
1140
|
record = await write(record, 'verifying');
|
|
805
1141
|
continue;
|
|
806
1142
|
case 'verifying': {
|
|
1143
|
+
if (isParkedForAnotherAccount(record)) {
|
|
1144
|
+
// Another account's purchase: only this player's Restore
|
|
1145
|
+
// Purchases sends it again (`ingestPurchase`).
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
807
1148
|
const request = record.request ?? buildRequest(record);
|
|
808
|
-
const
|
|
809
|
-
if (
|
|
1149
|
+
const answer = await sendVerify(record, request);
|
|
1150
|
+
if (answer === undefined) {
|
|
810
1151
|
retryLater(record.idempotencyKey);
|
|
811
1152
|
return;
|
|
812
1153
|
}
|
|
813
|
-
if (
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
else {
|
|
819
|
-
await write(record, 'rejected', { request, response });
|
|
820
|
-
}
|
|
1154
|
+
if (answer.kind === 'another-account') {
|
|
1155
|
+
// Not this player's, and not ended for the store transaction:
|
|
1156
|
+
// never sent again on its own, and the shared key is left to
|
|
1157
|
+
// the purchase's account (RFC "Another account's purchase").
|
|
1158
|
+
await parkForAnotherAccount(record, request, answer);
|
|
821
1159
|
return;
|
|
822
1160
|
}
|
|
823
|
-
const
|
|
824
|
-
if (
|
|
825
|
-
fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
|
|
1161
|
+
const granted = await settleVerify(record, request, answer.response);
|
|
1162
|
+
if (granted === undefined) {
|
|
826
1163
|
return;
|
|
827
1164
|
}
|
|
828
|
-
finalization = response.finalization?.status;
|
|
829
|
-
record =
|
|
1165
|
+
finalization = answer.response.finalization?.status;
|
|
1166
|
+
record = granted;
|
|
830
1167
|
continue;
|
|
831
1168
|
}
|
|
832
1169
|
case 'granted':
|
|
@@ -853,7 +1190,8 @@ export function createCommerceReconciler(input) {
|
|
|
853
1190
|
if (finalization === undefined) {
|
|
854
1191
|
// A pass that did not grant resends the saved request to read
|
|
855
1192
|
// finalization; the backend replays the grant, never a second one.
|
|
856
|
-
|
|
1193
|
+
const answer = await sendVerify(record, record.request ?? buildRequest(record));
|
|
1194
|
+
reread = answer?.kind === 'response' ? answer.response : undefined;
|
|
857
1195
|
finalization = reread?.verified === true
|
|
858
1196
|
? reread.finalization?.status
|
|
859
1197
|
: undefined;
|
|
@@ -881,12 +1219,25 @@ export function createCommerceReconciler(input) {
|
|
|
881
1219
|
retryLater(record.idempotencyKey);
|
|
882
1220
|
return;
|
|
883
1221
|
}
|
|
1222
|
+
if (sessionBlocked(record.idempotencyKey)) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
884
1225
|
let finished = false;
|
|
885
1226
|
try {
|
|
886
1227
|
finished = (await provider.finishTransaction({
|
|
887
1228
|
transactionId: finish.transactionId,
|
|
888
1229
|
ledgerEntryId: finish.ledgerEntryId,
|
|
889
1230
|
isConsumable: kind === 'consumable',
|
|
1231
|
+
// A record already `finishing` when this drive began was asked
|
|
1232
|
+
// to finish before (an earlier attempt or session): a `client`
|
|
1233
|
+
// store may read a consumable gone from its unfinished list as
|
|
1234
|
+
// that finish's lost acknowledgement. A first finish never is,
|
|
1235
|
+
// and a provider that does not declare the field never gets it.
|
|
1236
|
+
...(owner === 'client'
|
|
1237
|
+
&& initial.state === 'finishing'
|
|
1238
|
+
&& provider.acceptsFinishRetry === true
|
|
1239
|
+
? { retry: true }
|
|
1240
|
+
: {}),
|
|
890
1241
|
})).finished;
|
|
891
1242
|
}
|
|
892
1243
|
catch (error) {
|
|
@@ -961,6 +1312,10 @@ export function createCommerceReconciler(input) {
|
|
|
961
1312
|
};
|
|
962
1313
|
const eventPass = { explicit: false, refreshCapabilities: false };
|
|
963
1314
|
const pass = async (options) => {
|
|
1315
|
+
if (sessionBlocked()) {
|
|
1316
|
+
// Queued events wait for a pass the session permits.
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
964
1319
|
let capabilitiesNow = await readCapabilities(options.refreshCapabilities);
|
|
965
1320
|
if (options.refreshCapabilities) {
|
|
966
1321
|
await readServiceAlignment();
|
|
@@ -978,10 +1333,14 @@ export function createCommerceReconciler(input) {
|
|
|
978
1333
|
// call to journal the store answer: matched then, it is never journaled
|
|
979
1334
|
// twice, and a cancel event ends the attempt the call launched.
|
|
980
1335
|
const busy = inFlightProducts();
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1336
|
+
const queued = [...deferred.splice(0, deferred.length), ...inbox.splice(0, inbox.length)];
|
|
1337
|
+
for (const [index, event] of queued.entries()) {
|
|
1338
|
+
if (sessionBlocked()) {
|
|
1339
|
+
// A purchase reported now may be another player's: it is not
|
|
1340
|
+
// journaled under this one; the rest wait, in order.
|
|
1341
|
+
inbox.unshift(...queued.slice(index));
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
985
1344
|
try {
|
|
986
1345
|
if (event.type === 'purchaseUpdated') {
|
|
987
1346
|
const product = busy.size === 0 ? undefined : await resolveLogicalId(event.purchase);
|
|
@@ -1004,6 +1363,9 @@ export function createCommerceReconciler(input) {
|
|
|
1004
1363
|
}
|
|
1005
1364
|
}
|
|
1006
1365
|
for (const listed of await listRecoverable()) {
|
|
1366
|
+
if (sessionBlocked()) {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1007
1369
|
const record = records.get(listed.idempotencyKey) ?? listed;
|
|
1008
1370
|
if (options.restoreTrigger !== true && waitsForRestoreTrigger(record)) {
|
|
1009
1371
|
// At the holder limit only the player's Restore Purchases asks again;
|
|
@@ -1012,6 +1374,9 @@ export function createCommerceReconciler(input) {
|
|
|
1012
1374
|
}
|
|
1013
1375
|
await drive(record, capabilitiesNow, options.explicit);
|
|
1014
1376
|
}
|
|
1377
|
+
if (sessionBlocked()) {
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1015
1380
|
await revokeReversed();
|
|
1016
1381
|
};
|
|
1017
1382
|
const runUntilIdle = async () => {
|
|
@@ -1158,6 +1523,12 @@ export function createCommerceReconciler(input) {
|
|
|
1158
1523
|
})),
|
|
1159
1524
|
};
|
|
1160
1525
|
}
|
|
1526
|
+
const lateRefusal = sessionRefusal();
|
|
1527
|
+
if (lateRefusal !== undefined) {
|
|
1528
|
+
// The session changed while the purchase was prepared: nothing is journaled.
|
|
1529
|
+
fail('session', lateRefusal, request.idempotencyKey);
|
|
1530
|
+
return { kind: 'answered', result: failedOutcome(lateRefusal) };
|
|
1531
|
+
}
|
|
1161
1532
|
const { created, record } = existing === undefined ? await reserve({
|
|
1162
1533
|
idempotencyKey: request.idempotencyKey,
|
|
1163
1534
|
store: storeId,
|
|
@@ -1263,6 +1634,12 @@ export function createCommerceReconciler(input) {
|
|
|
1263
1634
|
// Only the call that reserved the record owns its in-flight entry: a
|
|
1264
1635
|
// same-key replay must not release it while the store sheet is open.
|
|
1265
1636
|
let owned = false;
|
|
1637
|
+
const refusal = sessionRefusal();
|
|
1638
|
+
if (refusal !== undefined) {
|
|
1639
|
+
// Nothing is journaled and no store UI opens; the key stays usable.
|
|
1640
|
+
fail('session', refusal, request.idempotencyKey);
|
|
1641
|
+
return failedOutcome(refusal);
|
|
1642
|
+
}
|
|
1266
1643
|
try {
|
|
1267
1644
|
const prepared = await withLock(() => prepareRequest(request));
|
|
1268
1645
|
if (prepared.kind === 'answered') {
|
|
@@ -1281,14 +1658,39 @@ export function createCommerceReconciler(input) {
|
|
|
1281
1658
|
notStarted = event.state === 'not-started';
|
|
1282
1659
|
}
|
|
1283
1660
|
});
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
finally {
|
|
1661
|
+
// Checked again with nothing awaited before the store call: the
|
|
1662
|
+
// provider binds the purchase to the account current now, which must
|
|
1663
|
+
// still be the player this attempt is journaled under.
|
|
1664
|
+
const lateRefusal = sessionRefusal();
|
|
1665
|
+
if (lateRefusal !== undefined) {
|
|
1666
|
+
fail('session', lateRefusal, request.idempotencyKey);
|
|
1291
1667
|
unsubscribePresentation?.();
|
|
1668
|
+
// The store never opened: the reserved attempt stays retryable.
|
|
1669
|
+
answer = failedOutcome(lateRefusal);
|
|
1670
|
+
notStarted = true;
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
try {
|
|
1674
|
+
answer = await provider.requestPurchase(request);
|
|
1675
|
+
}
|
|
1676
|
+
catch (error) {
|
|
1677
|
+
answer = failedOutcome(error);
|
|
1678
|
+
}
|
|
1679
|
+
finally {
|
|
1680
|
+
unsubscribePresentation?.();
|
|
1681
|
+
}
|
|
1682
|
+
// The provider refuses a missing or foreign account binding before
|
|
1683
|
+
// its store UI opens. Refused because the session changed meanwhile,
|
|
1684
|
+
// the attempt stays retryable instead of ending.
|
|
1685
|
+
const bindingRefusal = answer.outcome === 'failed'
|
|
1686
|
+
&& answer.error.code === 'ACCOUNT_BINDING_REQUIRED'
|
|
1687
|
+
? sessionRefusal()
|
|
1688
|
+
: undefined;
|
|
1689
|
+
if (bindingRefusal !== undefined) {
|
|
1690
|
+
fail('session', bindingRefusal, request.idempotencyKey);
|
|
1691
|
+
answer = failedOutcome(bindingRefusal);
|
|
1692
|
+
notStarted = true;
|
|
1693
|
+
}
|
|
1292
1694
|
}
|
|
1293
1695
|
result = await withLock(() => recordAnswer(request, prepared.record, prepared.product, answer, notStarted));
|
|
1294
1696
|
}
|
|
@@ -1330,13 +1732,27 @@ export function createCommerceReconciler(input) {
|
|
|
1330
1732
|
if (started) {
|
|
1331
1733
|
return;
|
|
1332
1734
|
}
|
|
1735
|
+
if (input.closed?.() === true) {
|
|
1736
|
+
// Closed for good: nothing subscribes, and nothing awaits before this
|
|
1737
|
+
// check, so a start under way already set `starting` for `stop()`.
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1333
1740
|
if (starting !== undefined) {
|
|
1334
1741
|
return starting;
|
|
1335
1742
|
}
|
|
1336
1743
|
starting = (async () => {
|
|
1337
1744
|
try {
|
|
1338
|
-
await withLock(async () => {
|
|
1745
|
+
const subscribed = await withLock(async () => {
|
|
1339
1746
|
await loadRecords();
|
|
1747
|
+
// The owner closed while this start waited for the lock or the
|
|
1748
|
+
// journal (its `stop()` may have stopped waiting already): leave
|
|
1749
|
+
// as a refused start does, with nothing subscribed or started and
|
|
1750
|
+
// no error. The records read stay cached, as after a `reconcile()`
|
|
1751
|
+
// before any start. Nothing awaits between this check and the
|
|
1752
|
+
// subscription.
|
|
1753
|
+
if (input.closed?.() === true) {
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1340
1756
|
// Subscribe first, then read: an event delivered before the listener is found either way.
|
|
1341
1757
|
unsubscribe = provider.subscribe(listener);
|
|
1342
1758
|
started = true;
|
|
@@ -1347,8 +1763,11 @@ export function createCommerceReconciler(input) {
|
|
|
1347
1763
|
initialScanPending = capabilitiesNow === undefined
|
|
1348
1764
|
|| (capabilitiesNow.lifecycle.events && !(await readAvailable(capabilitiesNow)));
|
|
1349
1765
|
await pass({ explicit: true, refreshCapabilities: false });
|
|
1766
|
+
return true;
|
|
1350
1767
|
});
|
|
1351
|
-
|
|
1768
|
+
if (subscribed) {
|
|
1769
|
+
await runUntilIdle();
|
|
1770
|
+
}
|
|
1352
1771
|
}
|
|
1353
1772
|
catch (error) {
|
|
1354
1773
|
// A failed start is retryable: nothing stays subscribed or started.
|
|
@@ -1410,6 +1829,13 @@ export function createCommerceReconciler(input) {
|
|
|
1410
1829
|
},
|
|
1411
1830
|
runUntilIdle,
|
|
1412
1831
|
async restorePurchases() {
|
|
1832
|
+
const refusal = sessionRefusal();
|
|
1833
|
+
if (refusal !== undefined) {
|
|
1834
|
+
// A store restore (StoreKit's sync, an account sign-in) would run for
|
|
1835
|
+
// the account current now, not this reconciler's player.
|
|
1836
|
+
fail('session', refusal);
|
|
1837
|
+
throw refusal;
|
|
1838
|
+
}
|
|
1413
1839
|
if (provider.restorePurchases !== undefined) {
|
|
1414
1840
|
try {
|
|
1415
1841
|
await provider.restorePurchases();
|
|
@@ -1442,6 +1868,12 @@ export function createCommerceReconciler(input) {
|
|
|
1442
1868
|
});
|
|
1443
1869
|
});
|
|
1444
1870
|
await runUntilIdle();
|
|
1871
|
+
const lateRefusal = sessionRefusal();
|
|
1872
|
+
if (lateRefusal !== undefined) {
|
|
1873
|
+
// The session changed during the restore: the passes stopped early,
|
|
1874
|
+
// so it did not finish for this player.
|
|
1875
|
+
throw lateRefusal;
|
|
1876
|
+
}
|
|
1445
1877
|
},
|
|
1446
1878
|
requestPurchase,
|
|
1447
1879
|
async fetchProducts(request = {}) {
|