@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.
@@ -1,3 +1,4 @@
1
+ import { createAppStoreGrantVerificationId, createAppStoreVerificationId, encodeVerificationIdSegment, } from './app-store-verification-id.js';
1
2
  export const appStoreServerApiBaseUrls = Object.freeze({
2
3
  Production: 'https://api.storekit.apple.com',
3
4
  Sandbox: 'https://api.storekit-sandbox.apple.com',
@@ -16,6 +17,42 @@ class AppStoreResponseTooLargeError extends Error {
16
17
  this.name = 'AppStoreResponseTooLargeError';
17
18
  }
18
19
  }
20
+ /**
21
+ * A process-local `AppStoreRevocationRegistry` for tests and single-process
22
+ * hosts. It forgets everything on restart and is not shared between
23
+ * instances (a Worker isolate is one), so a deployment that verifies
24
+ * purchases offline needs a durable registry of its own.
25
+ */
26
+ export function createInMemoryAppStoreRevocationRegistry() {
27
+ const records = new Map();
28
+ return {
29
+ async findRevocation(input) {
30
+ return records.get(input.verificationId);
31
+ },
32
+ async recordRevocation(input) {
33
+ assertNonEmptyString(input.record.verificationId, 'record.verificationId');
34
+ const kept = records.get(input.record.verificationId);
35
+ if (kept !== undefined && !isLaterRevocation(input.record, kept)) {
36
+ return { alreadyRecorded: true };
37
+ }
38
+ records.set(input.record.verificationId, { ...input.record });
39
+ return { alreadyRecorded: false };
40
+ },
41
+ };
42
+ }
43
+ /**
44
+ * Whether `record` revokes later than `kept`, the order a registry keeps
45
+ * records in. An unparseable `revokedAt` is the latest (it refuses every
46
+ * JWS), and one unparseable record never replaces another.
47
+ */
48
+ function isLaterRevocation(record, kept) {
49
+ const recordMs = Date.parse(record.revokedAt);
50
+ const keptMs = Date.parse(kept.revokedAt);
51
+ if (!Number.isFinite(keptMs)) {
52
+ return false;
53
+ }
54
+ return !Number.isFinite(recordMs) || recordMs > keptMs;
55
+ }
19
56
  /**
20
57
  * Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
21
58
  * `signedTransaction` is the StoreKit-verified JWS the client received. The
@@ -158,9 +195,9 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
158
195
  // Commerce v2 evidence carries the client's StoreKit-verified JWS. Its
159
196
  // signature establishes authenticity and binds the transaction id; the
160
197
  // grant decision is then taken on the transaction's current status from
161
- // the Server API (default), or on the JWS alone within a freshness window
162
- // (offline-only). Without evidence (current clients) the transaction is
163
- // fetched by id as before.
198
+ // the Server API (default), or on the JWS within a freshness window and
199
+ // the revocations notifications recorded (offline-only). Without evidence
200
+ // (current clients) the transaction is fetched by id as before.
164
201
  const policy = options.signedTransactionEvidence ?? defaultSignedTransactionEvidencePolicy;
165
202
  const evidence = policy.mode === 'ignore'
166
203
  ? { status: 'absent' }
@@ -240,8 +277,8 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
240
277
  if (offlineTransaction !== undefined
241
278
  && policy.mode === 'offline-only'
242
279
  && nowMs - transaction.signedDate > policy.maxSignedAgeMs) {
243
- // Offline-only never sees a later refund; a JWS older than the window is
244
- // not fresh enough to decide a grant on.
280
+ // The JWS never shows a refund Apple has not notified yet; a JWS older
281
+ // than the window is not fresh enough to decide a grant on.
245
282
  return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_STALE' };
246
283
  }
247
284
  let rejection = validateTransaction(input, transaction, options.bundleId, environment, expectedAccountToken.value, nowMs, clockSkewMs);
@@ -250,21 +287,55 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
250
287
  // when every other check passes: the backend's restore sharing policy
251
288
  // decides (RFC "Restore sharing"). The client's StoreKit-verified JWS is
252
289
  // required, so a transaction id alone never shares a purchase.
253
- const foreignBinding = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH'
290
+ const tokenMismatch = rejection === 'APP_STORE_ACCOUNT_TOKEN_MISMATCH';
291
+ if (tokenMismatch) {
292
+ // Every other check still decides: only a purchase that passes them all
293
+ // is reported as restorable or as another account's.
294
+ rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
295
+ }
296
+ const foreignBinding = tokenMismatch
254
297
  && input.request.intent === 'restore'
255
298
  && input.product.type === 'non_consumable'
256
299
  && offlineTransaction !== undefined;
257
- if (foreignBinding) {
258
- rejection = validateTransaction(input, transaction, options.bundleId, environment, undefined, nowMs, clockSkewMs);
259
- }
260
300
  if (rejection !== undefined) {
261
301
  return { status: 'rejected', reason: rejection };
262
302
  }
303
+ const verificationId = createAppStoreGrantVerificationId(transaction);
304
+ if (offlineTransaction !== undefined && policy.mode === 'offline-only') {
305
+ // The client JWS cannot show a refund after it was signed: a refund the
306
+ // backend already heard of refuses a JWS signed before it, as a fresh
307
+ // Server API status would. A refund before the first grant has no ledger
308
+ // grant to reverse, so without this check it would grant with nothing
309
+ // behind it. A JWS signed after it without a `revocationDate` shows that
310
+ // Apple reversed the refund, and passes. It runs before the account
311
+ // binding answers, so a refund refuses every presenter, as the Server
312
+ // API's `revocationDate` does.
313
+ const revocation = await findRecordedRevocation(policy.revocations, verificationId, transaction, clockSkewMs, input.signal);
314
+ if (revocation !== undefined) {
315
+ return revocation;
316
+ }
317
+ }
318
+ if (tokenMismatch && !foreignBinding) {
319
+ // A transaction without an appAccountToken (bought outside the app, or
320
+ // by a build that set none) is no account's: refused for every player.
321
+ // One with another token is that account's, which can still recover it
322
+ // (RFC "Another account's purchase").
323
+ return transaction.appAccountToken === undefined
324
+ ? { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISSING' }
325
+ : { status: 'rejected', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH', anotherAccount: true };
326
+ }
263
327
  return {
264
328
  ...(foreignBinding
265
- ? { status: 'restorable', reason: 'APP_STORE_ACCOUNT_TOKEN_MISMATCH' }
329
+ ? {
330
+ status: 'restorable',
331
+ // Bound to no account, or to another one: the backend refuses the
332
+ // former for good when no share covers it.
333
+ reason: transaction.appAccountToken === undefined
334
+ ? 'APP_STORE_ACCOUNT_TOKEN_MISSING'
335
+ : 'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
336
+ }
266
337
  : { status: 'verified' }),
267
- verificationId: createVerificationId(transaction),
338
+ verificationId,
268
339
  verifiedAt: new Date(nowMs).toISOString(),
269
340
  payload: {
270
341
  appStoreBundleId: transaction.bundleId,
@@ -280,6 +351,42 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
280
351
  const defaultSignedTransactionEvidencePolicy = {
281
352
  mode: 'server-api',
282
353
  };
354
+ /**
355
+ * The offline-only refusal for a revocation the backend recorded, or
356
+ * `undefined` when none applies. A record refuses a JWS signed at or before
357
+ * its `revokedAt` (widened by `clockSkewMs`, so skew errs toward refusing):
358
+ * that JWS is a snapshot from before the refund. A JWS signed later already
359
+ * shows the revocation in its own `revocationDate` (refused by the payload
360
+ * checks), unless Apple reversed the refund, so it passes. A record whose
361
+ * `revokedAt` does not parse refuses every JWS.
362
+ */
363
+ async function findRecordedRevocation(revocations, verificationId, transaction, clockSkewMs, signal) {
364
+ let record;
365
+ try {
366
+ record = await revocations.findRevocation({
367
+ verificationId,
368
+ environment: transaction.environment,
369
+ bundleId: transaction.bundleId,
370
+ transactionId: transaction.transactionId,
371
+ originalTransactionId: transaction.originalTransactionId,
372
+ signal,
373
+ });
374
+ }
375
+ catch (error) {
376
+ if (!(error instanceof AppStoreDependencyUnavailableError)) {
377
+ throw error;
378
+ }
379
+ return { status: 'pending', reason: 'APP_STORE_REVOCATION_LOOKUP_UNAVAILABLE' };
380
+ }
381
+ if (record === undefined) {
382
+ return undefined;
383
+ }
384
+ const revokedAtMs = typeof record.revokedAt === 'string' ? Date.parse(record.revokedAt) : NaN;
385
+ if (Number.isFinite(revokedAtMs) && transaction.signedDate > revokedAtMs + clockSkewMs) {
386
+ return undefined;
387
+ }
388
+ return { status: 'rejected', reason: 'APP_STORE_TRANSACTION_REVOKED' };
389
+ }
283
390
  function readSandboxGrantPolicy(options) {
284
391
  return options.environment === 'Production' && options.sandboxPurchases?.mode === 'grant'
285
392
  ? options.sandboxPurchases
@@ -377,6 +484,14 @@ function assertSignedTransactionEvidencePolicy(input) {
377
484
  }
378
485
  if (input.mode === 'offline-only') {
379
486
  readPositiveSafeInteger(input.maxSignedAgeMs, 'signedTransactionEvidence.maxSignedAgeMs');
487
+ if (typeof input.revocations !== 'object'
488
+ || input.revocations === null
489
+ || typeof input.revocations.findRevocation
490
+ !== 'function') {
491
+ // A client JWS cannot show a later refund; without the recorded
492
+ // revocations a refund before the first grant would still grant.
493
+ throw new Error('signedTransactionEvidence.revocations must implement findRevocation for offline-only.');
494
+ }
380
495
  return;
381
496
  }
382
497
  throw new Error('signedTransactionEvidence.mode must be ignore, server-api, or offline-only.');
@@ -492,24 +607,7 @@ expectedAccountToken, nowMs, clockSkewMs) {
492
607
  }
493
608
  return undefined;
494
609
  }
