@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
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Account deletion (Google Play "account deletion requirements", App Store
3
+ * Review Guideline 5.1.1(v)): the server erases what game services keep for
4
+ * a player, keeps only the purchase records the law requires (detached from
5
+ * the player), and has the identity service end the player's sessions.
6
+ *
7
+ * Retention decision (see docs/GAME_SERVICES_BACKEND.md#account-deletion):
8
+ * - Purchase ledger rows (`source: 'purchase'`), the revocations that reverse
9
+ * them and their notes are kept for five years, the period the Korean
10
+ * E-Commerce Act's enforcement decree (article 6(1)) sets for payment and
11
+ * supply records. They are rewritten so nothing links them to the player:
12
+ * a random ledger id and idempotency key, the player id replaced by
13
+ * `deleted:<deletionId>`, and a `playerId` payload key removed. What
14
+ * identifies the store transaction (store, product, platform transaction
15
+ * and order ids, the token digest, the store's own account binding) stays,
16
+ * so a dispute can be answered from the store receipt and the same store
17
+ * purchase can never be granted again.
18
+ * - Everything else (rewarded-ad and admin grants, scores, verified
19
+ * leaderboard entries, notification endpoints, consent records, notices,
20
+ * reminders, sends and their history) is deleted.
21
+ */
22
+ import { resolveGameServicesRequestHeaders, } from './request-headers.js';
23
+ /** The public route a signed-in player deletes their own account through. */
24
+ export const accountDeletionEndpoint = '/game-services/account/delete';
25
+ /**
26
+ * The request header carrying the deletion confirmation: a short-lived,
27
+ * single-use token the identity service issues to the holder of the
28
+ * session's refresh token after the in-app second step. A bare access
29
+ * token (15 minutes, sent with every request) is not enough to destroy the
30
+ * account.
31
+ */
32
+ export const accountDeletionConfirmationHeader = 'x-mpgd-account-deletion-confirmation';
33
+ /**
34
+ * The longest confirmation the server accepts (characters); the identity
35
+ * service issues shorter ones.
36
+ */
37
+ export const accountDeletionConfirmationMaxLength = 4096;
38
+ /** How long the detached purchase records are kept (Korean E-Commerce Act decree, art. 6(1)). */
39
+ export const retainedPurchaseRecordYears = 5;
40
+ /** The prefix of the player id that replaces a deleted player's on the records kept. */
41
+ export const retainedPlayerIdPrefix = 'deleted:';
42
+ export class AccountDeletionError extends Error {
43
+ code;
44
+ retryable;
45
+ /**
46
+ * Whether the request may have reached the server. For
47
+ * `ACCOUNT_DELETION_UNREACHABLE` it was sent and its answer was lost or
48
+ * unreadable, so the deletion may have completed; for `UNAUTHORIZED` the
49
+ * server refused the session (`false`: no session on this device).
50
+ */
51
+ requestSent;
52
+ constructor(code, options = {}) {
53
+ super(`Account deletion failed: ${code}.`, options.cause === undefined ? {} : { cause: options.cause });
54
+ this.name = 'AccountDeletionError';
55
+ this.code = code;
56
+ this.retryable = code === 'ACCOUNT_DELETION_FAILED' || code === 'ACCOUNT_DELETION_UNREACHABLE';
57
+ this.requestSent = options.requestSent ?? code !== 'ACCOUNT_DELETION_UNREACHABLE';
58
+ }
59
+ }
60
+ /** An `erasing` erasure older than this (one hour) is discarded by the sweep. */
61
+ export const staleErasingPendingMs = 60 * 60 * 1000;
62
+ /**
63
+ * The sweep erases an `identity-deleted` erasure once more, and only then
64
+ * clears it, at its first run at least this long (15 minutes) after the
65
+ * request: longer than any request that authenticated before the identity
66
+ * was deleted can still run. Until then the pending entry (with the player
67
+ * id) is kept, so a deployment must run the sweep (the Worker's cron).
68
+ */
69
+ export const finalErasureDelayMs = 15 * 60 * 1000;
70
+ export function createInMemoryPendingAccountErasureStore() {
71
+ const pending = new Map();
72
+ const triedAt = (erasure) => erasure.attemptedAt ?? erasure.recordedAt;
73
+ return {
74
+ async put(erasure) {
75
+ pending.set(erasure.deletionId, { ...erasure });
76
+ },
77
+ async clear({ deletionId }) {
78
+ pending.delete(deletionId);
79
+ },
80
+ async list({ limit, identityDeletedBefore }) {
81
+ const rank = (erasure) => erasure.phase === 'identity-deleted' ? 0 : 1;
82
+ return [...pending.values()]
83
+ .filter((erasure) => identityDeletedBefore === undefined
84
+ || erasure.phase !== 'identity-deleted'
85
+ || Date.parse(erasure.recordedAt) <= Date.parse(identityDeletedBefore))
86
+ .sort((left, right) => rank(left) - rank(right) || triedAt(left).localeCompare(triedAt(right)))
87
+ .slice(0, limit);
88
+ },
89
+ };
90
+ }
91
+ export function createAccountDeletionService(input) {
92
+ const now = input.now ?? (() => new Date().toISOString());
93
+ const createDeletionId = input.createDeletionId ?? (() => crypto.randomUUID());
94
+ const sleep = input.sleep ?? defaultSleep;
95
+ return {
96
+ async deletePlayerAccount({ playerId }) {
97
+ if (typeof playerId !== 'string' || playerId.length === 0 || isRetainedPlayerId(playerId)) {
98
+ // A `deleted:` id names kept records, never a player who can sign in.
99
+ throw new AccountDeletionError('UNAUTHORIZED');
100
+ }
101
+ const deletionId = createDeletionId();
102
+ const requestedAt = now();
103
+ const erasure = { playerId, retainedPlayerId: `${retainedPlayerIdPrefix}${deletionId}` };
104
+ const pendingErasure = {
105
+ ...erasure,
106
+ deletionId,
107
+ recordedAt: requestedAt,
108
+ phase: 'erasing',
109
+ attempts: 0,
110
+ };
111
+ try {
112
+ await input.pending.put(pendingErasure);
113
+ }
114
+ catch (error) {
115
+ throw new AccountDeletionError('ACCOUNT_DELETION_FAILED', { cause: error });
116
+ }
117
+ const identity = input.identity;
118
+ if (identity !== null) {
119
+ // Retried here (it is idempotent): a call that committed but whose
120
+ // answer was lost succeeds the next time, and the player, whose
121
+ // sessions may already be gone, could not retry it.
122
+ let lastError;
123
+ const identityDeleted = await retry(() => identity.deletePlayer({ playerId }), sleep, (error, attempt) => {
124
+ lastError = error;
125
+ logFailure(`Account deletion identity call failed (attempt ${attempt} of ${retryAttempts}):`, deletionId, playerId, error);
126
+ });
127
+ if (identityDeleted === undefined) {
128
+ // Nothing is erased yet and the player most likely still exists and
129
+ // retries; an `erasing` entry left behind is only discarded when stale.
130
+ throw new AccountDeletionError('ACCOUNT_DELETION_FAILED', { cause: lastError });
131
+ }
132
+ }
133
+ // Without an identity service (local development) the player still
134
+ // exists, so the entry stays `erasing` and no sweep erases it.
135
+ const marked = input.identity === null
136
+ || await retry(() => input.pending.put({ ...pendingErasure, phase: 'identity-deleted' }), sleep, (error, attempt) => logFailure(`Account deletion pending entry not marked (attempt ${attempt} of ${retryAttempts}):`, deletionId, playerId, error)) !== undefined;
137
+ // Each store on its own, so one failing store does not stop the others.
138
+ let deleted = 0;
139
+ let retained = 0;
140
+ let complete = true;
141
+ for (const eraser of input.erasers) {
142
+ const result = await retry(() => eraser.erasePlayerData(erasure), sleep, (error, attempt) => logFailure(`Account deletion erasure failed (attempt ${attempt} of ${retryAttempts}):`, deletionId, playerId, error));
143
+ if (result === undefined) {
144
+ complete = false;
145
+ continue;
146
+ }
147
+ deleted += result.value.deleted;
148
+ retained += result.value.retained;
149
+ }
150
+ if (!complete && (!marked || input.identity === null)) {
151
+ // No sweep finishes this one (not marked, or no identity service, so
152
+ // the entry stays `erasing` and is discarded): an operator can only
153
+ // learn it from this line and the audit entry.
154
+ console.error('Account deletion erasure incomplete; no sweep will finish it:', deletionId);
155
+ }
156
+ if (complete && !marked && input.identity !== null) {
157
+ // Erased once, but the `identity-deleted` mark failed, so the sweep
158
+ // never runs this deletion's final pass: a write by a request that
159
+ // authenticated before the identity was deleted may stay. The audit
160
+ // entry says complete, so this line is how an operator learns it.
161
+ console.error('Account deletion final pass skipped (pending entry not marked):', deletionId);
162
+ }
163
+ // A marked entry stays for the sweep's final pass (`finalErasureDelayMs`
164
+ // after the request): a request authenticated before the identity was
165
+ // deleted may still write, and only a later pass removes it. An
166
+ // unmarked one gets no sweep, so a complete erasure clears it.
167
+ if (complete && (!marked || input.identity === null)) {
168
+ try {
169
+ await input.pending.clear({ deletionId });
170
+ }
171
+ catch (error) {
172
+ logFailure('Account deletion pending entry not cleared:', deletionId, playerId, error);
173
+ }
174
+ }
175
+ const completedAt = now();
176
+ const entry = {
177
+ deletionId,
178
+ requestedAt,
179
+ completedAt,
180
+ identity: input.identity === null ? 'not-configured' : 'deleted',
181
+ complete,
182
+ deleted,
183
+ retained,
184
+ };
185
+ await recordAudit(input.audit, entry, playerId);
186
+ return {
187
+ status: 'deleted',
188
+ deletionId,
189
+ completedAt,
190
+ retainedPurchaseRecords: retained,
191
+ };
192
+ },
193
+ };
194
+ }
195
+ /**
196
+ * Finishes erasures a deletion left pending: an `identity-deleted` one is
197
+ * erased again `finalErasureDelayMs` after the request (every deletion
198
+ * gets this final pass, which removes what a request authenticated before
199
+ * the identity was deleted wrote later) and cleared once every store
200
+ * completed it, with an audit entry that marks the deletion complete; an
201
+ * `erasing` one older than
202
+ * `staleErasingPendingMs` is discarded without erasing. Run it from a
203
+ * scheduled job. A failing erasure is tried again later, least recently
204
+ * tried first, so one that keeps failing does not block the others.
205
+ */
206
+ export async function finishPendingAccountErasures(input) {
207
+ const now = input.now ?? (() => new Date().toISOString());
208
+ let finished = 0;
209
+ let failed = 0;
210
+ let discarded = 0;
211
+ const identityDeletedBefore = new Date(Date.parse(now()) - finalErasureDelayMs).toISOString();
212
+ for (const erasure of await input.pending.list({
213
+ limit: input.limit ?? 25,
214
+ identityDeletedBefore,
215
+ })) {
216
+ const at = now();
217
+ if (erasure.phase !== 'identity-deleted') {
218
+ if (Date.parse(at) - Date.parse(erasure.recordedAt) >= staleErasingPendingMs) {
219
+ try {
220
+ await input.pending.clear({ deletionId: erasure.deletionId });
221
+ discarded += 1;
222
+ console.error('Stale pending account erasure discarded:', erasure.deletionId);
223
+ }
224
+ catch (error) {
225
+ failed += 1;
226
+ logFailure('Stale pending account erasure not discarded:', erasure.deletionId, erasure.playerId, error);
227
+ }
228
+ }
229
+ continue;
230
+ }
231
+ if (Date.parse(at) - Date.parse(erasure.recordedAt) < finalErasureDelayMs) {
232
+ // Too early for the final pass: requests that authenticated before the
233
+ // identity was deleted may still be writing.
234
+ continue;
235
+ }
236
+ // Every store on its own, as in the inline erasure.
237
+ let deleted = 0;
238
+ let retained = 0;
239
+ let erased = true;
240
+ for (const eraser of input.erasers) {
241
+ try {
242
+ const result = await eraser.erasePlayerData(erasure);
243
+ deleted += result.deleted;
244
+ retained += result.retained;
245
+ }
246
+ catch (error) {
247
+ erased = false;
248
+ logFailure('Pending account erasure not finished:', erasure.deletionId, erasure.playerId, error);
249
+ }
250
+ }
251
+ if (!erased) {
252
+ failed += 1;
253
+ if (erasure.attempts + 1 >= pendingErasureAlertAttempts) {
254
+ console.error('Pending account erasure keeps failing; it needs an operator:', erasure.deletionId, `${erasure.attempts + 1} sweep attempts`);
255
+ }
256
+ try {
257
+ await input.pending.put({ ...erasure, attempts: erasure.attempts + 1, attemptedAt: at });
258
+ }
259
+ catch (error) {
260
+ logFailure('Pending account erasure attempt not recorded:', erasure.deletionId, erasure.playerId, error);
261
+ }
262
+ continue;
263
+ }
264
+ try {
265
+ await input.pending.clear({ deletionId: erasure.deletionId });
266
+ }
267
+ catch (error) {
268
+ // Erased; the next sweep repeats the (now empty) erasure and clears it.
269
+ logFailure('Pending account erasure not cleared:', erasure.deletionId, erasure.playerId, error);
270
+ }
271
+ finished += 1;
272
+ // Under the same deletion id as the request's own entry; a store keyed
273
+ // by it adds these counts to that entry's (the Worker's D1 table does).
274
+ await recordAudit(input.audit, {
275
+ deletionId: erasure.deletionId,
276
+ requestedAt: erasure.recordedAt,
277
+ completedAt: at,
278
+ identity: 'deleted',
279
+ complete: true,
280
+ deleted,
281
+ retained,
282
+ }, erasure.playerId);
283
+ }
284
+ return { finished, failed, discarded };
285
+ }
286
+ const retryAttempts = 3;
287
+ /** Sweep attempts after which a failing pending erasure is logged as needing an operator. */
288
+ const pendingErasureAlertAttempts = 10;
289
+ const retryBackoffMs = [100, 400];
290
+ /** Runs `operation` up to three times with a short backoff; `undefined` when all failed. */
291
+ async function retry(operation, sleep, onFailure) {
292
+ for (let attempt = 0; attempt < retryAttempts; attempt += 1) {
293
+ try {
294
+ if (attempt > 0) {
295
+ await sleep(retryBackoffMs[Math.min(attempt, retryBackoffMs.length) - 1] ?? 0);
296
+ }
297
+ return { value: await operation() };
298
+ }
299
+ catch (error) {
300
+ onFailure(error, attempt + 1);
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+ async function recordAudit(audit, entry, playerId) {
306
+ try {
307
+ await audit?.(entry);
308
+ }
309
+ catch (error) {
310
+ // The deletion itself is done; failing here would make the player retry
311
+ // with a session that no longer exists.
312
+ logFailure('Account deletion audit entry not recorded:', entry.deletionId, playerId, error);
313
+ }
314
+ }
315
+ /**
316
+ * Logs a failure by deletion id with the error's name and message, cut
317
+ * short and with the player id scrubbed, so no log line names the player.
318
+ */
319
+ function logFailure(message, deletionId, playerId, error) {
320
+ const described = error instanceof Error ? `${error.name}: ${error.message}` : 'unknown error';
321
+ console.error(message, deletionId, described.split(playerId).join('[player]').slice(0, 200));
322
+ }
323
+ function defaultSleep(ms) {
324
+ return new Promise((resolve) => {
325
+ setTimeout(resolve, ms);
326
+ });
327
+ }
328
+ /** The player id stored on a deleted player's kept purchase records. */
329
+ export function isRetainedPlayerId(playerId) {
330
+ return playerId.startsWith(retainedPlayerIdPrefix);
331
+ }
332
+ /** Refuses an erasure input whose ids could re-erase another deletion's kept records. */
333
+ export function assertPlayerDataErasureInput(input) {
334
+ if (typeof input.playerId !== 'string'
335
+ || input.playerId.length === 0
336
+ || isRetainedPlayerId(input.playerId)
337
+ || typeof input.retainedPlayerId !== 'string'
338
+ || !isRetainedPlayerId(input.retainedPlayerId)
339
+ || input.retainedPlayerId.length <= retainedPlayerIdPrefix.length) {
340
+ throw new Error('Player data erasure needs a player id and a deleted:<id> retained player id.');
341
+ }
342
+ return input;
343
+ }
344
+ /** A fresh id for a kept ledger row; it carries nothing of the player's ids. */
345
+ export function createRetainedLedgerEntryId() {
346
+ return `retained_${crypto.randomUUID()}`;
347
+ }
348
+ /** The kept row's payload: the store transaction stays, the player id goes. */
349
+ export function detachRetainedLedgerPayload(payload) {
350
+ const { playerId: _playerId, ...rest } = payload;
351
+ void _playerId;
352
+ return rest;
353
+ }
354
+ /** How long `deleteAccount()` waits for the session and the server by default. */
355
+ export const defaultAccountDeletionTimeoutMs = 30_000;
356
+ export function createAccountDeletionClient(input) {
357
+ const url = `${input.baseUrl.replace(/\/+$/gu, '')}${accountDeletionEndpoint}`;
358
+ // setTimeout fires at once for a delay that is not a positive 32-bit count.
359
+ const timeoutMs = typeof input.timeoutMs === 'number' && Number.isFinite(input.timeoutMs)
360
+ && input.timeoutMs > 0
361
+ ? Math.min(input.timeoutMs, 2_147_483_647)
362
+ : defaultAccountDeletionTimeoutMs;
363
+ return {
364
+ async deleteAccount(request = {}) {
365
+ const confirmationHeader = request.confirmation === undefined
366
+ ? {}
367
+ : { [accountDeletionConfirmationHeader]: request.confirmation };
368
+ const fetcher = input.fetch
369
+ ?? globalThis.fetch;
370
+ if (fetcher === undefined) {
371
+ throw new AccountDeletionError('ACCOUNT_DELETION_UNREACHABLE');
372
+ }
373
+ const controller = new AbortController();
374
+ let timer;
375
+ let sent = false;
376
+ const deadline = new Promise((_resolve, reject) => {
377
+ timer = setTimeout(() => {
378
+ controller.abort();
379
+ reject(new AccountDeletionError('ACCOUNT_DELETION_UNREACHABLE', { requestSent: sent }));
380
+ }, timeoutMs);
381
+ });
382
+ try {
383
+ let headers;
384
+ try {
385
+ headers = await Promise.race([
386
+ resolveGameServicesRequestHeaders({
387
+ headers: input.headers,
388
+ getHeaders: input.getHeaders,
389
+ }),
390
+ deadline,
391
+ ]);
392
+ }
393
+ catch (error) {
394
+ // A resolver that failed (a refresh that could not reach the
395
+ // identity service) sent nothing: retrying may work. A signed-out
396
+ // session resolves without a header and is caught below.
397
+ throw error instanceof AccountDeletionError
398
+ ? error
399
+ : new AccountDeletionError('ACCOUNT_DELETION_UNREACHABLE', { cause: error });
400
+ }
401
+ if (!Object.keys(headers).includes('authorization')) {
402
+ // Signed out on this device: nothing was sent.
403
+ throw new AccountDeletionError('UNAUTHORIZED', { requestSent: false });
404
+ }
405
+ let response;
406
+ let text;
407
+ try {
408
+ sent = true;
409
+ const answer = async () => {
410
+ const received = await fetcher(url, {
411
+ method: 'POST',
412
+ headers: { ...headers, ...confirmationHeader, 'content-type': 'application/json' },
413
+ body: '{}',
414
+ signal: controller.signal,
415
+ });
416
+ return [received, await received.text()];
417
+ };
418
+ [response, text] = await Promise.race([answer(), deadline]);
419
+ }
420
+ catch (error) {
421
+ throw error instanceof AccountDeletionError
422
+ ? error
423
+ : new AccountDeletionError('ACCOUNT_DELETION_UNREACHABLE', {
424
+ cause: error,
425
+ requestSent: true,
426
+ });
427
+ }
428
+ return readAnswer(response, text);
429
+ }
430
+ finally {
431
+ clearTimeout(timer);
432
+ }
433
+ },
434
+ };
435
+ }
436
+ function readAnswer(response, text) {
437
+ const body = parseJsonObject(text);
438
+ if (response.status === 200) {
439
+ const receipt = readReceipt(body);
440
+ if (receipt === undefined) {
441
+ throw new AccountDeletionError('ACCOUNT_DELETION_UNREACHABLE', { requestSent: true });
442
+ }
443
+ return receipt;
444
+ }
445
+ const code = body?.error;
446
+ if (response.status === 401) {
447
+ throw new AccountDeletionError('UNAUTHORIZED');
448
+ }
449
+ if (response.status === 403 && code === 'PLAYER_ID_MISMATCH') {
450
+ throw new AccountDeletionError('PLAYER_ID_MISMATCH');
451
+ }
452
+ if (response.status === 403 && code === 'ACCOUNT_DELETION_CONFIRMATION_REQUIRED') {
453
+ throw new AccountDeletionError('ACCOUNT_DELETION_CONFIRMATION_REQUIRED');
454
+ }
455
+ if (code === 'ACCOUNT_DELETION_NOT_CONFIGURED' || response.status === 404) {
456
+ // 404: a server from before this route.
457
+ throw new AccountDeletionError('ACCOUNT_DELETION_NOT_CONFIGURED');
458
+ }
459
+ throw new AccountDeletionError(response.status >= 500 || retryableClientStatuses.has(response.status)
460
+ ? 'ACCOUNT_DELETION_FAILED'
461
+ : 'ACCOUNT_DELETION_REJECTED');
462
+ }
463
+ /** Request Timeout, Too Early, Too Many Requests: worth another try. */
464
+ const retryableClientStatuses = new Set([408, 425, 429]);
465
+ function parseJsonObject(text) {
466
+ try {
467
+ const value = JSON.parse(text);
468
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
469
+ ? value
470
+ : undefined;
471
+ }
472
+ catch {
473
+ return undefined;
474
+ }
475
+ }
476
+ function readReceipt(body) {
477
+ if (body?.status !== 'deleted'
478
+ || typeof body.deletionId !== 'string'
479
+ || typeof body.completedAt !== 'string'
480
+ || typeof body.retainedPurchaseRecords !== 'number'
481
+ || !Number.isInteger(body.retainedPurchaseRecords)
482
+ || body.retainedPurchaseRecords < 0) {
483
+ return undefined;
484
+ }
485
+ return {
486
+ status: 'deleted',
487
+ deletionId: body.deletionId,
488
+ completedAt: body.completedAt,
489
+ retainedPurchaseRecords: body.retainedPurchaseRecords,
490
+ };
491
+ }
@@ -23,6 +23,12 @@ export interface CreateAppStoreRecoveryBackendOptions {
23
23
  readonly evidenceVerifier: GameServicesEvidenceVerifier;
24
24
  readonly bundleId: string;
25
25
  readonly environment: AppStoreEnvironment;
26
+ /**
27
+ * Set on a Production deployment whose evidence verifier grants sandbox
28
+ * purchases (`sandboxPurchases: { mode: 'grant' }`), so a restore during
29
+ * App Review or TestFlight re-verifies its sandbox grant too.
30
+ */
31
+ readonly acceptsSandboxTransactions?: boolean;
26
32
  readonly timeoutMs?: number;
27
33
  };
28
34
  /** Look up the original, durable checkout key; never derive one from the transaction ID. */
@@ -1,5 +1,6 @@
1
1
  import { resolveProductPlatformId } from '@mpgd/catalog';
2
2
  import { createAppStoreVerificationId, isAppStoreVerificationId, } from './app-store-verifier.js';
3
+ import { findPurchaseGrantByEvidenceVerificationId } from './commerce-ledger.js';
3
4
  /**
4
5
  * Connect StoreKit recovery to the existing authoritative purchase ledger.
5
6
  * This is a server-side helper, not an authentication or Apple verifier.
@@ -13,6 +14,10 @@ export function createAppStoreRecoveryBackend(options) {
13
14
  && (!Number.isSafeInteger(restoreTimeoutMs) || restoreTimeoutMs <= 0)) {
14
15
  throw new TypeError('Restored App Store verification timeout must be positive.');
15
16
  }
17
+ const acceptsSandbox = options.restoredNonConsumables?.acceptsSandboxTransactions;
18
+ if (acceptsSandbox !== undefined && typeof acceptsSandbox !== 'boolean') {
19
+ throw new TypeError('acceptsSandboxTransactions must be a boolean.');
20
+ }
16
21
  return {
17
22
  async recoverPurchase(input) {
18
23
  if (input?.target !== 'ios' || input.playerId !== options.playerId
@@ -37,12 +42,17 @@ export function createAppStoreRecoveryBackend(options) {
37
42
  && input.originalTransactionId !== undefined
38
43
  && input.originalTransactionId !== input.platformTransactionId
39
44
  && restoreConfig !== undefined) {
40
- const candidateId = createAppStoreVerificationId({
41
- environment: restoreConfig.environment,
45
+ const originalTransactionId = input.originalTransactionId;
46
+ const candidateIn = (environment) => findByVerificationId(options.store, createAppStoreVerificationId({
47
+ environment,
42
48
  bundleId: restoreConfig.bundleId,
43
- transactionId: input.originalTransactionId,
44
- });
45
- const candidate = await findByVerificationId(options.store, candidateId);
49
+ transactionId: originalTransactionId,
50
+ }));
51
+ let candidate = await candidateIn(restoreConfig.environment);
52
+ if (candidate === undefined && acceptsSandboxRestores(restoreConfig)) {
53
+ // An App Review or TestFlight grant on this production ledger.
54
+ candidate = await candidateIn('Sandbox');
55
+ }
46
56
  if (candidate !== undefined) {
47
57
  if (candidate.source !== 'purchase'
48
58
  || candidate.playerId !== request.playerId
@@ -174,7 +184,10 @@ async function verifyRestoredNonConsumable(input, request, options) {
174
184
  if (decision.status === 'pending') {
175
185
  return pending(decision.reason ?? 'APP_STORE_RESTORE_VERIFICATION_PENDING');
176
186
  }
177
- if (decision.status === 'rejected') {
187
+ if (decision.status === 'rejected' || decision.status === 'restorable') {
188
+ // This recovery path never grants across players; restore sharing
189
+ // belongs to the commerce v2 verify path (RFC "Restore sharing"), so a
190
+ // purchase bound to another account stays refused here.
178
191
  return rejected(decision.reason);
179
192
  }
180
193
  if (decision.status === 'settled') {
@@ -188,7 +201,8 @@ async function verifyRestoredNonConsumable(input, request, options) {
188
201
  if (decision.payload?.appStoreTransactionType !== 'Non-Consumable'
189
202
  || originalTransactionId !== input.originalTransactionId
190
203
  || (environment !== 'Production' && environment !== 'Sandbox')
191
- || environment !== config.environment
204
+ || (environment !== config.environment
205
+ && !(environment === 'Sandbox' && acceptsSandboxRestores(config)))
192
206
  || bundleId !== config.bundleId
193
207
  || decision.verificationId !== createAppStoreVerificationId({
194
208
  environment,
@@ -199,6 +213,10 @@ async function verifyRestoredNonConsumable(input, request, options) {
199
213
  }
200
214
  return decision.verificationId;
201
215
  }
216
+ /** A production deployment whose evidence verifier also grants App Review sandbox purchases. */
217
+ function acceptsSandboxRestores(config) {
218
+ return config.environment === 'Production' && config.acceptsSandboxTransactions === true;
219
+ }
202
220
  async function findByPlatformEvidence(store, platformTransactionId) {
203
221
  const identity = {
204
222
  source: 'purchase',
@@ -226,15 +244,7 @@ async function findByIdempotency(store, playerId, idempotencyKey) {
226
244
  });
227
245
  }
228
246
  async function findByVerificationId(store, evidenceVerificationId) {
229
- const identity = { source: 'purchase', evidenceVerificationId };
230
- if (store.findEntitlementTransactionByEvidenceVerificationId !== undefined) {
231
- return store.findEntitlementTransactionByEvidenceVerificationId(identity);
232
- }
233
- return (await store.listEntitlementTransactions()).find((transaction) => {
234
- return transaction.source === identity.source
235
- && (transaction.evidenceVerificationId
236
- ?? transaction.payload.evidenceVerificationId) === identity.evidenceVerificationId;
237
- });
247
+ return findPurchaseGrantByEvidenceVerificationId(store, evidenceVerificationId);
238
248
  }
239
249
  function matchesRecovery(transaction, request, restoredVerificationId = '') {
240
250
  const verificationId = transaction.evidenceVerificationId