@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.
@@ -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
- /** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
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
- const response = await backend.verifyPurchase(request);
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 response = await sendVerify(record, request);
809
- if (response === undefined) {
1149
+ const answer = await sendVerify(record, request);
1150
+ if (answer === undefined) {
810
1151
  retryLater(record.idempotencyKey);
811
1152
  return;
812
1153
  }
813
- if (!response.verified) {
814
- if (response.disposition === 'pending') {
815
- await write(record, 'pending', { request, response });
816
- retryLater(record.idempotencyKey);
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 ledgerEntryId = response.ledgerEntryId;
824
- if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
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 = await write(record, 'granted', { request, response, ledgerEntryId });
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
- reread = await sendVerify(record, record.request ?? buildRequest(record));
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
- for (const event of [
982
- ...deferred.splice(0, deferred.length),
983
- ...inbox.splice(0, inbox.length),
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
- try {
1285
- answer = await provider.requestPurchase(request);
1286
- }
1287
- catch (error) {
1288
- answer = failedOutcome(error);
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
- await runUntilIdle();
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 = {}) {