@mpgd/game-services 0.28.1 → 0.30.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.
Files changed (44) hide show
  1. package/README.md +18 -3
  2. package/dist/account-deletion.d.ts +306 -0
  3. package/dist/account-deletion.js +491 -0
  4. package/dist/app-store-recovery.d.ts +6 -0
  5. package/dist/app-store-recovery.js +26 -16
  6. package/dist/app-store-server-notifications.d.ts +158 -0
  7. package/dist/app-store-server-notifications.js +270 -0
  8. package/dist/app-store-server.d.ts +8 -0
  9. package/dist/app-store-server.js +135 -37
  10. package/dist/app-store-verifier.d.ts +34 -0
  11. package/dist/app-store-verifier.js +161 -31
  12. package/dist/commerce-ledger.d.ts +55 -0
  13. package/dist/commerce-ledger.js +198 -0
  14. package/dist/commerce-reconciler.js +47 -7
  15. package/dist/evidence-verification.d.ts +19 -0
  16. package/dist/google-play-publisher-client.d.ts +2 -1
  17. package/dist/google-play-publisher-client.js +80 -0
  18. package/dist/google-play-purchase.d.ts +7 -0
  19. package/dist/google-play-purchase.js +32 -7
  20. package/dist/google-play-store-server.d.ts +160 -0
  21. package/dist/google-play-store-server.js +297 -0
  22. package/dist/guest-session.d.ts +112 -2
  23. package/dist/guest-session.js +196 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/notifications/delivery-provider.d.ts +7 -0
  27. package/dist/notifications/fcm.d.ts +30 -3
  28. package/dist/notifications/fcm.js +72 -13
  29. package/dist/notifications/service.js +33 -5
  30. package/dist/notifications/store-conformance.js +7 -1
  31. package/dist/notifications/store.d.ts +9 -0
  32. package/dist/notifications/store.js +41 -0
  33. package/dist/operation-results.d.ts +7 -0
  34. package/dist/purchase-sharing.d.ts +41 -0
  35. package/dist/purchase-sharing.js +37 -0
  36. package/dist/runtime.d.ts +6 -0
  37. package/dist/runtime.js +13 -0
  38. package/dist/server.d.ts +60 -1
  39. package/dist/server.js +536 -42
  40. package/dist/types.d.ts +30 -0
  41. package/dist/types.js +22 -0
  42. package/dist/verified-leaderboard.d.ts +13 -0
  43. package/dist/verified-leaderboard.js +31 -0
  44. package/package.json +17 -5
@@ -461,22 +461,49 @@ export function createCommerceReconciler(input) {
461
461
  trigger();
462
462
  scheduleRun();
463
463
  };
464
- const readAvailable = async (capabilitiesNow) => {
464
+ /**
465
+ * Reads the store's available purchases into the inbox. `restore` is the
466
+ * player's Restore Purchases read only: the store also lists owned
467
+ * non-consumables its recovery list hides (acknowledged on Play, bound to
468
+ * another guest's token on StoreKit), so a reinstall or a new device can
469
+ * restore them. Startup and resume recovery never set it, so an
470
+ * acknowledged durable is not sent again on every launch.
471
+ */
472
+ const readAvailable = async (capabilitiesNow, restore = false) => {
465
473
  if (!capabilitiesNow.lifecycle.availablePurchases) {
466
474
  return true;
467
475
  }
468
- try {
476
+ const read = async (withRestore) => {
469
477
  const available = await provider.getAvailablePurchases({
470
478
  includePending: capabilitiesNow.lifecycle.pending,
479
+ ...(withRestore ? { restore: true } : {}),
471
480
  });
472
481
  for (const purchase of available) {
473
482
  inbox.push({ type: 'purchaseUpdated', purchase });
474
483
  }
484
+ };
485
+ try {
486
+ await read(restore);
475
487
  return true;
476
488
  }
477
489
  catch (error) {
490
+ if (!restore) {
491
+ fail('available', error);
492
+ return false;
493
+ }
494
+ // A failed restore read still makes the recovery read, so Restore
495
+ // Purchases never finds less than a recovery pass: a bridge host built
496
+ // before `restore` rejects the field, and a store may fail only the
497
+ // wider read. The restore failure is still reported.
478
498
  fail('available', error);
479
- return false;
499
+ try {
500
+ await read(false);
501
+ return true;
502
+ }
503
+ catch (fallbackError) {
504
+ fail('available', fallbackError);
505
+ return false;
506
+ }
480
507
  }
481
508
  };
482
509
  const buildRequest = (record) => {
@@ -497,6 +524,11 @@ export function createCommerceReconciler(input) {
497
524
  idempotencyKey: record.idempotencyKey,
498
525
  purchasedAt: purchase.purchasedAt ?? now(),
499
526
  evidence: purchase.evidence,
527
+ // A purchase the store reported without this journal starting it (a
528
+ // reinstall, a new device, Restore Purchases) is a restore: the backend
529
+ // may grant this player a non-consumable bound to another guest (RFC
530
+ // "Restore sharing"). A purchase the game started never asks for it.
531
+ ...(record.origin === 'discovered' ? { intent: 'restore' } : {}),
500
532
  };
501
533
  };
502
534
  // Reconciler step 2: match a reported purchase to a journal record.
@@ -847,7 +879,8 @@ export function createCommerceReconciler(input) {
847
879
  // read again until it succeeds, so a purchase that predates the
848
880
  // subscription is not missed for the session.
849
881
  if (capabilitiesNow !== undefined && (!capabilitiesNow.lifecycle.events || initialScanPending)) {
850
- initialScanPending = !(await readAvailable(capabilitiesNow)) && capabilitiesNow.lifecycle.events;
882
+ initialScanPending = !(await readAvailable(capabilitiesNow, options.restore === true))
883
+ && capabilitiesNow.lifecycle.events;
851
884
  }
852
885
  // An event for a product whose store call is still running waits for that
853
886
  // call to journal the store answer: matched then, it is never journaled
@@ -1292,11 +1325,18 @@ export function createCommerceReconciler(input) {
1292
1325
  await loadRecords();
1293
1326
  }
1294
1327
  const capabilitiesNow = await readCapabilities(true);
1295
- // A polled provider is read by the pass itself.
1328
+ // A polled provider is read by the pass itself. Either read is a
1329
+ // restore read: owned non-consumables the store's recovery list
1330
+ // hides (a reinstall, a new device) reach the backend as restores.
1331
+ let restoreRead = false;
1296
1332
  if (capabilitiesNow !== undefined && capabilitiesNow.lifecycle.events) {
1297
- await readAvailable(capabilitiesNow);
1333
+ restoreRead = await readAvailable(capabilitiesNow, true);
1334
+ if (restoreRead) {
1335
+ // This read covers a start-time read that failed.
1336
+ initialScanPending = false;
1337
+ }
1298
1338
  }
1299
- await pass({ explicit: true, refreshCapabilities: false });
1339
+ await pass({ explicit: true, refreshCapabilities: false, restore: !restoreRead });
1300
1340
  });
1301
1341
  await runUntilIdle();
1302
1342
  },
