@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.
- 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 +47 -7
- 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
|
@@ -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
|
|
41
|
-
|
|
45
|
+
const originalTransactionId = input.originalTransactionId;
|
|
46
|
+
const candidateIn = (environment) => findByVerificationId(options.store, createAppStoreVerificationId({
|
|
47
|
+
environment,
|
|
42
48
|
bundleId: restoreConfig.bundleId,
|
|
43
|
-
transactionId:
|
|
44
|
-
});
|
|
45
|
-
|
|
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
|
-
|
|
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
|