@mpgd/game-services 0.32.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,4 +1,4 @@
1
- import { type AppStoreEnvironment, type AppStoreTransactionPayload } from './app-store-verifier.js';
1
+ import { type AppStoreEnvironment, type AppStoreRevocationRecorder, type AppStoreTransactionPayload } from './app-store-verifier.js';
2
2
  import type { GameServicesStore } from './server.js';
3
3
  /**
4
4
  * App Store Server Notifications V2 for one-time purchases: the store-side
@@ -70,6 +70,15 @@ export interface HandleAppStoreServerNotificationInput {
70
70
  * most `maxUnmatchedRefundRetryMs` (6 hours).
71
71
  */
72
72
  readonly unmatchedRefundRetryMs?: number;
73
+ /**
74
+ * Records every `REFUND` and `REVOKE` of the customer's own one-time
75
+ * purchase, before the ledger is read, whether or not a grant exists yet.
76
+ * Required in practice for a verifier in `offline-only` mode, which reads
77
+ * the same registry: a refund that arrives before the first grant leaves
78
+ * no grant to reverse, and the client may still hold a JWS signed before
79
+ * it. A recorder outage (`AppStoreDependencyUnavailableError`) is `retry`.
80
+ */
81
+ readonly revocations?: AppStoreRevocationRecorder;
73
82
  }
74
83
  /** Default of `unmatchedRefundRetryMs`. */
75
84
  export declare const defaultUnmatchedRefundRetryMs: number;
@@ -135,6 +144,9 @@ export type AppStoreServerNotificationOutcome =
135
144
  export declare const maxAppStoreServerNotificationBytes: number;
136
145
  /**
137
146
  * Verifies one App Store Server Notification and applies it to the ledger.
147
+ * With `revocations`, every `REFUND` and `REVOKE` of the customer's own
148
+ * one-time purchase is recorded there first, so an offline-only purchase
149
+ * verifier refuses a JWS signed before it even when no grant existed yet.
138
150
  * `REFUND` and `REVOKE` for a granted one-time purchase append one `revoke`
139
151
  * entry, keyed by the notification's `notificationUUID` so a redelivery
140
152
  * answers the first entry. Every other type, a transaction this ledger never
@@ -149,7 +161,8 @@ export declare const maxAppStoreServerNotificationBytes: number;
149
161
  * A refund for a grant this ledger does not hold is `retry` while it is
150
162
  * recent (`unmatchedRefundRetryMs`, a purchase may be committing a grant it
151
163
  * verified before the refund) and `ignored` after; a purchase request that
152
- * starts after the refund is refused by the verifier's Server API mode.
164
+ * starts after the refund is refused by the verifier's Server API mode, or,
165
+ * in offline-only mode, by the `revocations` record.
153
166
  * A thrown error (a store outage, an abort) is not an outcome: the host must
154
167
  * answer it with a 5xx so Apple redelivers.
155
168
  */
@@ -1,5 +1,6 @@
1
1
  import { findEntitlementRevocationsChunked, findPurchaseGrantByEvidenceVerificationId, revokeEntitlementGrant, } from './commerce-ledger.js';
2
- import { AppStoreDependencyUnavailableError, createAppStoreVerificationId, } from './app-store-verifier.js';
2
+ import { createAppStoreGrantVerificationId } from './app-store-verification-id.js';
3
+ import { AppStoreDependencyUnavailableError, } from './app-store-verifier.js';
3
4
  /** Default of `unmatchedRefundRetryMs`. */
4
5
  export const defaultUnmatchedRefundRetryMs = 60 * 60 * 1000;
5
6
  /** Upper bound of `unmatchedRefundRetryMs`. */
@@ -19,6 +20,9 @@ const revocationReasons = {
19
20
  };
