@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.
- package/dist/app-store-server-notifications.d.ts +15 -2
- package/dist/app-store-server-notifications.js +43 -9
- package/dist/app-store-verification-id.d.ts +25 -0
- package/dist/app-store-verification-id.js +32 -0
- package/dist/app-store-verifier.d.ts +99 -9
- package/dist/app-store-verifier.js +103 -27
- package/dist/client.js +3 -1
- package/dist/commerce-reconciler.d.ts +19 -0
- package/dist/commerce-reconciler.js +229 -4
- package/package.json +4 -4
|
@@ -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 {
|
|
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 =
|
|
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
|
|
90
|
-
* API access: a JWS is a snapshot, so a transaction refunded
|
|
91
|
-
*
|
|
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
|
|
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
|
|
162
|
-
// (offline-only). Without evidence
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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'
|
|
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.
|
|
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.
|
|
235
|
-
"@mpgd/
|
|
236
|
-
"@mpgd/
|
|
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"
|