@mpgd/game-services 0.31.0 → 0.33.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 +8 -3
- package/dist/another-account.d.ts +65 -0
- package/dist/another-account.js +107 -0
- package/dist/app-store-server-notifications.d.ts +15 -2
- package/dist/app-store-server-notifications.js +43 -9
- package/dist/app-store-verification-id.d.ts +25 -0
- package/dist/app-store-verification-id.js +32 -0
- package/dist/app-store-verifier.d.ts +99 -9
- package/dist/app-store-verifier.js +127 -32
- package/dist/client.js +8 -3
- package/dist/commerce-reconciler.d.ts +55 -1
- package/dist/commerce-reconciler.js +456 -25
- 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/runtime.d.ts +12 -0
- package/dist/runtime.js +23 -0
- package/dist/server.d.ts +16 -1
- package/dist/server.js +99 -12
- package/package.json +4 -4
|
@@ -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,65 @@ 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
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Backend refusals that answer for a revoked purchase: the App Store verifier
|
|
74
|
+
* read the refund (`APP_STORE_TRANSACTION_REVOKED`), or the ledger had
|
|
75
|
+
* already reversed the grant this request names (`LEDGER_GRANT_REVERSED`).
|
|
76
|
+
*/
|
|
77
|
+
export const commerceRevokedRejectionReasons = new Set([
|
|
78
|
+
'APP_STORE_TRANSACTION_REVOKED',
|
|
79
|
+
'LEDGER_GRANT_REVERSED',
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* A record the backend answered for a refund with no grant standing: its
|
|
83
|
+
* grant reversed by the ledger (`revoked`), or the purchase refused as
|
|
84
|
+
* revoked (`rejected` with a reason in `commerceRevokedRejectionReasons`). A
|
|
85
|
+
* store transaction of such a record that the store reports revoked may be
|
|
86
|
+
* finished without a grant (`FinishTransactionInput.revoked`). A refusal for
|
|
87
|
+
* any other reason keeps the transaction unfinished.
|
|
88
|
+
*/
|
|
89
|
+
function answeredWithoutGrant(record) {
|
|
90
|
+
if (record.state === 'revoked') {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const response = record.response;
|
|
94
|
+
return record.state === 'rejected'
|
|
95
|
+
&& response !== undefined
|
|
96
|
+
&& !response.verified
|
|
97
|
+
&& typeof response.reason === 'string'
|
|
98
|
+
&& commerceRevokedRejectionReasons.has(response.reason);
|
|
99
|
+
}
|
|
47
100
|
function isRetryableBeforeLaunch(record) {
|
|
48
101
|
return record.state === 'requested' && record.error?.retryable === true
|
|
49
102
|
&& record.purchase === undefined && record.request === undefined;
|
|
@@ -483,6 +536,27 @@ export function createCommerceReconciler(input) {
|
|
|
483
536
|
retryAt.delete(record.idempotencyKey);
|
|
484
537
|
return next;
|
|
485
538
|
};
|
|
539
|
+
/**
|
|
540
|
+
* Parks a `verifying` record the backend answered as another account's
|
|
541
|
+
* purchase, or (`marker` undefined) lets this player's Restore Purchases
|
|
542
|
+
* send it again. The state stays `verifying`: the record is not ended for
|
|
543
|
+
* the store transaction, which its account can still recover.
|
|
544
|
+
*/
|
|
545
|
+
const reviseAnotherAccount = async (record, marker, patch = {}) => {
|
|
546
|
+
const { anotherAccount: _previous, ...rest } = record;
|
|
547
|
+
const next = {
|
|
548
|
+
...rest,
|
|
549
|
+
...patch,
|
|
550
|
+
...(marker === undefined ? {} : { anotherAccount: marker }),
|
|
551
|
+
state: record.state,
|
|
552
|
+
revision: record.revision + 1,
|
|
553
|
+
updatedAt: now(),
|
|
554
|
+
};
|
|
555
|
+
await journal.replace(record.revision, next);
|
|
556
|
+
remember(next);
|
|
557
|
+
retryAt.delete(record.idempotencyKey);
|
|
558
|
+
return next;
|
|
559
|
+
};
|
|
486
560
|
/**
|
|
487
561
|
* Where this player's record of an idempotency key lives, and what that key
|
|
488
562
|
* holds now. A record is under the shared key, or under this player's
|
|
@@ -496,6 +570,11 @@ export function createCommerceReconciler(input) {
|
|
|
496
570
|
* the slot holds, and `recordIn` refuses it.
|
|
497
571
|
* - `probeFree`: when the shared key is free, a record under the scoped key
|
|
498
572
|
* still answers (retention may have dropped the other player's record).
|
|
573
|
+
*
|
|
574
|
+
* Another player's record that was answered as another account's purchase
|
|
575
|
+
* (`releasesSharedKey`) is never this player's to refuse: the scoped key is
|
|
576
|
+
* the slot for any kind, as with `takeHeld`, so the purchase's own account
|
|
577
|
+
* recovers it on the same install.
|
|
499
578
|
*/
|
|
500
579
|
const resolveSlot = async (idempotencyKey, options = {}) => {
|
|
501
580
|
const sharedKey = createCommercePurchaseOperationKey(target, idempotencyKey);
|
|
@@ -503,6 +582,7 @@ export function createCommerceReconciler(input) {
|
|
|
503
582
|
const heldByAnother = shared !== undefined && heldByAnotherPlayer(shared);
|
|
504
583
|
const probeScoped = heldByAnother
|
|
505
584
|
? options.takeHeld === true
|
|
585
|
+
|| (isCommercePurchaseOperationRecord(shared) && releasesSharedKey(shared))
|
|
506
586
|
: shared === undefined && options.probeFree === true;
|
|
507
587
|
if (!probeScoped) {
|
|
508
588
|
return { key: sharedKey, found: shared, heldByAnother };
|
|
@@ -532,9 +612,15 @@ export function createCommerceReconciler(input) {
|
|
|
532
612
|
*/
|
|
533
613
|
const readRecord = async (idempotencyKey) => recordIn(await resolveSlot(idempotencyKey), idempotencyKey);
|
|
534
614
|
const ownedRecordsOf = (listed) => listed.filter((record) => isCommercePurchaseOperationRecord(record) && ownsRecord(record));
|
|
615
|
+
/**
|
|
616
|
+
* The player's open records. One parked as another account's purchase is
|
|
617
|
+
* kept in memory, so a store report matches it without the journal's
|
|
618
|
+
* transaction index, but is not the player's to recover.
|
|
619
|
+
*/
|
|
535
620
|
const listRecoverable = async () => ownedRecordsOf(await journal.listRecoverable(playerId))
|
|
536
621
|
.filter((record) => !isTerminalCommercePurchaseState(record.state))
|
|
537
|
-
.map(remember)
|
|
622
|
+
.map(remember)
|
|
623
|
+
.filter((record) => !isParkedForAnotherAccount(record));
|
|
538
624
|
const listGranted = async () => {
|
|
539
625
|
const listed = journal.listGranted === undefined
|
|
540
626
|
? []
|
|
@@ -717,6 +803,252 @@ export function createCommerceReconciler(input) {
|
|
|
717
803
|
fail('finish', error, record.idempotencyKey);
|
|
718
804
|
}
|
|
719
805
|
};
|
|
806
|
+
/**
|
|
807
|
+
* Consumables a `client`-finish store reported revoked this session (the
|
|
808
|
+
* App Store refunded them while they were unfinished, so StoreKit keeps
|
|
809
|
+
* them unfinished and redelivers them until `finish()`), by transaction
|
|
810
|
+
* id, with their record's idempotency key, or `undefined` for one this
|
|
811
|
+
* player holds no record of. `finishRevokedReported` finishes one, without
|
|
812
|
+
* any grant, once its record holds the backend's answer (`answeredWithoutGrant`),
|
|
813
|
+
* or at once without a record. Until then it waits: a grant the ledger
|
|
814
|
+
* still holds keeps the transaction unfinished, as before.
|
|
815
|
+
*/
|
|
816
|
+
const revokedReports = new Map();
|
|
817
|
+
/** Transactions this session already asked the store to finish as revoked. */
|
|
818
|
+
const revokedFinishAsked = new Set();
|
|
819
|
+
/**
|
|
820
|
+
* Notes a store report of a revoked purchase whose record this player
|
|
821
|
+
* holds. Only a `client`-finish consumable on a provider that declares
|
|
822
|
+
* `acceptsRevokedFinish` is noted: any other store, kind, or provider keeps
|
|
823
|
+
* the revoked report as a no-op, and only the ledger moves a record to
|
|
824
|
+
* `revoked` (step 5).
|
|
825
|
+
*/
|
|
826
|
+
const revokedFinishApplies = (kind, transactionId) => kind === 'consumable'
|
|
827
|
+
&& capabilities?.lifecycle.finish === 'client'
|
|
828
|
+
&& provider.finishTransaction !== undefined
|
|
829
|
+
&& provider.acceptsRevokedFinish === true
|
|
830
|
+
&& !revokedFinishAsked.has(transactionId);
|
|
831
|
+
const noteRevoked = (record, purchase, kind) => {
|
|
832
|
+
if (record.purchase === undefined
|
|
833
|
+
|| !samePurchase(record.purchase, purchase)
|
|
834
|
+
// A `finished` record's transaction was finished: the report is the
|
|
835
|
+
// store's refund notice of a finished consumable (`Transaction.updates`),
|
|
836
|
+
// with nothing left unfinished. Step 5 revokes the record if the
|
|
837
|
+
// ledger reversed it; a later report of a still-unfinished one is
|
|
838
|
+
// noted then.
|
|
839
|
+
|| record.state === 'finished'
|
|
840
|
+
|| !revokedFinishApplies(record.productKind ?? kind, record.purchase.transactionId)) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
revokedReports.set(record.purchase.transactionId, record.idempotencyKey);
|
|
844
|
+
};
|
|
845
|
+
/**
|
|
846
|
+
* Whether a store purchase is bound to this player's own account: the
|
|
847
|
+
* purchase carries a binding, this player's binding reads, and the two are
|
|
848
|
+
* the same kind and value. Stricter than `accountOf`, which calls an
|
|
849
|
+
* unbound purchase or an unread binding this player's.
|
|
850
|
+
*/
|
|
851
|
+
const boundToThisPlayer = async (purchase) => {
|
|
852
|
+
const bound = purchase.accountBinding;
|
|
853
|
+
if (bound?.value === undefined || input.accountBinding === undefined) {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
let own;
|
|
857
|
+
try {
|
|
858
|
+
own = await input.accountBinding();
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
if (own?.value === undefined || own.kind !== bound.kind) {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
return bound.kind === 'app-account-token'
|
|
867
|
+
? own.value.toLowerCase() === bound.value.toLowerCase()
|
|
868
|
+
: own.value === bound.value;
|
|
869
|
+
};
|
|
870
|
+
/**
|
|
871
|
+
* A revoked purchase this player holds no record of (the journal dropped
|
|
872
|
+
* it, it was lost, or the store answer was never journaled). A refunded
|
|
873
|
+
* consumable gives nobody anything and the backend learns of the refund
|
|
874
|
+
* from the store (App Store Server Notifications), so it is finished
|
|
875
|
+
* without a verify, never journaled or granted: verifying it could grant
|
|
876
|
+
* from a signed transaction saved before the refund. Only a consumable
|
|
877
|
+
* bound to this player's own account, on a provider that finishes revoked
|
|
878
|
+
* transactions; a record found under its derived key (a journal without the
|
|
879
|
+
* transaction index) takes the record's path instead.
|
|
880
|
+
*/
|
|
881
|
+
const noteUnrecordedRevoked = async (purchase, kind) => {
|
|
882
|
+
if (!revokedFinishApplies(kind, purchase.transactionId)
|
|
883
|
+
// Already noted this session (the store redelivers it): no second
|
|
884
|
+
// derived-key hash and journal read.
|
|
885
|
+
|| revokedReports.has(purchase.transactionId)) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const idempotencyKey = await createDerivedPurchaseIdempotencyKey({
|
|
889
|
+
store: purchase.store,
|
|
890
|
+
transactionId: purchase.transactionId,
|
|
891
|
+
token: purchase.token,
|
|
892
|
+
});
|
|
893
|
+
let found;
|
|
894
|
+
try {
|
|
895
|
+
found = await readRecord(idempotencyKey);
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
// Another player's record holds the key: not this player's to finish.
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (found !== undefined) {
|
|
902
|
+
if (found.purchase !== undefined && samePurchase(found.purchase, purchase)) {
|
|
903
|
+
noteRevoked(found, purchase, kind);
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (!(await boundToThisPlayer(purchase))) {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
revokedReports.set(purchase.transactionId, undefined);
|
|
911
|
+
};
|
|
912
|
+
/**
|
|
913
|
+
* Finishes the revoked transactions `noteRevoked` kept, once their record
|
|
914
|
+
* holds the backend's answer, with `revoked: true` and the answer as the
|
|
915
|
+
* ledger entry id (the reversed grant's entry, or the refused key). Nothing
|
|
916
|
+
* is granted and the record is not rewritten: it is already terminal. The
|
|
917
|
+
* store finishes only a transaction it still lists as revoked. Asked once
|
|
918
|
+
* per session; a transport failure is asked again on a later pass (after
|
|
919
|
+
* the retry interval, unless the pass is explicit), and a refusal is
|
|
920
|
+
* reported as a `finish` failure (for StoreKit: a native build from before
|
|
921
|
+
* the revoked finish; the next launch asks again).
|
|
922
|
+
*/
|
|
923
|
+
const finishRevokedReported = async (explicit) => {
|
|
924
|
+
const finishTransaction = provider.finishTransaction;
|
|
925
|
+
for (const [transactionId, idempotencyKey] of [...revokedReports]) {
|
|
926
|
+
const record = idempotencyKey === undefined ? undefined : records.get(idempotencyKey);
|
|
927
|
+
if (idempotencyKey !== undefined) {
|
|
928
|
+
if (record === undefined || record.purchase?.transactionId !== transactionId) {
|
|
929
|
+
revokedReports.delete(transactionId);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (!answeredWithoutGrant(record)) {
|
|
933
|
+
if (isTerminalCommercePurchaseState(record.state) && record.state !== 'finished') {
|
|
934
|
+
// Refused for another reason (or ended otherwise): it stays unfinished.
|
|
935
|
+
revokedReports.delete(transactionId);
|
|
936
|
+
}
|
|
937
|
+
// Otherwise still verifying, granted, finishing, or finished: the
|
|
938
|
+
// backend has not answered for the refund yet (step 3 or 5 will).
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const retryKey = idempotencyKey ?? transactionId;
|
|
943
|
+
const waitUntil = retryAt.get(retryKey);
|
|
944
|
+
if (!explicit && waitUntil !== undefined && nowMs() < waitUntil) {
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (finishTransaction === undefined || sessionBlocked(idempotencyKey)) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
revokedReports.delete(transactionId);
|
|
951
|
+
revokedFinishAsked.add(transactionId);
|
|
952
|
+
try {
|
|
953
|
+
const { finished } = await finishTransaction.call(provider, {
|
|
954
|
+
transactionId,
|
|
955
|
+
...(record?.ledgerEntryId === undefined ? {} : { ledgerEntryId: record.ledgerEntryId }),
|
|
956
|
+
isConsumable: true,
|
|
957
|
+
revoked: true,
|
|
958
|
+
});
|
|
959
|
+
emit({
|
|
960
|
+
type: 'revoked-finish',
|
|
961
|
+
transactionId,
|
|
962
|
+
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
|
963
|
+
finished,
|
|
964
|
+
});
|
|
965
|
+
if (!finished) {
|
|
966
|
+
// Not counted as asked: the store could not finish it now (a native
|
|
967
|
+
// build from before the revoked finish, or a list that does not
|
|
968
|
+
// carry it yet). The next report of the transaction asks again.
|
|
969
|
+
revokedFinishAsked.delete(transactionId);
|
|
970
|
+
fail('finish', new Error(`The store did not finish the revoked transaction ${transactionId}.`), idempotencyKey);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
// Transport-class failure: a later pass asks again.
|
|
975
|
+
revokedFinishAsked.delete(transactionId);
|
|
976
|
+
revokedReports.set(transactionId, idempotencyKey);
|
|
977
|
+
retryLater(retryKey);
|
|
978
|
+
fail('finish', error, idempotencyKey);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
/**
|
|
983
|
+
* A store report of a record the backend answered as another account's
|
|
984
|
+
* purchase. Only this player's Restore Purchases asks again (a restore
|
|
985
|
+
* sharing policy or a binding may have changed since), with the evidence
|
|
986
|
+
* the store reports now, so a stale saved request (an expired signed
|
|
987
|
+
* transaction) is not resent, and as a restore, which the player asked
|
|
988
|
+
* for (restore sharing may cover a durable the game started, too); other
|
|
989
|
+
* reports leave it parked. It stays parked until the backend answers
|
|
990
|
+
* something else: a transport failure (an offline Restore) changes
|
|
991
|
+
* nothing, so the record never becomes an open attempt that serializes the
|
|
992
|
+
* player's purchases or holds the shared key against the purchase's
|
|
993
|
+
* account. Another answer unparks it and settles it as a verify does; a
|
|
994
|
+
* grant is finished by the pass's recoverable records.
|
|
995
|
+
*
|
|
996
|
+
* Returns false when the journal dropped the record before Restore could
|
|
997
|
+
* rewrite it (another context's save applied the journal's retention after
|
|
998
|
+
* `rereadParked` read it): the record is forgotten and the caller matches
|
|
999
|
+
* the report as a purchase the journal does not know.
|
|
1000
|
+
*/
|
|
1001
|
+
const reportParked = async (record, purchase, restoring) => {
|
|
1002
|
+
if (!restoring || purchase.state !== 'purchased') {
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
const request = {
|
|
1006
|
+
...buildRequest({ ...record, purchase }),
|
|
1007
|
+
intent: 'restore',
|
|
1008
|
+
};
|
|
1009
|
+
let refreshed;
|
|
1010
|
+
try {
|
|
1011
|
+
refreshed = await reviseAnotherAccount(record, record.anotherAccount, {
|
|
1012
|
+
purchase,
|
|
1013
|
+
request,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
if (await rereadParked(record) === undefined) {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
const answer = await sendVerify(refreshed, request);
|
|
1023
|
+
if (answer === undefined) {
|
|
1024
|
+
return true;
|
|
1025
|
+
}
|
|
1026
|
+
if (answer.kind === 'another-account') {
|
|
1027
|
+
await parkForAnotherAccount(refreshed, request, answer);
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
await settleVerify(await reviseAnotherAccount(refreshed, undefined), request, answer.response);
|
|
1031
|
+
return true;
|
|
1032
|
+
};
|
|
1033
|
+
/**
|
|
1034
|
+
* The journal's current record of a parked record this session holds, or
|
|
1035
|
+
* undefined (and forgotten) when the journal no longer holds it for this
|
|
1036
|
+
* player: dropped, or the key reserved again for another purchase of the
|
|
1037
|
+
* player, which this purchase's report must never be written onto.
|
|
1038
|
+
*/
|
|
1039
|
+
const rereadParked = async (record) => {
|
|
1040
|
+
const current = await journal.read(record.key);
|
|
1041
|
+
if (current !== undefined
|
|
1042
|
+
&& isCommercePurchaseOperationRecord(current)
|
|
1043
|
+
&& ownsRecord(current)
|
|
1044
|
+
&& current.purchase !== undefined
|
|
1045
|
+
&& record.purchase !== undefined
|
|
1046
|
+
&& samePurchase(current.purchase, record.purchase)) {
|
|
1047
|
+
return remember(current);
|
|
1048
|
+
}
|
|
1049
|
+
records.delete(record.idempotencyKey);
|
|
1050
|
+
return undefined;
|
|
1051
|
+
};
|
|
720
1052
|
// Reconciler step 2: match a reported purchase to a journal record.
|
|
721
1053
|
const ingestPurchase = async (purchase, restoring) => {
|
|
722
1054
|
if (purchase.store !== storeId) {
|
|
@@ -746,6 +1078,38 @@ export function createCommerceReconciler(input) {
|
|
|
746
1078
|
record = remember(stored);
|
|
747
1079
|
}
|
|
748
1080
|
}
|
|
1081
|
+
if (record !== undefined && isParkedForAnotherAccount(record) && restoring) {
|
|
1082
|
+
// Restore Purchases writes the record, so it must still be the journal's:
|
|
1083
|
+
// a journal's retention may have dropped a parked record this session
|
|
1084
|
+
// still holds (RFC "Another account's purchase"). Then the report is a
|
|
1085
|
+
// purchase the journal does not know, and is matched as one.
|
|
1086
|
+
record = await rereadParked(record);
|
|
1087
|
+
}
|
|
1088
|
+
if (record !== undefined && isParkedForAnotherAccount(record)) {
|
|
1089
|
+
if (await reportParked(record, purchase, restoring)) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
record = undefined;
|
|
1093
|
+
}
|
|
1094
|
+
if (purchase.state === 'revoked') {
|
|
1095
|
+
// A revoked store state never revokes or verifies anything on its own:
|
|
1096
|
+
// only the ledger revokes (step 5). It is matched only to this
|
|
1097
|
+
// player's record of the same transaction, for a finish without a
|
|
1098
|
+
// grant once the backend has answered for it. It is never attached to
|
|
1099
|
+
// the player's open (`launched`) attempt for the product: the store
|
|
1100
|
+
// reported no purchase for that attempt, and the open attempt may be
|
|
1101
|
+
// another purchase still in flight (an Ask to Buy waiting for approval),
|
|
1102
|
+
// which this refund must not end. Finishing the refunded transaction
|
|
1103
|
+
// as unrecorded grants nothing, and the attempt keeps waiting for its
|
|
1104
|
+
// own purchase report or its expiry, as before.
|
|
1105
|
+
if (record !== undefined) {
|
|
1106
|
+
noteRevoked(record, purchase, product.kind);
|
|
1107
|
+
}
|
|
1108
|
+
else {
|
|
1109
|
+
await noteUnrecordedRevoked(purchase, product.kind);
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
749
1113
|
const account = record !== undefined || (restoring && product.kind === 'durable')
|
|
750
1114
|
? 'own'
|
|
751
1115
|
: await accountOf(purchase);
|
|
@@ -776,10 +1140,6 @@ export function createCommerceReconciler(input) {
|
|
|
776
1140
|
}
|
|
777
1141
|
}
|
|
778
1142
|
if (record === undefined) {
|
|
779
|
-
if (purchase.state === 'revoked') {
|
|
780
|
-
// Revoked store states never revoke on their own: only the ledger does (step 5).
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
1143
|
const idempotencyKey = await createDerivedPurchaseIdempotencyKey({
|
|
784
1144
|
store: purchase.store,
|
|
785
1145
|
transactionId: purchase.transactionId,
|
|
@@ -800,6 +1160,16 @@ export function createCommerceReconciler(input) {
|
|
|
800
1160
|
probeFree: durable,
|
|
801
1161
|
});
|
|
802
1162
|
record = recordIn(slot, idempotencyKey);
|
|
1163
|
+
if (record !== undefined
|
|
1164
|
+
&& isParkedForAnotherAccount(record)
|
|
1165
|
+
&& record.logicalId === product.logicalId
|
|
1166
|
+
&& record.purchase !== undefined
|
|
1167
|
+
&& samePurchase(record.purchase, purchase)) {
|
|
1168
|
+
// Found under its derived key only (a journal without the
|
|
1169
|
+
// transaction index): the same rule as a transaction match.
|
|
1170
|
+
await reportParked(record, purchase, restoring);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
803
1173
|
if (record !== undefined
|
|
804
1174
|
&& (record.logicalId !== product.logicalId
|
|
805
1175
|
|| (record.purchase !== undefined && !samePurchase(record.purchase, purchase)))) {
|
|
@@ -863,18 +1233,71 @@ export function createCommerceReconciler(input) {
|
|
|
863
1233
|
retryable: false,
|
|
864
1234
|
message,
|
|
865
1235
|
});
|
|
1236
|
+
/**
|
|
1237
|
+
* The backend's answer: a response, another account's purchase (the
|
|
1238
|
+
* `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error, or a `rejected` receipt with a
|
|
1239
|
+
* store binding mismatch reason from a backend before it), or undefined
|
|
1240
|
+
* for a transport failure.
|
|
1241
|
+
*/
|
|
866
1242
|
const sendVerify = async (record, request) => {
|
|
867
1243
|
emit({ type: 'verify-requested', record });
|
|
1244
|
+
let response;
|
|
868
1245
|
try {
|
|
869
|
-
|
|
870
|
-
emit({ type: 'verify-result', record, response });
|
|
871
|
-
return response;
|
|
1246
|
+
response = await backend.verifyPurchase(request);
|
|
872
1247
|
}
|
|
873
1248
|
catch (error) {
|
|
1249
|
+
const anotherAccount = readPurchaseBoundToAnotherAccount(error);
|
|
1250
|
+
if (anotherAccount !== undefined) {
|
|
1251
|
+
// An answer, not a failure: the caller parks the record and reports it.
|
|
1252
|
+
return { kind: 'another-account', reason: anotherAccount.reason };
|
|
1253
|
+
}
|
|
874
1254
|
// Transport failure: the state is kept and the next pass resends the saved request.
|
|
875
1255
|
fail('verify', error, record.idempotencyKey);
|
|
876
1256
|
return undefined;
|
|
877
1257
|
}
|
|
1258
|
+
emit({ type: 'verify-result', record, response });
|
|
1259
|
+
if (!response.verified
|
|
1260
|
+
&& response.disposition !== 'pending'
|
|
1261
|
+
&& isStoreAccountMismatchReason(response.reason)) {
|
|
1262
|
+
return { kind: 'another-account', reason: response.reason, response };
|
|
1263
|
+
}
|
|
1264
|
+
return { kind: 'response', response };
|
|
1265
|
+
};
|
|
1266
|
+
/**
|
|
1267
|
+
* Parks a `verifying` record the backend answered as another account's
|
|
1268
|
+
* purchase and reports it with an `another-account` event (not a failure).
|
|
1269
|
+
*/
|
|
1270
|
+
const parkForAnotherAccount = async (record, request, answer) => {
|
|
1271
|
+
const parked = await reviseAnotherAccount(record, { reason: answer.reason, reportedAt: now() }, { request, ...(answer.response === undefined ? {} : { response: answer.response }) });
|
|
1272
|
+
emit({
|
|
1273
|
+
type: 'another-account',
|
|
1274
|
+
record: parked,
|
|
1275
|
+
reason: answer.reason,
|
|
1276
|
+
...(answer.response === undefined ? {} : { response: answer.response }),
|
|
1277
|
+
});
|
|
1278
|
+
};
|
|
1279
|
+
/**
|
|
1280
|
+
* Journals the backend's answer to a `verifying` record: `pending` or
|
|
1281
|
+
* `rejected`, or `granted` (returned) for a grant that carries its ledger
|
|
1282
|
+
* entry; a malformed grant is reported and the record kept.
|
|
1283
|
+
*/
|
|
1284
|
+
const settleVerify = async (record, request, response) => {
|
|
1285
|
+
if (!response.verified) {
|
|
1286
|
+
if (response.disposition === 'pending') {
|
|
1287
|
+
await write(record, 'pending', { request, response });
|
|
1288
|
+
retryLater(record.idempotencyKey);
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
await write(record, 'rejected', { request, response });
|
|
1292
|
+
}
|
|
1293
|
+
return undefined;
|
|
1294
|
+
}
|
|
1295
|
+
const ledgerEntryId = response.ledgerEntryId;
|
|
1296
|
+
if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
|
|
1297
|
+
fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
|
|
1298
|
+
return undefined;
|
|
1299
|
+
}
|
|
1300
|
+
return write(record, 'granted', { request, response, ledgerEntryId });
|
|
878
1301
|
};
|
|
879
1302
|
// Reconciler steps 3 and 4: drive one record as far as the backend and the store allow.
|
|
880
1303
|
const drive = async (initial, capabilitiesNow, explicit) => {
|
|
@@ -936,29 +1359,30 @@ export function createCommerceReconciler(input) {
|
|
|
936
1359
|
record = await write(record, 'verifying');
|
|
937
1360
|
continue;
|
|
938
1361
|
case 'verifying': {
|
|
1362
|
+
if (isParkedForAnotherAccount(record)) {
|
|
1363
|
+
// Another account's purchase: only this player's Restore
|
|
1364
|
+
// Purchases sends it again (`ingestPurchase`).
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
939
1367
|
const request = record.request ?? buildRequest(record);
|
|
940
|
-
const
|
|
941
|
-
if (
|
|
1368
|
+
const answer = await sendVerify(record, request);
|
|
1369
|
+
if (answer === undefined) {
|
|
942
1370
|
retryLater(record.idempotencyKey);
|
|
943
1371
|
return;
|
|
944
1372
|
}
|
|
945
|
-
if (
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
else {
|
|
951
|
-
await write(record, 'rejected', { request, response });
|
|
952
|
-
}
|
|
1373
|
+
if (answer.kind === 'another-account') {
|
|
1374
|
+
// Not this player's, and not ended for the store transaction:
|
|
1375
|
+
// never sent again on its own, and the shared key is left to
|
|
1376
|
+
// the purchase's account (RFC "Another account's purchase").
|
|
1377
|
+
await parkForAnotherAccount(record, request, answer);
|
|
953
1378
|
return;
|
|
954
1379
|
}
|
|
955
|
-
const
|
|
956
|
-
if (
|
|
957
|
-
fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
|
|
1380
|
+
const granted = await settleVerify(record, request, answer.response);
|
|
1381
|
+
if (granted === undefined) {
|
|
958
1382
|
return;
|
|
959
1383
|
}
|
|
960
|
-
finalization = response.finalization?.status;
|
|
961
|
-
record =
|
|
1384
|
+
finalization = answer.response.finalization?.status;
|
|
1385
|
+
record = granted;
|
|
962
1386
|
continue;
|
|
963
1387
|
}
|
|
964
1388
|
case 'granted':
|
|
@@ -985,7 +1409,8 @@ export function createCommerceReconciler(input) {
|
|
|
985
1409
|
if (finalization === undefined) {
|
|
986
1410
|
// A pass that did not grant resends the saved request to read
|
|
987
1411
|
// finalization; the backend replays the grant, never a second one.
|
|
988
|
-
|
|
1412
|
+
const answer = await sendVerify(record, record.request ?? buildRequest(record));
|
|
1413
|
+
reread = answer?.kind === 'response' ? answer.response : undefined;
|
|
989
1414
|
finalization = reread?.verified === true
|
|
990
1415
|
? reread.finalization?.status
|
|
991
1416
|
: undefined;
|
|
@@ -1172,6 +1597,10 @@ export function createCommerceReconciler(input) {
|
|
|
1172
1597
|
return;
|
|
1173
1598
|
}
|
|
1174
1599
|
await revokeReversed();
|
|
1600
|
+
if (sessionBlocked()) {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
await finishRevokedReported(options.explicit);
|
|
1175
1604
|
};
|
|
1176
1605
|
const runUntilIdle = async () => {
|
|
1177
1606
|
if (running !== undefined) {
|
|
@@ -1574,6 +2003,7 @@ export function createCommerceReconciler(input) {
|
|
|
1574
2003
|
deferred.length = 0;
|
|
1575
2004
|
records.clear();
|
|
1576
2005
|
retryAt.clear();
|
|
2006
|
+
revokedReports.clear();
|
|
1577
2007
|
throw error;
|
|
1578
2008
|
}
|
|
1579
2009
|
finally {
|
|
@@ -1611,6 +2041,7 @@ export function createCommerceReconciler(input) {
|
|
|
1611
2041
|
deferred.length = 0;
|
|
1612
2042
|
records.clear();
|
|
1613
2043
|
retryAt.clear();
|
|
2044
|
+
revokedReports.clear();
|
|
1614
2045
|
},
|
|
1615
2046
|
async reconcile() {
|
|
1616
2047
|
await withLock(async () => {
|
|
@@ -34,8 +34,9 @@ export type EvidenceVerificationDecision = {
|
|
|
34
34
|
* check passed. The reference verifiers answer it only for a request
|
|
35
35
|
* with `intent: 'restore'` of a non-consumable; the backend then grants
|
|
36
36
|
* the player its own share under its `restoreSharing` policy, or
|
|
37
|
-
* answers `
|
|
38
|
-
* policy refuses
|
|
37
|
+
* answers `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` with `reason` (the
|
|
38
|
+
* binding mismatch) when the policy refuses, as for a `rejected`
|
|
39
|
+
* decision with `anotherAccount`. Fields are those of `verified`.
|
|
39
40
|
*/
|
|
40
41
|
readonly status: 'restorable';
|
|
41
42
|
readonly reason: string;
|
|
@@ -49,6 +50,17 @@ export type EvidenceVerificationDecision = {
|
|
|
49
50
|
} | {
|
|
50
51
|
readonly status: 'rejected';
|
|
51
52
|
readonly reason: string;
|
|
53
|
+
/**
|
|
54
|
+
* The store binds the purchase to another account than the requesting
|
|
55
|
+
* player's (`reason` is the binding mismatch, for example
|
|
56
|
+
* `GOOGLE_PLAY_ACCOUNT_MISMATCH`), and every other check passed: the
|
|
57
|
+
* purchase is valid, only not this player's (RFC "Another account's
|
|
58
|
+
* purchase"). The backend grants nothing, records nothing, and answers
|
|
59
|
+
* the retryable `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error instead of a
|
|
60
|
+
* `rejected` receipt, so the purchase stays recoverable by its account.
|
|
61
|
+
* A caller that reads only `status` still refuses it.
|
|
62
|
+
*/
|
|
63
|
+
readonly anotherAccount?: true;
|
|
52
64
|
};
|
|
53
65
|
export interface VerifyPurchaseEvidenceInput {
|
|
54
66
|
readonly request: VerifyPurchaseRequest;
|