20
21
  /**
21
22
  * Verifies one App Store Server Notification and applies it to the ledger.
23
+ * With `revocations`, every `REFUND` and `REVOKE` of the customer's own
24
+ * one-time purchase is recorded there first, so an offline-only purchase
25
+ * verifier refuses a JWS signed before it even when no grant existed yet.
22
26
  * `REFUND` and `REVOKE` for a granted one-time purchase append one `revoke`
23
27
  * entry, keyed by the notification's `notificationUUID` so a redelivery
24
28
  * answers the first entry. Every other type, a transaction this ledger never
@@ -33,7 +37,8 @@ const revocationReasons = {
33
37
  * A refund for a grant this ledger does not hold is `retry` while it is
34
38
  * recent (`unmatchedRefundRetryMs`, a purchase may be committing a grant it
35
39
  * verified before the refund) and `ignored` after; a purchase request that
36
- * starts after the refund is refused by the verifier's Server API mode.
40
+ * starts after the refund is refused by the verifier's Server API mode, or,
41
+ * in offline-only mode, by the `revocations` record.
37
42
  * A thrown error (a store outage, an abort) is not an outcome: the host must
38
43
  * answer it with a 5xx so Apple redelivers.
39
44
  */
@@ -102,14 +107,43 @@ export async function handleAppStoreServerNotification(input) {
102
107
  }
103
108
  // The grant's identity is the one the purchase verifier recorded:
104
109
  // non-consumables by their original transaction, consumables by their own.
105
- const grantVerificationId = createAppStoreVerificationId({
106
- environment: transaction.environment,
107
- bundleId: transaction.bundleId,
108
- transactionId: transaction.type === 'Non-Consumable'
109
- ? transaction.originalTransactionId
110
- : transaction.transactionId,
111
- });
110
+ const grantVerificationId = createAppStoreGrantVerificationId(transaction);
112
111
  const now = input.now ?? (() => new Date().toISOString());
112
+ if (reason !== undefined && input.revocations !== undefined) {
113
+ // Recorded before the ledger is read, so a grant that does not exist
114
+ // yet is refused by an offline-only verifier instead of only retried.
115
+ try {
116
+ await input.revocations.recordRevocation({
117
+ record: {
118
+ verificationId: grantVerificationId,
119
+ environment: transaction.environment,
120
+ bundleId: transaction.bundleId,
121
+ transactionId: transaction.transactionId,
122
+ originalTransactionId: transaction.originalTransactionId,
123
+ productId: transaction.productId,
124
+ reason,
125
+ // Apple's signed dates, never the handling time: a redelivery of
126
+ // an undated refund must not move the cutoff past a reversal. An
127
+ // unrepresentable date is kept unparseable, which refuses every JWS.
128
+ revokedAt: readTimestamp(transaction.revocationDate ?? notification.signedDate)
129
+ ?? 'unparseable',
130
+ notificationType: notification.notificationType,
131
+ notificationUUID,
132
+ },
133
+ signal: input.signal,
134
+ });
135
+ }
136
+ catch (error) {
137
+ if (error instanceof AppStoreDependencyUnavailableError) {
138
+ return {
139
+ status: 'retry',
140
+ reason: 'APP_STORE_REVOCATION_REGISTRY_UNAVAILABLE',
141
+ notificationUUID,
142
+ };
143
+ }
144
+ throw error;
145
+ }
146
+ }
113
147
  const grant = await findPurchaseGrantByEvidenceVerificationId(input.store, grantVerificationId);