495
- function createVerificationId(transaction) {
496
- const grantTransactionId = transaction.type === 'Non-Consumable'
497
- ? transaction.originalTransactionId
498
- : transaction.transactionId;
499
- return createAppStoreVerificationId({
500
- environment: transaction.environment,
501
- bundleId: transaction.bundleId,
502
- transactionId: grantTransactionId,
503
- });
504
- }
505
- export function createAppStoreVerificationId(input) {
506
- return [
507
- 'app-store',
508
- input.environment,
509
- input.bundleId,
510
- input.transactionId,
511
- ].map(encodeVerificationIdSegment).join(':');
512
- }
610
+ export { createAppStoreVerificationId };
513
611
  /** Identify IDs emitted by this verifier without duplicating its encoding format. */
514
612
  export function isAppStoreVerificationId(verificationId) {
515
613
  const parts = verificationId.split(':');
@@ -541,9 +639,6 @@ export function isAppStoreVerificationId(verificationId) {
541
639
  && typeof values[2] === 'string' && values[2].length > 0
542
640
  && typeof values[3] === 'string' && /^[1-9][0-9]{0,19}$/u.test(values[3]);
543
641
  }
544
- function encodeVerificationIdSegment(value) {
545
- return `${value.length}:${encodeURIComponent(value)}`;
546
- }
547
642
  function toCatalogProductType(type) {
548
643
  switch (type) {
549
644
  case 'Consumable':
package/dist/client.js CHANGED
@@ -165,7 +165,9 @@ export function createGameServicesClient(input) {
165
165
  }
166
166
  };
167
167
  const unsubscribe = reconciler.subscribe((event) => {
168
- if (event.type === 'failure' || event.record.idempotencyKey !== key) {
168
+ if (event.type === 'failure'
169
+ || event.type === 'revoked-finish'
170
+ || event.record.idempotencyKey !== key) {
169
171
  return;
170
172
  }
171
173
  if (event.type === 'transition') {
@@ -191,7 +193,8 @@ export function createGameServicesClient(input) {
191
193
  }
192
194
  else if (!serverResulted) {
193
195
  serverResulted = true;
194
- progress.serverResult(event.response.verified);
196
+ // Another account's purchase is a server answer with no grant.
197
+ progress.serverResult(event.type === 'verify-result' && event.response.verified);
195
198
  }
196
199
  });
197
200
  try {
@@ -235,7 +238,9 @@ export function createGameServicesClient(input) {
235
238
  alreadyProcessed: result.verification?.alreadyProcessed,
236
239
  reason: result.status === 'pending'
237
240
  ? 'purchase_pending'
238
- : (result.verification?.reason ?? record?.error?.code),
241
+ : (result.verification?.reason
242
+ ?? record?.anotherAccount?.reason
243
+ ?? record?.error?.code),
239
244
  },
240
245
  });
241
246
  }
@@ -55,6 +55,23 @@ export interface CommercePurchaseOperationRecord {
55
55
  /** A retryable error on `requested` means the provider proved checkout never started. */
56
56
  readonly error?: CommerceErrorInfo;
57
57
  readonly revokedBy?: string;
58
+ /**
59
+ * The backend answered that the store binds this purchase to another
60
+ * account than this record's player (RFC "Another account's purchase"):
61
+ * `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT`, or a `rejected` receipt with a
62
+ * store binding mismatch reason from a backend before that answer. The
63
+ * record is parked in `verifying`: never sent again automatically, not
64
+ * listed among the player's recoverable records, summarized as `rejected`
65
+ * for this player, and its shared key no longer refuses another player
66
+ * of the install, who journals the purchase under its own player-scoped
67
+ * key. Only this player's Restore Purchases, when the store reports the
68
+ * purchase again, sends it once more.
69
+ */
70
+ readonly anotherAccount?: {
71
+ /** The store's binding mismatch, for example `GOOGLE_PLAY_ACCOUNT_MISMATCH`. */
72
+ readonly reason: string;
73
+ readonly reportedAt: string;
74
+ };
58
75
  }
59
76
  /**
60
77
  * The store key of a commerce v2 purchase record: one namespace per ledger
@@ -65,8 +82,20 @@ export interface CommercePurchaseOperationRecord {
65
82
  */
66
83
  export declare function createCommercePurchaseOperationKey(target: string, idempotencyKey: string, playerId?: string): string;
67
84
  export declare function isCommercePurchaseOperationRecord(record: unknown): record is CommercePurchaseOperationRecord;
68
- /** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
85
+ /**
86
+ * Summary status of a record: the RFC mapping, with `revoked` kept distinct.
87
+ * A record parked as another account's purchase is `rejected` for its
88
+ * player: nothing was granted to it, and nothing will be.
89
+ */
69
90
  export declare function summarizeCommercePurchaseOperation(record: CommercePurchaseOperationRecord): CommercePurchaseSummaryStatus;
91
+ /** A `verifying` record the backend answered as another account's purchase. */
92
+ export declare function isParkedForAnotherAccount(record: CommercePurchaseOperationRecord): boolean;
93
+ /**
94
+ * Backend refusals that answer for a revoked purchase: the App Store verifier
95
+ * read the refund (`APP_STORE_TRANSACTION_REVOKED`), or the ledger had
96
+ * already reversed the grant this request names (`LEDGER_GRANT_REVERSED`).
97
+ */
98
+ export declare const commerceRevokedRejectionReasons: ReadonlySet<string>;
70
99
  /**
71
100
  * Only `granted` and `finishing` records that hold the backend's ledger entry
72
101
  * id and a store transaction may be finished. The StoreKit SDK keeps no
@@ -104,6 +133,31 @@ export type CommerceReconcilerEvent = {
104
133
  readonly type: 'verify-result';
105
134
  readonly record: CommercePurchaseOperationRecord;
106
135
  readonly response: VerifyPurchaseResponse;
136
+ } | {
137
+ /**
138
+ * The backend answered the record's verify as another account's
139
+ * purchase (RFC "Another account's purchase"): the record is parked,
140
+ * nothing was granted, and it is not a failure. `response` is the
141
+ * receipt of a backend before that answer (`rejected` with a store
142
+ * binding mismatch reason); absent for the 409 answer.
143
+ */
144
+ readonly type: 'another-account';
145
+ readonly record: CommercePurchaseOperationRecord;
146
+ readonly reason: string;
147
+ readonly response?: VerifyPurchaseResponse;
148
+ } | {
149
+ /**
150
+ * A transaction the store revoked (refunded) while it was unfinished
151
+ * was asked to finish without a grant (`FinishTransactionInput.revoked`),
152
+ * and the store answered `finished`. `idempotencyKey` names this
153
+ * player's record the backend answered for; absent, the reconciler
154
+ * held no record of it, and the store's own-account revoked consumable
155
+ * was finished without a verify (nothing is journaled or granted).
156
+ */
157
+ readonly type: 'revoked-finish';
158
+ readonly transactionId: string;
159
+ readonly idempotencyKey?: string;
160
+ readonly finished: boolean;
107
161
  } | {
108
162
  readonly type: 'failure';
109
163
  readonly stage: CommerceReconcilerFailureStage;