@@ -24,6 +24,25 @@ export type EvidenceVerificationDecision = {
24
24
  readonly ledgerEntryId: string;
25
25
  /** The ledger target the entry was written under when it is not the request's target. */
26
26
  readonly ledgerTarget?: string;
27
+ } | {
28
+ /**
29
+ * The store verified a restored non-consumable, but its account
30
+ * binding (Google Play `obfuscatedAccountId` / `obfuscatedProfileId`,
31
+ * StoreKit `appAccountToken`) names another account than the
32
+ * requesting player's, or none: the player reinstalled or moved to a
33
+ * new device and is a new guest (RFC "Restore sharing"). Every other
34
+ * check passed. The reference verifiers answer it only for a request
35
+ * with `intent: 'restore'` of a non-consumable; the backend then grants
36
+ * the player its own share under its `restoreSharing` policy, or
37
+ * answers `rejected` with `reason` (the binding mismatch) when the
38
+ * policy refuses. Fields are those of `verified`.
39
+ */
40
+ readonly status: 'restorable';
41
+ readonly reason: string;
42
+ readonly verificationId: string;
43
+ readonly verifiedAt: string;
44
+ readonly payload?: EntitlementLedgerPayload;
45
+ readonly platformEvidenceId?: string | null;
27
46
  } | {
28
47
  readonly status: 'pending';
29
48
  readonly reason?: string;
@@ -1,4 +1,5 @@
1
1
  import type { GooglePlayProductPurchaseClient } from './google-play-purchase.js';
2
+ import type { GooglePlayVoidedPurchasesClient } from './google-play-store-server.js';
2
3
  export interface GooglePlayPublisherClientOptions {
3
4
  /** Supplies an androidpublisher-scoped OAuth access token for each request. */
4
5
  readonly getAccessToken: (signal: AbortSignal) => Promise<string> | string;
@@ -8,4 +9,4 @@ export interface GooglePlayPublisherClientOptions {
8
9
  * Google Play purchase verification and post-ledger finalization transport.
9
10
  * The caller owns OAuth credential rotation and must never expose this client to the game.
10
11
  */
11
- export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient;
12
+ export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient & GooglePlayVoidedPurchasesClient;
@@ -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
- return {
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
- return rejected('GOOGLE_PLAY_ACCOUNT_MISMATCH');
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
- return rejected('GOOGLE_PLAY_PROFILE_MISMATCH');
344
+ foreignBinding = 'GOOGLE_PLAY_PROFILE_MISMATCH';
345
+ }
346
+ if (foreignBinding !== undefined && input.reportForeignBinding !== true) {
347
+ return rejected(foreignBinding);
335
348
  }
336
- if (input.accountBinding.profileId === undefined
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 (input.accountBinding.accountId === undefined
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
+ }>;