114
148
  if (grant === undefined || grant.payload.target !== 'ios') {
115
149
  if (grant === undefined
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The App Store grant identity, shared by the purchase verifier (the
3
+ * verification id its grants record) and the server notification handler
4
+ * (the grant it reverses and the revocation it records): both must derive
5
+ * the same key from a transaction. Internal: the public names are
6
+ * re-exported from `app-store-verifier`.
7
+ */
8
+ export declare function createAppStoreVerificationId(input: {
9
+ readonly environment: 'Production' | 'Sandbox';
10
+ readonly bundleId: string;
11
+ readonly transactionId: string;
12
+ }): string;
13
+ /**
14
+ * The verification id of the grant a transaction makes: a non-consumable by
15
+ * its original transaction (restores and re-downloads share one grant), any
16
+ * other type by its own transaction.
17
+ */
18
+ export declare function createAppStoreGrantVerificationId(transaction: {
19
+ readonly environment: 'Production' | 'Sandbox';
20
+ readonly bundleId: string;
21
+ readonly type: string;
22
+ readonly transactionId: string;
23
+ readonly originalTransactionId: string;
24
+ }): string;
25
+ export declare function encodeVerificationIdSegment(value: string): string;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The App Store grant identity, shared by the purchase verifier (the
3
+ * verification id its grants record) and the server notification handler
4
+ * (the grant it reverses and the revocation it records): both must derive
5
+ * the same key from a transaction. Internal: the public names are
6
+ * re-exported from `app-store-verifier`.
7
+ */
8
+ export function createAppStoreVerificationId(input) {
9
+ return [
10
+ 'app-store',
11
+ input.environment,
12
+ input.bundleId,
13
+ input.transactionId,
14
+ ].map(encodeVerificationIdSegment).join(':');
15
+ }
16
+ /**
17
+ * The verification id of the grant a transaction makes: a non-consumable by
18
+ * its original transaction (restores and re-downloads share one grant), any
19
+ * other type by its own transaction.
20
+ */
21
+ export function createAppStoreGrantVerificationId(transaction) {
22
+ return createAppStoreVerificationId({
23
+ environment: transaction.environment,
24
+ bundleId: transaction.bundleId,
25
+ transactionId: transaction.type === 'Non-Consumable'
26
+ ? transaction.originalTransactionId
27
+ : transaction.transactionId,
28
+ });
29
+ }
30
+ export function encodeVerificationIdSegment(value) {
31
+ return `${value.length}:${encodeURIComponent(value)}`;
32
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PlatformEvidenceEnvelope } from '@mpgd/platform';
2
2
  import type { GameServicesEvidenceVerifier } from './evidence-verification.js';
3
+ import { createAppStoreVerificationId } from './app-store-verification-id.js';
3
4
  export type AppStoreEnvironment = 'Production' | 'Sandbox';
4
5
  export declare const appStoreServerApiBaseUrls: Readonly<{
5
6
  readonly Production: 'https://api.storekit.apple.com';
@@ -85,10 +86,17 @@ export interface CreateAppStoreEvidenceVerifierOptions {
85
86
  * establish authenticity and bind the transaction id, and the grant decision
86
87
  * is then taken on the transaction's current status fetched from the App
87
88
  * Store Server API (`Get Transaction Info`), so a refund that happened after
88
- * the client cached its JWS is seen. `{ mode: 'offline-only', maxSignedAgeMs }`
89
- * decides on the client JWS alone and is only for deployments without Server
90
- * API access: a JWS is a snapshot, so a transaction refunded after it was
91
- * signed is accepted until `signedDate` falls out of the window. `{ mode:
89
+ * the client cached its JWS is seen. `{ mode: 'offline-only', maxSignedAgeMs,
90
+ * revocations }` decides on the client JWS and is only for deployments
91
+ * without Server API access: a JWS is a snapshot, so a transaction refunded
92
+ * after it was signed still reads as purchased. Before a grant it therefore
93
+ * asks `revocations`, the store of refunds and revocations App Store Server
94
+ * Notifications recorded (`handleAppStoreServerNotification({ revocations })`),
95
+ * and refuses a JWS signed at or before a revocation found there (plus
96
+ * `clockSkewMs`) with `APP_STORE_TRANSACTION_REVOKED`; a JWS signed later
97
+ * without a `revocationDate` shows a refund Apple reversed and passes. A
98
+ * refund Apple has not notified yet is still accepted until `signedDate`
99
+ * falls out of the window; the notification then reverses the grant. `{ mode:
92
100
  * 'ignore' }` fetches by id for every request.
93
101
  */
94
102
  readonly signedTransactionEvidence?: AppStoreSignedTransactionEvidencePolicy;
@@ -129,7 +137,93 @@ export type AppStoreSignedTransactionEvidencePolicy = {
129
137
  readonly mode: 'offline-only';
130
138
  /** Maximum age of the JWS `signedDate` at verification time; older evidence is rejected. */
131
139
  readonly maxSignedAgeMs: number;
140
+ /**
141
+ * Required. The revocations this backend learned from App Store Server
142
+ * Notifications, read before every offline-only grant: a client can keep
143
+ * a JWS signed before a refund, and a refund that reached the backend
144
+ * before the first grant has no ledger grant to reverse. A record
145
+ * refuses only a JWS signed at or before its `revokedAt` plus
146
+ * `clockSkewMs`, so a refund Apple later reversed grants again from a
147
+ * fresh JWS. It must read
148
+ * the durable store the notification handler's `revocations` recorder
149
+ * writes, across every instance that verifies purchases.
150
+ */
151
+ readonly revocations: AppStoreRevocationLookup;
132
152
  };
153
+ /** Why Apple ended a one-time purchase: `REFUND` is `refund`, `REVOKE` is `voided`. */
154
+ export type AppStoreRevocationReason = 'refund' | 'voided';
155
+ /**
156
+ * A refund or revocation of a one-time purchase, recorded from a verified App
157
+ * Store Server Notification whether or not the ledger holds a grant for it.
158
+ */
159
+ export interface AppStoreRevocationRecord {
160
+ /**
161
+ * The grant identity the purchase verifier records
162
+ * (`createAppStoreVerificationId`): the original transaction id of a
163
+ * non-consumable, the transaction id of a consumable. The lookup key.
164
+ */
165
+ readonly verificationId: string;
166
+ readonly environment: AppStoreEnvironment;
167
+ readonly bundleId: string;
168
+ readonly transactionId: string;
169
+ readonly originalTransactionId: string;
170
+ readonly productId: string;
171
+ readonly reason: AppStoreRevocationReason;
172
+ /**
173
+ * When Apple revoked it: the transaction's `revocationDate`, else the
174
+ * notification's `signedDate`. Both are Apple's signed values, so a
175
+ * redelivery of the same notification records the same time. A
176
+ * record may be dropped once `revokedAt` is older than the verifier's
177
+ * `maxSignedAgeMs` plus `clockSkewMs`: every JWS the record refuses
178
+ * (signed at or before `revokedAt` plus `clockSkewMs`) is then stale.
179
+ */
180
+ readonly revokedAt: string;
181
+ readonly notificationType: string;
182
+ readonly notificationUUID: string;
183
+ }
184
+ /**
185
+ * Reads recorded revocations for the offline-only verifier. Answer the record
186
+ * for `verificationId`, or `undefined` when none is recorded. Throw
187
+ * `AppStoreDependencyUnavailableError` only for a transient storage outage
188
+ * (the purchase stays `pending`); any other error propagates.
189
+ */
190
+ export interface AppStoreRevocationLookup {
191
+ findRevocation(input: {
192
+ readonly verificationId: string;
193
+ readonly environment: AppStoreEnvironment;
194
+ readonly bundleId: string;
195
+ readonly transactionId: string;
196
+ readonly originalTransactionId: string;
197
+ readonly signal: AbortSignal;
198
+ }): Promise<AppStoreRevocationRecord | undefined>;
199
+ }
200
+ /**
201
+ * Durably records a revocation, keeping per `verificationId` the record with
202
+ * the latest `revokedAt`: a purchase can be refunded, reversed, and revoked
203
+ * again, and the lookup must refuse every JWS signed before the latest
204
+ * revocation. A record whose `revokedAt` does not parse counts as the
205
+ * latest. A record no later than the kept one (a redelivery, or an older
206
+ * notification arriving late) keeps it and answers `alreadyRecorded: true`.
207
+ * Throw `AppStoreDependencyUnavailableError` only for a transient storage
208
+ * outage (the notification is answered `retry`, so Apple redelivers).
209
+ */
210
+ export interface AppStoreRevocationRecorder {
211
+ recordRevocation(input: {
212
+ readonly record: AppStoreRevocationRecord;
213
+ readonly signal: AbortSignal;
214
+ }): Promise<{
215
+ readonly alreadyRecorded: boolean;
216
+ }>;
217
+ }
218
+ export interface AppStoreRevocationRegistry extends AppStoreRevocationLookup, AppStoreRevocationRecorder {
219
+ }
220
+ /**
221
+ * A process-local `AppStoreRevocationRegistry` for tests and single-process
222
+ * hosts. It forgets everything on restart and is not shared between
223
+ * instances (a Worker isolate is one), so a deployment that verifies
224
+ * purchases offline needs a durable registry of its own.
225
+ */
226
+ export declare function createInMemoryAppStoreRevocationRegistry(): AppStoreRevocationRegistry;
133
227
  /**
134
228
  * Evidence schema of a commerce v2 App Store purchase (RFC B3): the payload's
135
229
  * `signedTransaction` is the StoreKit-verified JWS the client received. The
@@ -156,11 +250,7 @@ export type AppStoreFetch = (url: string, init: {
156
250
  }) => Promise<AppStoreFetchResponse>;
157
251
  export declare function createAppStoreServerApiClient(options: CreateAppStoreServerApiClientOptions): AppStoreServerApiClient;
158
252
  export declare function createAppStoreGameServicesEvidenceVerifier(options: CreateAppStoreEvidenceVerifierOptions): GameServicesEvidenceVerifier;
159
- export declare function createAppStoreVerificationId(input: {
160
- readonly environment: AppStoreEnvironment;
161
- readonly bundleId: string;
162
- readonly transactionId: string;
163
- }): string;
253
+ export { createAppStoreVerificationId };
164
254
  /** Identify IDs emitted by this verifier without duplicating its encoding format. */
165
255
  export declare function isAppStoreVerificationId(verificationId: string): boolean;
166
256
  export declare function assertAppStoreTransactionPayload(input: unknown): AppStoreTransactionPayload;
@@ -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);
@@ -263,6 +300,21 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
263
300
  if (rejection !== undefined) {
264
301
  return { status: 'rejected', reason: rejection };
265
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
+ }
266
318
  if (tokenMismatch && !foreignBinding) {
267
319
  // A transaction without an appAccountToken (bought outside the app, or
268
320
  // by a build that set none) is no account's: refused for every player.
@@ -283,7 +335,7 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
283
335
  : 'APP_STORE_ACCOUNT_TOKEN_MISMATCH',
284
336
  }
285
337
  : { status: 'verified' }),
286
- verificationId: createVerificationId(transaction),
338
+ verificationId,
287
339
  verifiedAt: new Date(nowMs).toISOString(),
288
340
  payload: {
289
341
  appStoreBundleId: transaction.bundleId,
@@ -299,6 +351,42 @@ async function verifyAppStorePurchase(input, options, now, clockSkewMs) {
299
351
  const defaultSignedTransactionEvidencePolicy = {
300
352
  mode: 'server-api',
301
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
+ }
302
390
  function readSandboxGrantPolicy(options) {
303
391
  return options.environment === 'Production' && options.sandboxPurchases?.mode === 'grant'
304
392
  ? options.sandboxPurchases
@@ -396,6 +484,14 @@ function assertSignedTransactionEvidencePolicy(input) {
396
484
  }
397
485
  if (input.mode === 'offline-only') {
398
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
+ }
399
495
  return;
400
496
  }
401
497
  throw new Error('signedTransactionEvidence.mode must be ignore, server-api, or offline-only.');
@@ -511,24 +607,7 @@ expectedAccountToken, nowMs, clockSkewMs) {
511
607
  }
512
608
  return undefined;
513
609
  }
514
- function createVerificationId(transaction) {
515
- const grantTransactionId = transaction.type === 'Non-Consumable'
516
- ? transaction.originalTransactionId
517
- : transaction.transactionId;
518
- return createAppStoreVerificationId({
519
- environment: transaction.environment,
520
- bundleId: transaction.bundleId,
521
- transactionId: grantTransactionId,
522
- });
523
- }
524
- export function createAppStoreVerificationId(input) {
525
- return [
526
- 'app-store',
527
- input.environment,
528
- input.bundleId,
529
- input.transactionId,
530
- ].map(encodeVerificationIdSegment).join(':');
531
- }
610
+ export { createAppStoreVerificationId };
532
611
  /** Identify IDs emitted by this verifier without duplicating its encoding format. */
533
612
  export function isAppStoreVerificationId(verificationId) {
534
613
  const parts = verificationId.split(':');
@@ -560,9 +639,6 @@ export function isAppStoreVerificationId(verificationId) {
560
639
  && typeof values[2] === 'string' && values[2].length > 0
561
640
  && typeof values[3] === 'string' && /^[1-9][0-9]{0,19}$/u.test(values[3]);
562
641
  }
563
- function encodeVerificationIdSegment(value) {
564
- return `${value.length}:${encodeURIComponent(value)}`;
565
- }
566
642
  function toCatalogProductType(type) {
567
643
  switch (type) {
568
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') {
@@ -91,6 +91,12 @@ export declare function summarizeCommercePurchaseOperation(record: CommercePurch
91
91
  /** A `verifying` record the backend answered as another account's purchase. */
92
92
  export declare function isParkedForAnotherAccount(record: CommercePurchaseOperationRecord): boolean;
93
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>;
99
+ /**
94
100
  * Only `granted` and `finishing` records that hold the backend's ledger entry
95
101
  * id and a store transaction may be finished. The StoreKit SDK keeps no
96
102
  * finish allow-list of its own (the removed v1 driver paired transactions in
@@ -139,6 +145,19 @@ export type CommerceReconcilerEvent = {
139
145
  readonly record: CommercePurchaseOperationRecord;
140
146
  readonly reason: string;
141
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;
142
161
  } | {
143
162
  readonly type: 'failure';
144
163
  readonly stage: CommerceReconcilerFailureStage;
@@ -69,6 +69,34 @@ function releasesSharedKey(record) {
69
69
  && !record.response.verified
70
70
  && isStoreAccountMismatchReason(record.response.reason));
71
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
+ }
72
100
  function isRetryableBeforeLaunch(record) {
73
101
  return record.state === 'requested' && record.error?.retryable === true
74
102
  && record.purchase === undefined && record.request === undefined;
@@ -775,6 +803,182 @@ export function createCommerceReconciler(input) {
775
803
  fail('finish', error, record.idempotencyKey);
776
804
  }
777
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
+ };
778
982
  /**
779
983
  * A store report of a record the backend answered as another account's
780
984
  * purchase. Only this player's Restore Purchases asks again (a restore
@@ -887,6 +1091,25 @@ export function createCommerceReconciler(input) {
887
1091
  }
888
1092
  record = undefined;
889
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
+ }
890
1113
  const account = record !== undefined || (restoring && product.kind === 'durable')
891
1114
  ? 'own'
892
1115
  : await accountOf(purchase);
@@ -917,10 +1140,6 @@ export function createCommerceReconciler(input) {
917
1140
  }
918
1141
  }
919
1142
  if (record === undefined) {
920
- if (purchase.state === 'revoked') {
921
- // Revoked store states never revoke on their own: only the ledger does (step 5).
922
- return;
923
- }
924
1143
  const idempotencyKey = await createDerivedPurchaseIdempotencyKey({
925
1144
  store: purchase.store,
926
1145
  transactionId: purchase.transactionId,
@@ -1378,6 +1597,10 @@ export function createCommerceReconciler(input) {
1378
1597
  return;
1379
1598
  }
1380
1599
  await revokeReversed();
1600
+ if (sessionBlocked()) {
1601
+ return;
1602
+ }
1603
+ await finishRevokedReported(options.explicit);
1381
1604
  };
1382
1605
  const runUntilIdle = async () => {
1383
1606
  if (running !== undefined) {
@@ -1780,6 +2003,7 @@ export function createCommerceReconciler(input) {
1780
2003
  deferred.length = 0;
1781
2004
  records.clear();
1782
2005
  retryAt.clear();
2006
+ revokedReports.clear();
1783
2007
  throw error;
1784
2008
  }
1785
2009
  finally {
@@ -1817,6 +2041,7 @@ export function createCommerceReconciler(input) {
1817
2041
  deferred.length = 0;
1818
2042
  records.clear();
1819
2043
  retryAt.clear();
2044
+ revokedReports.clear();
1820
2045
  },
1821
2046
  async reconcile() {
1822
2047
  await withLock(async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/game-services",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Client, contract, server, store, and test helpers for authoritative mpgd game services.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -231,9 +231,9 @@
231
231
  "@orpc/client": "2.0.0-beta.46",
232
232
  "@orpc/contract": "2.0.0-beta.46",
233
233
  "@orpc/server": "2.0.0-beta.46",
234
- "@mpgd/analytics": "0.5.7",
235
- "@mpgd/platform": "0.34.0",
236
- "@mpgd/catalog": "0.14.1"
234
+ "@mpgd/analytics": "0.5.8",
235
+ "@mpgd/catalog": "0.14.2",
236
+ "@mpgd/platform": "0.35.0"
237
237
  },
238
238
  "peerDependencies": {
239
239
  "@apple/app-store-server-library": "^3.1.0"