@mpgd/game-services 0.28.1 → 0.29.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 +18 -3
- package/dist/account-deletion.d.ts +306 -0
- package/dist/account-deletion.js +491 -0
- package/dist/app-store-recovery.d.ts +6 -0
- package/dist/app-store-recovery.js +26 -16
- package/dist/app-store-server-notifications.d.ts +158 -0
- package/dist/app-store-server-notifications.js +270 -0
- package/dist/app-store-server.d.ts +8 -0
- package/dist/app-store-server.js +135 -37
- package/dist/app-store-verifier.d.ts +34 -0
- package/dist/app-store-verifier.js +161 -31
- package/dist/commerce-ledger.d.ts +55 -0
- package/dist/commerce-ledger.js +198 -0
- package/dist/commerce-reconciler.js +5 -0
- package/dist/evidence-verification.d.ts +19 -0
- package/dist/google-play-publisher-client.d.ts +2 -1
- package/dist/google-play-publisher-client.js +80 -0
- package/dist/google-play-purchase.d.ts +7 -0
- package/dist/google-play-purchase.js +32 -7
- package/dist/google-play-store-server.d.ts +160 -0
- package/dist/google-play-store-server.js +297 -0
- package/dist/guest-session.d.ts +112 -2
- package/dist/guest-session.js +196 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/notifications/delivery-provider.d.ts +7 -0
- package/dist/notifications/fcm.d.ts +30 -3
- package/dist/notifications/fcm.js +72 -13
- package/dist/notifications/service.js +33 -5
- package/dist/notifications/store-conformance.js +7 -1
- package/dist/notifications/store.d.ts +9 -0
- package/dist/notifications/store.js +41 -0
- package/dist/operation-results.d.ts +7 -0
- package/dist/purchase-sharing.d.ts +41 -0
- package/dist/purchase-sharing.js +37 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +13 -0
- package/dist/server.d.ts +60 -1
- package/dist/server.js +536 -42
- package/dist/types.d.ts +30 -0
- package/dist/types.js +22 -0
- package/dist/verified-leaderboard.d.ts +13 -0
- package/dist/verified-leaderboard.js +31 -0
- package/package.json +17 -5
|
@@ -59,8 +59,88 @@ export function createGooglePlayPublisherClient(options) {
|
|
|
59
59
|
+ `/tokens/${pathPart(purchaseToken)}:consume`;
|
|
60
60
|
await discardBody(await request('POST', path, signal));
|
|
61
61
|
},
|
|
62
|
+
async listVoidedPurchases({ packageName, startTimeMillis, endTimeMillis, pageToken, maxResults, signal, }) {
|
|
63
|
+
// purchases.voidedpurchases.list, in-app products only (type 0, the
|
|
64
|
+
// default): https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.voidedpurchases/list
|
|
65
|
+
const query = new URLSearchParams();
|
|
66
|
+
if (pageToken === undefined) {
|
|
67
|
+
query.set('startTime', String(readMillis(startTimeMillis, 'startTimeMillis')));
|
|
68
|
+
query.set('endTime', String(readMillis(endTimeMillis, 'endTimeMillis')));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
query.set('token', pageToken);
|
|
72
|
+
}
|
|
73
|
+
if (maxResults !== undefined) {
|
|
74
|
+
if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > 1000) {
|
|
75
|
+
throw new RangeError('maxResults must be an integer from 1 to 1000.');
|
|
76
|
+
}
|
|
77
|
+
query.set('maxResults', String(maxResults));
|
|
78
|
+
}
|
|
79
|
+
const response = await request('GET', `${purchasePath(packageName)}/voidedpurchases?${query.toString()}`, signal);
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = await readBoundedJson(response);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
if (signal.aborted) {
|
|
86
|
+
throw new DOMException('Google Play Publisher request was cancelled.', 'AbortError');
|
|
87
|
+
}
|
|
88
|
+
throw new Error('Google Play Publisher returned invalid voided purchases JSON.');
|
|
89
|
+
}
|
|
90
|
+
return readVoidedPurchasesPage(body);
|
|
91
|
+
},
|
|
62
92
|
};
|
|
63
93
|
}
|
|
94
|
+
function readMillis(value, label) {
|
|
95
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
96
|
+
throw new RangeError(`${label} must be a non-negative integer of milliseconds.`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function readVoidedPurchasesPage(body) {
|
|
101
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
102
|
+
throw new Error('Google Play Publisher returned an invalid voided purchases page.');
|
|
103
|
+
}
|
|
104
|
+
const record = body;
|
|
105
|
+
const entries = record.voidedPurchases ?? [];
|
|
106
|
+
if (!Array.isArray(entries)) {
|
|
107
|
+
throw new Error('Google Play Publisher returned an invalid voided purchases page.');
|
|
108
|
+
}
|
|
109
|
+
const voidedPurchases = [];
|
|
110
|
+
let droppedEntries = 0;
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
113
|
+
droppedEntries += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const item = entry;
|
|
117
|
+
if (typeof item.purchaseToken !== 'string' || item.purchaseToken.length === 0) {
|
|
118
|
+
droppedEntries += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
voidedPurchases.push({
|
|
122
|
+
purchaseToken: item.purchaseToken,
|
|
123
|
+
...(typeof item.orderId === 'string' ? { orderId: item.orderId } : {}),
|
|
124
|
+
...pickMillisOrCode(item, 'voidedTimeMillis'),
|
|
125
|
+
...pickMillisOrCode(item, 'purchaseTimeMillis'),
|
|
126
|
+
...pickMillisOrCode(item, 'voidedSource'),
|
|
127
|
+
...pickMillisOrCode(item, 'voidedReason'),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const pagination = record.tokenPagination;
|
|
131
|
+
const nextPageToken = typeof pagination === 'object' && pagination !== null
|
|
132
|
+
? pagination.nextPageToken
|
|
133
|
+
: undefined;
|
|
134
|
+
return {
|
|
135
|
+
voidedPurchases,
|
|
136
|
+
...(typeof nextPageToken === 'string' && nextPageToken.length > 0 ? { nextPageToken } : {}),
|
|
137
|
+
...(droppedEntries === 0 ? {} : { droppedEntries }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function pickMillisOrCode(item, key) {
|
|
141
|
+
const value = item[key];
|
|
142
|
+
return typeof value === 'string' || typeof value === 'number' ? { [key]: value } : {};
|
|
143
|
+
}
|
|
64
144
|
function purchasePath(packageName) {
|
|
65
145
|
return `/androidpublisher/v3/applications/${pathPart(packageName)}/purchases`;
|
|
66
146
|
}
|
|
@@ -38,3 +38,10 @@ export interface GooglePlayProductPurchaseBoundary extends GameServicesPurchaseG
|
|
|
38
38
|
verifyPurchase(input: VerifyPurchaseEvidenceInput): ReturnType<GameServicesEvidenceVerifier['verifyPurchase']>;
|
|
39
39
|
}
|
|
40
40
|
export declare function createGooglePlayProductPurchaseBoundary(input: CreateGooglePlayProductPurchaseBoundaryInput): GooglePlayProductPurchaseBoundary;
|
|
41
|
+
/**
|
|
42
|
+
* The ledger's authority identity of a Play one-time purchase: what
|
|
43
|
+
* `verifyPurchase` records as `evidenceVerificationId`. Store-side handlers
|
|
44
|
+
* (voided purchases, RTDN) find the grant of a purchase token with it, so the
|
|
45
|
+
* raw token never needs to be stored in the ledger.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createGooglePlayProductVerificationId(packageName: string, purchaseToken: string): Promise<string>;
|
|
@@ -49,18 +49,25 @@ export function createGooglePlayProductPurchaseBoundary(input) {
|
|
|
49
49
|
orderMatch: createGooglePlayObservedOrderMatch(verificationInput.request.platformTransactionId, await createGooglePlayTokenPurchaseKey(purchaseToken)),
|
|
50
50
|
accountBinding,
|
|
51
51
|
allowConsumed: false,
|
|
52
|
+
// A restored non-consumable bound to an earlier player (a reinstall,
|
|
53
|
+
// a new device) is reported, not refused: the backend's restore
|
|
54
|
+
// sharing policy decides (RFC "Restore sharing").
|
|
55
|
+
reportForeignBinding: verificationInput.request.intent === 'restore'
|
|
56
|
+
&& verificationInput.product.type === 'non_consumable',
|
|
52
57
|
signal: verificationInput.signal,
|
|
53
58
|
});
|
|
54
59
|
if (inspection.status !== 'verified') {
|
|
55
60
|
return inspection;
|
|
56
61
|
}
|
|
57
|
-
|
|
58
|
-
status: 'verified',
|
|
62
|
+
const verified = {
|
|
59
63
|
verificationId: createGooglePlayVerificationId(inspection.purchase.purchaseTokenDigest),
|
|
60
64
|
verifiedAt: now(),
|
|
61
65
|
platformEvidenceId: inspection.purchase.orderId ?? null,
|
|
62
66
|
payload: createVerificationPayload(packageName, inspection.purchase),
|
|
63
67
|
};
|
|
68
|
+
return inspection.foreignBinding === undefined
|
|
69
|
+
? { status: 'verified', ...verified }
|
|
70
|
+
: { status: 'restorable', reason: inspection.foreignBinding, ...verified };
|
|
64
71
|
},
|
|
65
72
|
async finalizePurchaseGrant(finalizationInput) {
|
|
66
73
|
if (finalizationInput.product.type === 'subscription') {
|
|
@@ -318,6 +325,7 @@ async function inspectGooglePlayPurchase(input) {
|
|
|
318
325
|
&& obfuscatedExternalProfileId === undefined) {
|
|
319
326
|
return rejected('GOOGLE_PLAY_PROFILE_INVALID');
|
|
320
327
|
}
|
|
328
|
+
let foreignBinding;
|
|
321
329
|
if (input.accountBinding.status === 'bound') {
|
|
322
330
|
// Defensive invariant: every bound context must carry at least one
|
|
323
331
|
// identifier, even though the constructors below already enforce it.
|
|
@@ -325,19 +333,26 @@ async function inspectGooglePlayPurchase(input) {
|
|
|
325
333
|
&& input.accountBinding.profileId === undefined) {
|
|
326
334
|
return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_REQUIRED');
|
|
327
335
|
}
|
|
336
|
+
// A purchase made outside the app (a promo code) carries no ids at all
|
|
337
|
+
// ("purchases made outside your app won't have these identifiers set").
|
|
328
338
|
if (input.accountBinding.accountId !== undefined
|
|
329
339
|
&& obfuscatedExternalAccountId !== input.accountBinding.accountId) {
|
|
330
|
-
|
|
340
|
+
foreignBinding = 'GOOGLE_PLAY_ACCOUNT_MISMATCH';
|
|
331
341
|
}
|
|
332
|
-
if (input.accountBinding.profileId !== undefined
|
|
342
|
+
else if (input.accountBinding.profileId !== undefined
|
|
333
343
|
&& obfuscatedExternalProfileId !== input.accountBinding.profileId) {
|
|
334
|
-
|
|
344
|
+
foreignBinding = 'GOOGLE_PLAY_PROFILE_MISMATCH';
|
|
345
|
+
}
|
|
346
|
+
if (foreignBinding !== undefined && input.reportForeignBinding !== true) {
|
|
347
|
+
return rejected(foreignBinding);
|
|
335
348
|
}
|
|
336
|
-
if (
|
|
349
|
+
if (foreignBinding === undefined
|
|
350
|
+
&& input.accountBinding.profileId === undefined
|
|
337
351
|
&& obfuscatedExternalProfileId !== undefined) {
|
|
338
352
|
return rejected('GOOGLE_PLAY_PROFILE_BINDING_REQUIRED');
|
|
339
353
|
}
|
|
340
|
-
if (
|
|
354
|
+
if (foreignBinding === undefined
|
|
355
|
+
&& input.accountBinding.accountId === undefined
|
|
341
356
|
&& obfuscatedExternalAccountId !== undefined) {
|
|
342
357
|
return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_REQUIRED');
|
|
343
358
|
}
|
|
@@ -350,6 +365,7 @@ async function inspectGooglePlayPurchase(input) {
|
|
|
350
365
|
}
|
|
351
366
|
return {
|
|
352
367
|
status: 'verified',
|
|
368
|
+
...(foreignBinding === undefined ? {} : { foreignBinding }),
|
|
353
369
|
purchase: {
|
|
354
370
|
purchaseToken: input.purchaseToken,
|
|
355
371
|
purchaseTokenDigest: await digestPurchaseToken(input.packageName, input.purchaseToken),
|
|
@@ -545,6 +561,15 @@ async function digestPurchaseToken(packageName, purchaseToken) {
|
|
|
545
561
|
const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
|
|
546
562
|
return [...digest].map((value) => value.toString(16).padStart(2, '0')).join('');
|
|
547
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* The ledger's authority identity of a Play one-time purchase: what
|
|
566
|
+
* `verifyPurchase` records as `evidenceVerificationId`. Store-side handlers
|
|
567
|
+
* (voided purchases, RTDN) find the grant of a purchase token with it, so the
|
|
568
|
+
* raw token never needs to be stored in the ledger.
|
|
569
|
+
*/
|
|
570
|
+
export async function createGooglePlayProductVerificationId(packageName, purchaseToken) {
|
|
571
|
+
return createGooglePlayVerificationId(await digestPurchaseToken(packageName, purchaseToken));
|
|
572
|
+
}
|
|
548
573
|
function createGooglePlayVerificationId(purchaseTokenDigest) {
|
|
549
574
|
return `google-play:product:${purchaseTokenDigest}`;
|
|
550
575
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { GameServicesPurchaseGrantFinalizer } from './evidence-verification.js';
|
|
2
|
+
import type { GameServicesStore } from './server.js';
|
|
3
|
+
import type { PurchaseGrantFinalization } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Server-side Google Play upkeep that does not wait for the game client:
|
|
6
|
+
* revoking grants of voided purchases (refunds, chargebacks, Play's own
|
|
7
|
+
* refund of a purchase left unacknowledged) and retrying the acknowledge or
|
|
8
|
+
* consume of a grant whose client went away. Both read the ledger the
|
|
9
|
+
* purchase verifier wrote; neither grants anything.
|
|
10
|
+
*
|
|
11
|
+
* - Voided Purchases API: https://developers.google.com/android-publisher/voided-purchases
|
|
12
|
+
* - Three-day acknowledgement window: https://developer.android.com/google/play/billing/integrate#process
|
|
13
|
+
*/
|
|
14
|
+
/** One entry of `purchases.voidedpurchases.list` (in-app products, `type=0`). */
|
|
15
|
+
export interface GooglePlayVoidedPurchase {
|
|
16
|
+
readonly purchaseToken: string;
|
|
17
|
+
readonly orderId?: string;
|
|
18
|
+
/** Milliseconds since the epoch, as Google's int64 string or a number. */
|
|
19
|
+
readonly voidedTimeMillis?: string | number;
|
|
20
|
+
readonly purchaseTimeMillis?: string | number;
|
|
21
|
+
/** 0 user, 1 developer, 2 Google. */
|
|
22
|
+
readonly voidedSource?: string | number;
|
|
23
|
+
/**
|
|
24
|
+
* 0 other, 1 remorse, 2 not received, 3 defective, 4 accidental purchase,
|
|
25
|
+
* 5 fraud, 6 friendly fraud, 7 chargeback, 8 unacknowledged purchase.
|
|
26
|
+
*/
|
|
27
|
+
readonly voidedReason?: string | number;
|
|
28
|
+
}
|
|
29
|
+
export interface GooglePlayVoidedPurchasesPage {
|
|
30
|
+
readonly voidedPurchases: readonly GooglePlayVoidedPurchase[];
|
|
31
|
+
readonly nextPageToken?: string;
|
|
32
|
+
/** Entries of the page the client could not read (no purchase token); reported, never revoked. */
|
|
33
|
+
readonly droppedEntries?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface GooglePlayVoidedPurchasesClient {
|
|
36
|
+
/**
|
|
37
|
+
* One page of voided in-app purchases. `startTimeMillis` and
|
|
38
|
+
* `endTimeMillis` filter on when Google recorded the void, at most 30 days
|
|
39
|
+
* back; Google ignores them when `pageToken` is set.
|
|
40
|
+
*/
|
|
41
|
+
listVoidedPurchases(input: {
|
|
42
|
+
readonly packageName: string;
|
|
43
|
+
readonly startTimeMillis: number;
|
|
44
|
+
readonly endTimeMillis: number;
|
|
45
|
+
readonly pageToken?: string;
|
|
46
|
+
readonly maxResults?: number;
|
|
47
|
+
readonly signal: AbortSignal;
|
|
48
|
+
}): Promise<GooglePlayVoidedPurchasesPage>;
|
|
49
|
+
}
|
|
50
|
+
export type GooglePlayVoidedPurchaseOutcome = {
|
|
51
|
+
readonly status: 'revoked';
|
|
52
|
+
readonly ledgerEntryId: string;
|
|
53
|
+
readonly alreadyProcessed: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** No purchase grant of this token on this ledger (granted elsewhere, or never). */
|
|
56
|
+
| {
|
|
57
|
+
readonly status: 'grant_not_found';
|
|
58
|
+
}
|
|
59
|
+
/** The ledger refused the revocation; logged, not retried. */
|
|
60
|
+
| {
|
|
61
|
+
readonly status: 'conflict';
|
|
62
|
+
readonly reason: string;
|
|
63
|
+
} | {
|
|
64
|
+
readonly status: 'invalid';
|
|
65
|
+
readonly reason: string;
|
|
66
|
+
};
|
|
67
|
+
/** Google keeps voided purchases for 30 days. */
|
|
68
|
+
export declare const googlePlayVoidedPurchasesWindowMs: number;
|
|
69
|
+
/**
|
|
70
|
+
* Kept off the oldest edge of that window: Google refuses a `startTime` older
|
|
71
|
+
* than 30 days at the time it receives the request, which is a little after
|
|
72
|
+
* the caller's clock read.
|
|
73
|
+
*/
|
|
74
|
+
export declare const googlePlayVoidedPurchasesWindowSlackMs = 60000;
|
|
75
|
+
/**
|
|
76
|
+
* Appends the `revoke` entry for one voided purchase. The grant is found by
|
|
77
|
+
* the purchase token's verification id, so the revocation is keyed by the
|
|
78
|
+
* token: a purchase seen again by a later sweep (or an RTDN message) answers
|
|
79
|
+
* the first entry with `alreadyProcessed`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function revokeGooglePlayVoidedPurchase(input: {
|
|
82
|
+
readonly store: GameServicesStore;
|
|
83
|
+
readonly packageName: string;
|
|
84
|
+
readonly voided: GooglePlayVoidedPurchase;
|
|
85
|
+
readonly now?: () => string;
|
|
86
|
+
}): Promise<GooglePlayVoidedPurchaseOutcome>;
|
|
87
|
+
export interface SweepGooglePlayVoidedPurchasesInput {
|
|
88
|
+
readonly client: GooglePlayVoidedPurchasesClient;
|
|
89
|
+
readonly store: GameServicesStore;
|
|
90
|
+
readonly packageName: string;
|
|
91
|
+
/** Raised to Google's 30-day window, measured from `now` (`clamped` reports it). */
|
|
92
|
+
readonly startTimeMillis: number;
|
|
93
|
+
readonly endTimeMillis: number;
|
|
94
|
+
readonly signal: AbortSignal;
|
|
95
|
+
/**
|
|
96
|
+
* Pages read before the sweep stops as incomplete; 10 by default. A window
|
|
97
|
+
* that does not fit is not finished by repeating it (the listing does not
|
|
98
|
+
* shrink as grants are revoked): raise the cap or split the window into
|
|
99
|
+
* shorter ones.
|
|
100
|
+
*/
|
|
101
|
+
readonly maxPages?: number;
|
|
102
|
+
/** Asked before every page and voided purchase; `false` stops the sweep as incomplete. */
|
|
103
|
+
readonly shouldContinue?: (next: 'page' | 'purchase') => boolean;
|
|
104
|
+
/** The clock, for the revocation time of a void without one and the 30-day window. */
|
|
105
|
+
readonly now?: () => string;
|
|
106
|
+
/** Every voided purchase's outcome; the purchase token is left out so it can be logged. */
|
|
107
|
+
readonly onOutcome?: (voided: Omit<GooglePlayVoidedPurchase, 'purchaseToken'>, outcome: GooglePlayVoidedPurchaseOutcome | {
|
|
108
|
+
readonly status: 'error';
|
|
109
|
+
readonly reason: 'GOOGLE_PLAY_VOIDED_STORE_ERROR';
|
|
110
|
+
/** The error's name and message; store errors carry no purchase token. */
|
|
111
|
+
readonly detail: string;
|
|
112
|
+
}) => void;
|
|
113
|
+
}
|
|
114
|
+
export interface GooglePlayVoidedPurchasesSweep {
|
|
115
|
+
/**
|
|
116
|
+
* Every page in the window was read and every entry applied or refused;
|
|
117
|
+
* the caller may advance its cursor to `endTimeMillis`. A store error on
|
|
118
|
+
* an entry (`errored`) leaves the sweep incomplete, since reading the window
|
|
119
|
+
* again retries it. Entries counted in `failed` are not retried that way:
|
|
120
|
+
* they need an operator (see `onOutcome`).
|
|
121
|
+
*/
|
|
122
|
+
readonly complete: boolean;
|
|
123
|
+
/** The requested start was older than Google keeps and was raised: voids before it are gone. */
|
|
124
|
+
readonly clamped: boolean;
|
|
125
|
+
readonly pages: number;
|
|
126
|
+
readonly revoked: number;
|
|
127
|
+
readonly alreadyProcessed: number;
|
|
128
|
+
readonly notFound: number;
|
|
129
|
+
/** Ledger refusals and unreadable entries. */
|
|
130
|
+
readonly failed: number;
|
|
131
|
+
/** Entries a store error kept from being applied; the window must be read again. */
|
|
132
|
+
readonly errored: number;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Reads the voided purchases of one time window and revokes their grants.
|
|
136
|
+
* The sweep is the reliable path even next to RTDN, because a Pub/Sub push
|
|
137
|
+
* can be missed: run it on a schedule with a window that overlaps the last
|
|
138
|
+
* complete one (revocations are idempotent per token). An incomplete sweep
|
|
139
|
+
* (page budget, `shouldContinue`) must be repeated from the same start.
|
|
140
|
+
*/
|
|
141
|
+
export declare function sweepGooglePlayVoidedPurchases(input: SweepGooglePlayVoidedPurchasesInput): Promise<GooglePlayVoidedPurchasesSweep>;
|
|
142
|
+
/**
|
|
143
|
+
* Retries the acknowledge or consume of a durable Play grant from the ledger
|
|
144
|
+
* entry and the purchase token the Worker kept for it (the ledger itself
|
|
145
|
+
* stores only the token digest). The finalizer is the same Play boundary the
|
|
146
|
+
* purchase path uses, so the provider state is re-read before acting and an
|
|
147
|
+
* already completed purchase answers `completed`. A grant that was reversed
|
|
148
|
+
* is never finalized: Google is refunding it.
|
|
149
|
+
*/
|
|
150
|
+
export declare function retryGooglePlayPurchaseFinalization(input: {
|
|
151
|
+
readonly finalizer: GameServicesPurchaseGrantFinalizer;
|
|
152
|
+
readonly store: GameServicesStore;
|
|
153
|
+
readonly ledgerEntryId: string;
|
|
154
|
+
readonly purchaseToken: string;
|
|
155
|
+
readonly signal: AbortSignal;
|
|
156
|
+
readonly timeoutMs: number;
|
|
157
|
+
}): Promise<PurchaseGrantFinalization | {
|
|
158
|
+
readonly status: 'abandoned';
|
|
159
|
+
readonly reason: string;
|
|
160
|
+
}>;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
|
|
2
|
+
import { findPurchaseGrantByEvidenceVerificationId, isEntitlementGrantReversed, readPurchaseVerificationId, revokeEntitlementGrant, } from './commerce-ledger.js';
|
|
3
|
+
import { createGooglePlayProductVerificationId, googlePlayProductPurchaseEvidenceSchema, } from './google-play-purchase.js';
|
|
4
|
+
/** Google keeps voided purchases for 30 days. */
|
|
5
|
+
export const googlePlayVoidedPurchasesWindowMs = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Kept off the oldest edge of that window: Google refuses a `startTime` older
|
|
8
|
+
* than 30 days at the time it receives the request, which is a little after
|
|
9
|
+
* the caller's clock read.
|
|
10
|
+
*/
|
|
11
|
+
export const googlePlayVoidedPurchasesWindowSlackMs = 60_000;
|
|
12
|
+
/**
|
|
13
|
+
* Appends the `revoke` entry for one voided purchase. The grant is found by
|
|
14
|
+
* the purchase token's verification id, so the revocation is keyed by the
|
|
15
|
+
* token: a purchase seen again by a later sweep (or an RTDN message) answers
|
|
16
|
+
* the first entry with `alreadyProcessed`.
|
|
17
|
+
*/
|
|
18
|
+
export async function revokeGooglePlayVoidedPurchase(input) {
|
|
19
|
+
const { voided } = input;
|
|
20
|
+
if (typeof voided?.purchaseToken !== 'string' || voided.purchaseToken.length === 0) {
|
|
21
|
+
return { status: 'invalid', reason: 'GOOGLE_PLAY_VOIDED_PURCHASE_TOKEN_MISSING' };
|
|
22
|
+
}
|
|
23
|
+
const verificationId = await createGooglePlayProductVerificationId(input.packageName, voided.purchaseToken);
|
|
24
|
+
const grant = await findPurchaseGrantByEvidenceVerificationId(input.store, verificationId);
|
|
25
|
+
if (grant === undefined || grant.payload.target !== 'android') {
|
|
26
|
+
return { status: 'grant_not_found' };
|
|
27
|
+
}
|
|
28
|
+
const voidedAt = readMillis(voided.voidedTimeMillis);
|
|
29
|
+
const voidedReason = readCode(voided.voidedReason);
|
|
30
|
+
const voidedSource = readCode(voided.voidedSource);
|
|
31
|
+
const result = await revokeEntitlementGrant(input.store, {
|
|
32
|
+
playerId: grant.playerId,
|
|
33
|
+
revokesLedgerEntryId: grant.ledgerEntryId,
|
|
34
|
+
idempotencyKey: `google-play:voided:${verificationId}`,
|
|
35
|
+
reason: revocationReasonOf(voidedReason),
|
|
36
|
+
revokedAt: voidedAt === undefined
|
|
37
|
+
? (input.now ?? (() => new Date().toISOString()))()
|
|
38
|
+
: new Date(voidedAt).toISOString(),
|
|
39
|
+
evidenceVerificationId: `${verificationId}:revoke`,
|
|
40
|
+
payload: {
|
|
41
|
+
target: 'android',
|
|
42
|
+
store: 'google-play',
|
|
43
|
+
googlePlayPackageName: input.packageName,
|
|
44
|
+
...(voided.orderId === undefined ? {} : { googlePlayOrderId: voided.orderId }),
|
|
45
|
+
...(voidedReason === undefined ? {} : { googlePlayVoidedReason: voidedReason }),
|
|
46
|
+
...(voidedSource === undefined ? {} : { googlePlayVoidedSource: voidedSource }),
|
|
47
|
+
...(voidedAt === undefined ? {} : { googlePlayVoidedTimeMillis: voidedAt }),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (result.status === 'revoked') {
|
|
51
|
+
return {
|
|
52
|
+
status: 'revoked',
|
|
53
|
+
ledgerEntryId: result.ledgerEntryId,
|
|
54
|
+
alreadyProcessed: result.alreadyProcessed,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (result.status === 'grant_not_found') {
|
|
58
|
+
return { status: 'grant_not_found' };
|
|
59
|
+
}
|
|
60
|
+
return { status: 'conflict', reason: `GOOGLE_PLAY_VOIDED_${result.status.toUpperCase()}` };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Reads the voided purchases of one time window and revokes their grants.
|
|
64
|
+
* The sweep is the reliable path even next to RTDN, because a Pub/Sub push
|
|
65
|
+
* can be missed: run it on a schedule with a window that overlaps the last
|
|
66
|
+
* complete one (revocations are idempotent per token). An incomplete sweep
|
|
67
|
+
* (page budget, `shouldContinue`) must be repeated from the same start.
|
|
68
|
+
*/
|
|
69
|
+
export async function sweepGooglePlayVoidedPurchases(input) {
|
|
70
|
+
const maxPages = input.maxPages ?? 10;
|
|
71
|
+
if (!Number.isSafeInteger(maxPages) || maxPages < 1) {
|
|
72
|
+
throw new RangeError('maxPages must be a positive integer.');
|
|
73
|
+
}
|
|
74
|
+
const requestedStart = input.startTimeMillis;
|
|
75
|
+
const endTimeMillis = input.endTimeMillis;
|
|
76
|
+
const nowMillis = input.now === undefined ? Date.now() : Date.parse(input.now());
|
|
77
|
+
if (!Number.isSafeInteger(requestedStart) || !Number.isSafeInteger(endTimeMillis)) {
|
|
78
|
+
throw new RangeError('The voided purchases window must be integer milliseconds.');
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isFinite(nowMillis)) {
|
|
81
|
+
throw new RangeError('now() must return an ISO timestamp.');
|
|
82
|
+
}
|
|
83
|
+
const oldest = nowMillis - googlePlayVoidedPurchasesWindowMs + googlePlayVoidedPurchasesWindowSlackMs;
|
|
84
|
+
const startTimeMillis = Math.max(requestedStart, oldest);
|
|
85
|
+
if (startTimeMillis > endTimeMillis) {
|
|
86
|
+
throw new RangeError('The voided purchases window is empty or invalid.');
|
|
87
|
+
}
|
|
88
|
+
const clamped = startTimeMillis > requestedStart;
|
|
89
|
+
const shouldContinue = input.shouldContinue ?? (() => true);
|
|
90
|
+
const totals = {
|
|
91
|
+
pages: 0,
|
|
92
|
+
revoked: 0,
|
|
93
|
+
alreadyProcessed: 0,
|
|
94
|
+
notFound: 0,
|
|
95
|
+
failed: 0,
|
|
96
|
+
errored: 0,
|
|
97
|
+
};
|
|
98
|
+
let pageToken;
|
|
99
|
+
do {
|
|
100
|
+
if (totals.pages >= maxPages || !shouldContinue('page')) {
|
|
101
|
+
return { complete: false, clamped, ...totals };
|
|
102
|
+
}
|
|
103
|
+
const page = await input.client.listVoidedPurchases({
|
|
104
|
+
packageName: input.packageName,
|
|
105
|
+
startTimeMillis,
|
|
106
|
+
endTimeMillis,
|
|
107
|
+
...(pageToken === undefined ? {} : { pageToken }),
|
|
108
|
+
signal: input.signal,
|
|
109
|
+
});
|
|
110
|
+
totals.pages += 1;
|
|
111
|
+
totals.failed += page.droppedEntries ?? 0;
|
|
112
|
+
for (const voided of page.voidedPurchases) {
|
|
113
|
+
if (!shouldContinue('purchase')) {
|
|
114
|
+
return { complete: false, clamped, ...totals };
|
|
115
|
+
}
|
|
116
|
+
const { purchaseToken: _token, ...loggable } = voided;
|
|
117
|
+
let outcome;
|
|
118
|
+
try {
|
|
119
|
+
outcome = await revokeGooglePlayVoidedPurchase({
|
|
120
|
+
store: input.store,
|
|
121
|
+
packageName: input.packageName,
|
|
122
|
+
voided,
|
|
123
|
+
...(input.now === undefined ? {} : { now: input.now }),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (input.signal.aborted) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
// One entry's store failure does not hide the rest of the window.
|
|
131
|
+
totals.errored += 1;
|
|
132
|
+
input.onOutcome?.(loggable, {
|
|
133
|
+
status: 'error',
|
|
134
|
+
reason: 'GOOGLE_PLAY_VOIDED_STORE_ERROR',
|
|
135
|
+
detail: error instanceof Error
|
|
136
|
+
? `${error.name}: ${error.message}`.slice(0, 200)
|
|
137
|
+
: 'unknown error',
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (outcome.status === 'revoked') {
|
|
142
|
+
totals[outcome.alreadyProcessed ? 'alreadyProcessed' : 'revoked'] += 1;
|
|
143
|
+
}
|
|
144
|
+
else if (outcome.status === 'grant_not_found') {
|
|
145
|
+
totals.notFound += 1;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
totals.failed += 1;
|
|
149
|
+
}
|
|
150
|
+
input.onOutcome?.(loggable, outcome);
|
|
151
|
+
}
|
|
152
|
+
pageToken = page.nextPageToken === undefined || page.nextPageToken.length === 0
|
|
153
|
+
? undefined
|
|
154
|
+
: page.nextPageToken;
|
|
155
|
+
} while (pageToken !== undefined);
|
|
156
|
+
return { complete: totals.errored === 0, clamped, ...totals };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Retries the acknowledge or consume of a durable Play grant from the ledger
|
|
160
|
+
* entry and the purchase token the Worker kept for it (the ledger itself
|
|
161
|
+
* stores only the token digest). The finalizer is the same Play boundary the
|
|
162
|
+
* purchase path uses, so the provider state is re-read before acting and an
|
|
163
|
+
* already completed purchase answers `completed`. A grant that was reversed
|
|
164
|
+
* is never finalized: Google is refunding it.
|
|
165
|
+
*/
|
|
166
|
+
export async function retryGooglePlayPurchaseFinalization(input) {
|
|
167
|
+
const grant = await input.store.getEntitlementTransaction(input.ledgerEntryId);
|
|
168
|
+
if (grant === undefined || grant.source !== 'purchase' || grant.payload.target !== 'android') {
|
|
169
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_GRANT_NOT_FOUND' };
|
|
170
|
+
}
|
|
171
|
+
if (await isEntitlementGrantReversed(input.store, grant.ledgerEntryId)) {
|
|
172
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_GRANT_REVERSED' };
|
|
173
|
+
}
|
|
174
|
+
const payload = grant.payload;
|
|
175
|
+
const productType = readOneTimeProductType(payload.productType);
|
|
176
|
+
const platformProductId = payload.platformProductId;
|
|
177
|
+
// An orderless purchase (Google omitted the order id) was recorded without
|
|
178
|
+
// one; the boundary matches it by token, as the client's token key does.
|
|
179
|
+
const platformTransactionId = typeof payload.platformTransactionId === 'string'
|
|
180
|
+
&& payload.platformTransactionId.length > 0
|
|
181
|
+
? payload.platformTransactionId
|
|
182
|
+
: await createGooglePlayTokenPurchaseKey(input.purchaseToken);
|
|
183
|
+
const purchasedAt = payload.purchasedAt;
|
|
184
|
+
// A grant restore sharing wrote is acknowledged as the purchase it records.
|
|
185
|
+
const evidenceVerificationId = readPurchaseVerificationId(grant);
|
|
186
|
+
if (productType === undefined
|
|
187
|
+
|| typeof platformProductId !== 'string' || platformProductId.length === 0
|
|
188
|
+
|| typeof purchasedAt !== 'string' || purchasedAt.length === 0
|
|
189
|
+
|| typeof evidenceVerificationId !== 'string'
|
|
190
|
+
|| grant.grant === undefined) {
|
|
191
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_CONTEXT_UNAVAILABLE' };
|
|
192
|
+
}
|
|
193
|
+
if (evidenceVerificationId
|
|
194
|
+
!== await createGooglePlayProductVerificationId(typeof payload.googlePlayPackageName === 'string' ? payload.googlePlayPackageName : '', input.purchaseToken)) {
|
|
195
|
+
// The kept token is not the one this grant was verified with.
|
|
196
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_EVIDENCE_MISMATCH' };
|
|
197
|
+
}
|
|
198
|
+
const deploymentTarget = typeof payload.deploymentTarget === 'string'
|
|
199
|
+
? payload.deploymentTarget
|
|
200
|
+
: undefined;
|
|
201
|
+
const store = typeof payload.store === 'string' ? payload.store : undefined;
|
|
202
|
+
const request = {
|
|
203
|
+
target: 'android',
|
|
204
|
+
...(store === undefined ? {} : { store }),
|
|
205
|
+
...(deploymentTarget === undefined ? {} : { deploymentTarget }),
|
|
206
|
+
playerId: grant.playerId,
|
|
207
|
+
productId: grant.grantId,
|
|
208
|
+
platformTransactionId,
|
|
209
|
+
idempotencyKey: grant.idempotencyKey,
|
|
210
|
+
purchasedAt,
|
|
211
|
+
evidence: {
|
|
212
|
+
schema: googlePlayProductPurchaseEvidenceSchema,
|
|
213
|
+
payload: { purchaseToken: input.purchaseToken },
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const product = {
|
|
217
|
+
id: grant.grantId,
|
|
218
|
+
type: productType,
|
|
219
|
+
grant: grant.grant,
|
|
220
|
+
platformProductIds: { android: platformProductId },
|
|
221
|
+
};
|
|
222
|
+
const finalizationInput = {
|
|
223
|
+
request,
|
|
224
|
+
product,
|
|
225
|
+
platformProductId,
|
|
226
|
+
evidenceVerificationId,
|
|
227
|
+
evidencePayload: payload,
|
|
228
|
+
ledgerEntryId: grant.ledgerEntryId,
|
|
229
|
+
alreadyProcessed: true,
|
|
230
|
+
timeoutMs: input.timeoutMs,
|
|
231
|
+
};
|
|
232
|
+
if (input.finalizer.supportsPurchaseGrant?.(finalizationInput) === false) {
|
|
233
|
+
return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_UNSUPPORTED' };
|
|
234
|
+
}
|
|
235
|
+
const result = await finalizeWithin(input.finalizer, finalizationInput, input.signal, input.timeoutMs);
|
|
236
|
+
if (result.status === 'pending' && result.reason === 'GOOGLE_PLAY_FINALIZATION_EVIDENCE_MISMATCH') {
|
|
237
|
+
// The boundary is configured for another package than the grant: no
|
|
238
|
+
// retry can succeed.
|
|
239
|
+
return { status: 'abandoned', reason: result.reason };
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Runs the finalizer within `timeoutMs` like the purchase path does: the
|
|
245
|
+
* provider signal is aborted at the deadline and the retry answers pending,
|
|
246
|
+
* even when a provider call ignores the abort.
|
|
247
|
+
*/
|
|
248
|
+
async function finalizeWithin(finalizer, input, signal, timeoutMs) {
|
|
249
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
250
|
+
throw new RangeError('timeoutMs must be a positive integer.');
|
|
251
|
+
}
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const abort = () => controller.abort(signal.reason);
|
|
254
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
255
|
+
if (signal.aborted) {
|
|
256
|
+
abort();
|
|
257
|
+
}
|
|
258
|
+
let timer;
|
|
259
|
+
try {
|
|
260
|
+
return await Promise.race([
|
|
261
|
+
finalizer.finalizePurchaseGrant({ ...input, signal: controller.signal }),
|
|
262
|
+
new Promise((resolve) => {
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
controller.abort();
|
|
265
|
+
resolve({
|
|
266
|
+
status: 'pending',
|
|
267
|
+
alreadyCompleted: false,
|
|
268
|
+
reason: 'GOOGLE_PLAY_FINALIZATION_TIMEOUT',
|
|
269
|
+
});
|
|
270
|
+
}, timeoutMs);
|
|
271
|
+
}),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
if (timer !== undefined) {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
}
|
|
278
|
+
signal.removeEventListener('abort', abort);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function readOneTimeProductType(input) {
|
|
282
|
+
return input === 'consumable' || input === 'non_consumable' ? input : undefined;
|
|
283
|
+
}
|
|
284
|
+
function revocationReasonOf(voidedReason) {
|
|
285
|
+
return voidedReason === 7 ? 'chargeback' : 'voided';
|
|
286
|
+
}
|
|
287
|
+
function readMillis(input) {
|
|
288
|
+
const value = typeof input === 'string' && /^[0-9]{1,16}$/u.test(input) ? Number(input) : input;
|
|
289
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
290
|
+
&& value <= 8_640_000_000_000_000
|
|
291
|
+
? value
|
|
292
|
+
: undefined;
|
|
293
|
+
}
|
|
294
|
+
function readCode(input) {
|
|
295
|
+
const value = typeof input === 'string' && /^[0-9]{1,3}$/u.test(input) ? Number(input) : input;
|
|
296
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
297
|
+
}
|