@mpgd/game-services 0.27.0 → 0.27.1

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.
@@ -107,7 +107,12 @@ export interface MicrosoftStoreRecoveryOwnershipRecord {
107
107
  readonly storeId: string;
108
108
  readonly playerId: string;
109
109
  readonly generation: string;
110
- /** Stable identity derived from the exact Collections item being granted. */
110
+ /**
111
+ * Identity of one Collections purchase. `transactionId` is preferred. When it
112
+ * is absent, the id is the collection item id plus `modifiedDate`, so a later
113
+ * purchase of the same item is distinct after the previous claim is released.
114
+ * An unreleased claim keeps the id it was claimed with.
115
+ */
111
116
  readonly providerPurchaseId: string;
112
117
  }
113
118
  export type MicrosoftStoreRecoveryOwnershipLookup = Omit<MicrosoftStoreRecoveryOwnershipRecord, 'generation' | 'providerPurchaseId'> & {
@@ -125,10 +130,9 @@ export type MicrosoftStoreRecoveryOwnershipResult = {
125
130
  export interface MicrosoftStoreRecoveryOwnershipStore {
126
131
  /**
127
132
  * Atomically claims an unconsumed Store product for one authenticated game player. An
128
- * existing claim for the same provider purchase remains unchanged so a retry cannot replace
129
- * its generation. A different provider purchase may atomically replace the same player's
130
- * consumed purchase claim. Return the effective record, or undefined when another player owns
131
- * the Store product binding.
133
+ * existing unreleased claim remains unchanged: a retry cannot replace its generation, and a
134
+ * different provider purchase cannot replace it until `release` runs after consume. Return
135
+ * the effective record, or undefined when another player owns the Store product binding.
132
136
  */
133
137
  claim(input: MicrosoftStoreRecoveryOwnershipRecord): Promise<MicrosoftStoreRecoveryOwnershipRecord | undefined>;
134
138
  get(input: MicrosoftStoreRecoveryOwnershipLookup): Promise<MicrosoftStoreRecoveryOwnershipRecord | undefined>;
@@ -11,11 +11,13 @@ export class InMemoryMicrosoftStoreRecoveryOwnershipStore {
11
11
  if (owner !== undefined && owner.playerId !== input.playerId) {
12
12
  return undefined;
13
13
  }
14
- if (owner === undefined || owner.providerPurchaseId !== input.providerPurchaseId) {
15
- this.owners.set(key, Object.freeze({ ...input }));
16
- return Object.freeze({ ...input });
14
+ // A later Collections query can change modifiedDate or item id while quantity is still 1.
15
+ // Replacing that unreleased claim would mint a second ledger identity for the same unit.
16
+ if (owner !== undefined) {
17
+ return Object.freeze({ ...owner });
17
18
  }
18
- return Object.freeze({ ...owner });
19
+ this.owners.set(key, Object.freeze({ ...input }));
20
+ return Object.freeze({ ...input });
19
21
  }
20
22
  async get(input) {
21
23
  const owner = this.owners.get(createRecoveryOwnershipKey(input));
@@ -174,7 +176,10 @@ export function createMicrosoftStorePurchaseBoundary(input) {
174
176
  playerId,
175
177
  },
176
178
  ...(item.status === 'verified'
177
- ? { providerPurchaseId: createVerificationId(item.item) }
179
+ ? {
180
+ providerPurchaseId: createVerificationId(item.item),
181
+ item: item.item,
182
+ }
178
183
  : {}),
179
184
  ...(generation === undefined ? {} : { requestedGeneration: generation }),
180
185
  };
@@ -188,10 +193,21 @@ export function createMicrosoftStorePurchaseBoundary(input) {
188
193
  if (ownership.requestedGeneration === undefined) {
189
194
  return { status: 'denied' };
190
195
  }
191
- if (ownership.providerPurchaseId === undefined) {
196
+ if (ownership.providerPurchaseId === undefined || ownership.item === undefined) {
192
197
  return { status: 'unavailable' };
193
198
  }
194
199
  try {
200
+ const existing = await input.recoveryOwnershipStore.get(ownership.record);
201
+ if (existing !== undefined) {
202
+ if (existing.playerId !== ownership.record.playerId
203
+ || !sameUnconsumedPurchase(existing.providerPurchaseId, ownership.item)
204
+ || existing.generation !== ownership.requestedGeneration) {
205
+ return { status: 'denied' };
206
+ }
207
+ // Keep the stored purchase id. Replacing it with a stable id would
208
+ // make a later verify miss the ledger row already keyed by it.
209
+ return { status: 'granted', idempotencyKey: existing.generation };
210
+ }
195
211
  const effective = await input.recoveryOwnershipStore.claim({
196
212
  ...ownership.record,
197
213
  generation: ownership.requestedGeneration,
@@ -220,7 +236,7 @@ export function createMicrosoftStorePurchaseBoundary(input) {
220
236
  if (existing.playerId !== ownership.record.playerId) {
221
237
  return { status: 'denied' };
222
238
  }
223
- if (ownership.providerPurchaseId === undefined) {
239
+ if (ownership.providerPurchaseId === undefined || ownership.item === undefined) {
224
240
  // Collections stops listing an item after consume. Preserve only an exact durable
225
241
  // generation so its already-granted finalizer can retry a failed conditional release;
226
242
  // a generationless listing still cannot establish player ownership.
@@ -231,23 +247,17 @@ export function createMicrosoftStorePurchaseBoundary(input) {
231
247
  ? { status: 'granted', idempotencyKey: existing.generation }
232
248
  : { status: 'denied' };
233
249
  }
234
- if (existing.providerPurchaseId === ownership.providerPurchaseId) {
235
- return (ownership.requestedGeneration === undefined
236
- || existing.generation === ownership.requestedGeneration)
250
+ if (!sameUnconsumedPurchase(existing.providerPurchaseId, ownership.item)) {
251
+ // A changed item or transaction id is not a new unit while this claim is
252
+ // unreleased. Do not mint a generation. The exact generation may resume so
253
+ // its finalizer can consume and release; every other query stays denied.
254
+ return ownership.requestedGeneration === existing.generation
237
255
  ? { status: 'granted', idempotencyKey: existing.generation }
238
256
  : { status: 'denied' };
239
257
  }
240
- if (ownership.requestedGeneration !== undefined) {
241
- return { status: 'denied' };
242
- }
243
- const effective = await input.recoveryOwnershipStore.claim({
244
- ...ownership.record,
245
- generation: await createRecoveryGeneration(ownership.providerPurchaseId),
246
- providerPurchaseId: ownership.providerPurchaseId,
247
- });
248
- return effective?.playerId === ownership.record.playerId
249
- && effective.providerPurchaseId === ownership.providerPurchaseId
250
- ? { status: 'granted', idempotencyKey: effective.generation }
258
+ return (ownership.requestedGeneration === undefined
259
+ || existing.generation === ownership.requestedGeneration)
260
+ ? { status: 'granted', idempotencyKey: existing.generation }
251
261
  : { status: 'denied' };
252
262
  }
253
263
  catch {
@@ -347,27 +357,49 @@ export function createMicrosoftStorePurchaseBoundary(input) {
347
357
  if (!validIsoDate(verifiedAt)) {
348
358
  return pending('MICROSOFT_STORE_VERIFIER_CLOCK_INVALID');
349
359
  }
350
- const verificationId = createVerificationId(item.item);
351
- if (recoveryOwnership.providerPurchaseId !== verificationId) {
360
+ const verificationId = recoveryOwnership.providerPurchaseId;
361
+ const currentItemMatches = sameUnconsumedPurchase(verificationId, item.item);
362
+ const storedIdentity = currentItemMatches
363
+ ? undefined
364
+ : readStoredCollectionIdentity(verificationId, item.item.productId);
365
+ if (!currentItemMatches && storedIdentity === undefined) {
352
366
  return rejected('MICROSOFT_STORE_RECOVERY_OWNERSHIP_REQUIRED');
353
367
  }
368
+ // Keep the stored purchase id when Collections rotates the item. The ledger
369
+ // dedupes on that id, and finalization resumes the existing grant instead of
370
+ // writing a second one. The resume item id is only the consume echo.
371
+ const collectionItemId = storedIdentity?.itemId ?? item.item.id;
372
+ const transactionId = storedIdentity === undefined
373
+ ? item.item.transactionId
374
+ : storedIdentity.transactionId;
375
+ const modifiedDate = storedIdentity?.modifiedDate ?? item.item.modifiedDate;
376
+ const resumeCollectionItemId = storedIdentity !== undefined
377
+ && item.item.id !== collectionItemId
378
+ ? item.item.id
379
+ : undefined;
354
380
  return {
355
381
  status: 'verified',
356
382
  verificationId,
357
383
  verifiedAt,
358
- platformEvidenceId: null,
384
+ // The client purchase token is the add-on product, not a transaction.
385
+ // Publish the stored purchase id so the ledger's platform-transaction
386
+ // index applies, including a re-verify that must keep the legacy key.
387
+ platformEvidenceId: verificationId,
359
388
  payload: {
360
- microsoftStoreCollectionItemId: item.item.id,
361
- microsoftStoreModifiedDate: item.item.modifiedDate,
389
+ microsoftStoreCollectionItemId: collectionItemId,
390
+ microsoftStoreModifiedDate: modifiedDate,
362
391
  microsoftStoreProductId: item.item.productId,
363
392
  microsoftStoreProductKind: item.item.productKind,
364
393
  microsoftStoreQuantity: item.item.quantity,
365
394
  microsoftStoreStatus: item.item.status,
366
395
  microsoftStoreAccountBindingHash: accountBindingHash,
367
396
  microsoftStoreRecoveryOwnershipGeneration: recoveryOwnership.generation,
368
- ...(item.item.transactionId === undefined
397
+ ...(transactionId === undefined
398
+ ? {}
399
+ : { microsoftStoreTransactionId: transactionId }),
400
+ ...(resumeCollectionItemId === undefined
369
401
  ? {}
370
- : { microsoftStoreTransactionId: item.item.transactionId }),
402
+ : { microsoftStoreResumeCollectionItemId: resumeCollectionItemId }),
371
403
  digitalGoodsItemId: clientEvidence.itemId,
372
404
  },
373
405
  };
@@ -432,16 +464,36 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
432
464
  trackingId,
433
465
  signal: finalizationInput.signal,
434
466
  });
435
- if (!isRecord(raw)) {
436
- return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_INVALID');
467
+ if (!isRecord(raw) || raw.trackingId !== trackingId) {
468
+ return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_MISMATCH');
437
469
  }
438
- if (raw.itemId !== context.collectionItemId
439
- || raw.trackingId !== trackingId
440
- || raw.newQuantity !== 0) {
470
+ const orderTransactions = readConsumeOrderTransactions(raw.orderTransactions);
471
+ const echoedItemId = optionalIdentifier(raw.itemId);
472
+ const itemMatches = echoedItemId !== undefined
473
+ && (echoedItemId === context.collectionItemId
474
+ || echoedItemId === context.resumeCollectionItemId);
475
+ // A reused tracking id is a confirmation, not a second consume. Microsoft
476
+ // documents that retry as the current remaining balance with order ids
477
+ // omitted. The echoed item must be the granted item or the item Collections
478
+ // currently lists for this product. A missing balance, a malformed balance,
479
+ // or an unrelated item keeps the claim.
480
+ const currentBalance = readDocumentedCurrentBalance(raw.newQuantity);
481
+ const echoedItemIsCurrent = !itemMatches
482
+ && echoedItemId !== undefined
483
+ && raw.orderTransactions === undefined
484
+ && currentBalance !== undefined
485
+ && currentBalance > 0
486
+ && await echoedItemIsCurrentCollection(input, credentials, context.storeId, echoedItemId, finalizationInput.signal);
487
+ const priorFulfillmentConfirmed = raw.orderTransactions === undefined
488
+ && currentBalance !== undefined
489
+ && currentBalance > 0
490
+ && (itemMatches || echoedItemIsCurrent);
491
+ if (!priorFulfillmentConfirmed
492
+ && (!itemMatches
493
+ || currentBalance !== 0)) {
441
494
  return finalizationPending('MICROSOFT_STORE_CONSUME_RESPONSE_MISMATCH');
442
495
  }
443
496
  if (input.persistConsumptionReceipt !== undefined) {
444
- const orderTransactions = readConsumeOrderTransactions(raw.orderTransactions);
445
497
  // Some consume responses omit the product echo. Attribution comes from the
446
498
  // verified context and the item/tracking checks above; reject a conflicting echo.
447
499
  if ((raw.productId !== undefined && raw.productId !== context.storeId)
@@ -493,6 +545,28 @@ async function consumeMicrosoftStorePurchase(input, finalizationInput, context)
493
545
  return finalizationPending('MICROSOFT_STORE_CONSUME_UNAVAILABLE');
494
546
  }
495
547
  }
548
+ async function echoedItemIsCurrentCollection(input, credentials, storeId, itemId, signal) {
549
+ let response;
550
+ try {
551
+ response = await input.client.queryProduct({ credentials, storeId, signal });
552
+ }
553
+ catch (error) {
554
+ if (signal.aborted) {
555
+ throw error;
556
+ }
557
+ return false;
558
+ }
559
+ const item = inspectPublisherQuery(response, storeId);
560
+ return item.status === 'verified' && item.item.id === itemId;
561
+ }
562
+ function readDocumentedCurrentBalance(value) {
563
+ // Consume documents newQuantity as an int: the remaining balance. A missing,
564
+ // fractional, or negative value is not that balance.
565
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
566
+ return undefined;
567
+ }
568
+ return value;
569
+ }
496
570
  function readConsumeOrderTransactions(value) {
497
571
  // Microsoft documents omitted order IDs for developer-managed consume retries.
498
572
  // Never invent them from the collection item, client token, or tracking ID.
@@ -617,19 +691,23 @@ function readFinalizationContext(input) {
617
691
  const modifiedDate = optionalIdentifier(input.evidencePayload?.microsoftStoreModifiedDate);
618
692
  const accountBindingHash = optionalIdentifier(input.evidencePayload?.microsoftStoreAccountBindingHash);
619
693
  const recoveryOwnershipGeneration = optionalIdentifier(input.evidencePayload?.microsoftStoreRecoveryOwnershipGeneration);
694
+ const transactionId = optionalIdentifier(input.evidencePayload?.microsoftStoreTransactionId);
695
+ const resumeCollectionItemId = optionalIdentifier(input.evidencePayload?.microsoftStoreResumeCollectionItemId);
620
696
  // The binding and this verifier ship together in the first Store release. Missing bindings are
621
697
  // therefore malformed evidence, not legacy evidence, and must fail closed to prevent an account
622
- // switch between verification and consumption.
698
+ // switch between verification and consumption. A grant already keyed by the modifiedDate id is
699
+ // not malformed: finalization must still accept that stored key.
623
700
  if (payloadStoreId === undefined
624
701
  || collectionItemId === undefined
625
702
  || modifiedDate === undefined
626
703
  || accountBindingHash === undefined
627
704
  || recoveryOwnershipGeneration === undefined
628
705
  || recoveryOwnershipGeneration !== input.request.idempotencyKey
629
- || input.evidenceVerificationId !== createVerificationId({
706
+ || !sameUnconsumedPurchase(input.evidenceVerificationId, {
630
707
  id: collectionItemId,
631
708
  modifiedDate,
632
709
  productId: payloadStoreId,
710
+ ...(transactionId === undefined ? {} : { transactionId }),
633
711
  })) {
634
712
  return {
635
713
  status: 'pending',
@@ -641,22 +719,76 @@ function readFinalizationContext(input) {
641
719
  return {
642
720
  status: 'verified',
643
721
  collectionItemId,
722
+ ...(resumeCollectionItemId === undefined ? {} : { resumeCollectionItemId }),
644
723
  storeId: payloadStoreId,
645
724
  accountBindingHash,
646
725
  recoveryOwnershipGeneration,
647
726
  };
648
727
  }
649
728
  function createVerificationId(item) {
729
+ // transactionId names the purchase. When Collections omits it, modifiedDate
730
+ // is the documented per-purchase discriminator. An unreleased claim is not
731
+ // replaced with that new id; see sameUnconsumedPurchase.
732
+ return item.transactionId === undefined
733
+ ? `microsoft-store:${item.productId}:${item.id}:${item.modifiedDate}`
734
+ : `microsoft-store:${item.productId}:${item.id}:${item.transactionId}`;
735
+ }
736
+ /**
737
+ * Parses a verification id this boundary wrote. Item ids are not expected to
738
+ * contain `:`; a date suffix may, so only the first separator is split.
739
+ */
740
+ function readStoredCollectionIdentity(verificationId, storeId) {
741
+ const prefix = `microsoft-store:${storeId}:`;
742
+ if (!verificationId.startsWith(prefix) || verificationId.length <= prefix.length) {
743
+ return undefined;
744
+ }
745
+ const rest = verificationId.slice(prefix.length);
746
+ const separator = rest.indexOf(':');
747
+ if (separator === -1) {
748
+ return optionalIdentifier(rest) === undefined ? undefined : { itemId: rest };
749
+ }
750
+ const itemId = rest.slice(0, separator);
751
+ const suffix = rest.slice(separator + 1);
752
+ if (optionalIdentifier(itemId) === undefined || optionalIdentifier(suffix) === undefined) {
753
+ return undefined;
754
+ }
755
+ return validIsoDate(suffix)
756
+ ? { itemId, modifiedDate: suffix }
757
+ : { itemId, transactionId: suffix };
758
+ }
759
+ /** Ledger key written when a purchase omits transactionId, including older rows. */
760
+ function createLegacyVerificationId(item) {
650
761
  return `microsoft-store:${item.productId}:${item.id}:${item.modifiedDate}`;
651
762
  }
652
- /** Keep existing short generations stable while bounding new provider-derived request keys. */
653
- async function createRecoveryGeneration(providerPurchaseId) {
654
- if (providerPurchaseId.length <= gameServicesRequestLimits.maxStringLength) {
655
- return providerPurchaseId;
763
+ /**
764
+ * True when a stored purchase id still names this unconsumed Collections item.
765
+ * A changed `modifiedDate` still matches, so that query resumes the stored
766
+ * claim instead of minting another id. A stored `transactionId` still matches
767
+ * if the collection item id changed. After release, a new modifiedDate is a
768
+ * new id because no stored claim remains to match.
769
+ */
770
+ function sameUnconsumedPurchase(storedId, item) {
771
+ if (storedId === createVerificationId(item)
772
+ || storedId === createLegacyVerificationId(item)
773
+ || storedId === `microsoft-store:${item.productId}:${item.id}`) {
774
+ return true;
775
+ }
776
+ const itemPrefix = `microsoft-store:${item.productId}:${item.id}:`;
777
+ if (storedId.startsWith(itemPrefix)) {
778
+ const suffix = storedId.slice(itemPrefix.length);
779
+ if (validIsoDate(suffix)) {
780
+ return true;
781
+ }
782
+ return item.transactionId === undefined || item.transactionId === suffix;
783
+ }
784
+ if (item.transactionId !== undefined && !validIsoDate(item.transactionId)) {
785
+ const productPrefix = `microsoft-store:${item.productId}:`;
786
+ const transactionSuffix = `:${item.transactionId}`;
787
+ return storedId.startsWith(productPrefix)
788
+ && storedId.endsWith(transactionSuffix)
789
+ && storedId.length > productPrefix.length + transactionSuffix.length;
656
790
  }
657
- const encoded = new TextEncoder().encode(providerPurchaseId);
658
- const digest = new Uint8Array(await readGlobalSubtleCrypto().digest('SHA-256', encoded));
659
- return `microsoft-store-recovery:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
791
+ return false;
660
792
  }
661
793
  async function deterministicTrackingId(value) {
662
794
  const subtle = readGlobalSubtleCrypto();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -219,9 +219,9 @@
219
219
  "@orpc/client": "2.0.0-beta.14",
220
220
  "@orpc/contract": "2.0.0-beta.14",
221
221
  "@orpc/server": "2.0.0-beta.14",
222
- "@mpgd/analytics": "0.4.14",
223
- "@mpgd/catalog": "0.10.5",
224
- "@mpgd/platform": "0.27.0"
222
+ "@mpgd/analytics": "0.4.15",
223
+ "@mpgd/platform": "0.27.1",
224
+ "@mpgd/catalog": "0.10.6"
225
225
  },
226
226
  "peerDependencies": {
227
227
  "@apple/app-store-server-library": "^3.1.0"