@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.
- package/README.md +8 -3
- package/dist/another-account.d.ts +65 -0
- package/dist/another-account.js +107 -0
- 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 +127 -32
- package/dist/client.js +8 -3
- package/dist/commerce-reconciler.d.ts +55 -1
- package/dist/commerce-reconciler.js +456 -25
- package/dist/evidence-verification.d.ts +14 -2
- package/dist/google-play-purchase.js +22 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +23 -0
- package/dist/server.d.ts +16 -1
- package/dist/server.js +99 -12
- package/package.json +4 -4
|
@@ -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);
|
|
@@ -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
|
|
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
|
-
? {
|
|
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
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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;
|