@mpgd/adapter-ait 0.13.3 → 0.14.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/dist/host.js CHANGED
@@ -2,7 +2,10 @@ import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as gra
2
2
  import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
3
3
  import { assertAdShowInput, toAdAdapter } from '@mpgd/platform/ads';
4
4
  import { createAitAdProvider } from './ad-provider.js';
5
+ import { aitIapDiagnosticCodes, aitIapProductNotGrantedByPartnerCode, } from './iap-diagnostics.js';
5
6
  import { dispatchAitLifecycleEvent } from './lifecycle.js';
7
+ export { aitIapDiagnosticCodes, aitIapProductNotGrantedByPartnerCode, } from './iap-diagnostics.js';
8
+ export { aitBrowserOrigins, isAitBrowserOrigin } from './origins.js';
6
9
  const defaultAdTimeoutMs = 60_000;
7
10
  const defaultAdLoadQueueTimeoutMs = 5_000;
8
11
  const defaultAdDisplayStartTimeoutMs = 60_000;
@@ -37,6 +40,26 @@ const maximumIndexedCompletedIapPurchaseAttempts = 64;
37
40
  const pendingIapPurchaseAttemptRecoveryAgeMs = defaultIapPurchaseSessionTimeoutMs;
38
41
  /** Rotates bounded pending-order recovery so an early rejected order cannot starve later work. */
39
42
  const iapPendingOrderCursorStorageKey = 'mpgd:ait:pending-order-cursor:v1';
43
+ /**
44
+ * Links a provider order id to the client purchase attempt that opened it, so
45
+ * recovery paths can pass the game's own purchase key to the verifier and
46
+ * finish that attempt's retry barrier.
47
+ */
48
+ const iapOrderAttemptLinkStoragePrefix = 'mpgd:ait:iap-order-attempt:v1:';
49
+ /**
50
+ * Orders whose provider acknowledgement was started after a server grant but
51
+ * not confirmed. A timed-out `completeProductGrant()` may still succeed later
52
+ * and remove the order from the pending list, so this list keeps the
53
+ * same-product checkout barrier until a later pass sees the order gone.
54
+ */
55
+ const iapAcknowledgementsInFlightStorageKey = 'mpgd:ait:iap-ack-in-flight:v1';
56
+ const maximumIapAcknowledgementsInFlight = 32;
57
+ /**
58
+ * Barriers whose SKU is no longer configured are kept apart from the active
59
+ * capacity. That product is no longer sold, so once more than this many
60
+ * accumulate, the oldest is dropped; it only served diagnostics by then.
61
+ */
62
+ const maximumRetiredIapAcknowledgements = 32;
40
63
  const defaultNotificationAgreementTimeoutMs = 120_000;
41
64
  const notificationTopics = new Set([
42
65
  'daily-ready',
@@ -90,6 +113,12 @@ const defaultDependencies = {
90
113
  submitGameCenterLeaderBoardScore,
91
114
  iap: IAP,
92
115
  };
116
+ /**
117
+ * Native IAP dependency objects whose single IAP lock is held. Module scoped
118
+ * so that two bridges on the same native SDK, for example after the host
119
+ * bridge is reinstalled while an old request is still active, share it.
120
+ */
121
+ const aitIapExclusiveOperations = new WeakSet();
93
122
  export function installAitHostBridge(options = {}) {
94
123
  const bridge = createAitHostBridge(options);
95
124
  globalThis.__GAME_PLATFORM_BRIDGE__ = bridge;
@@ -112,6 +141,16 @@ export function createAitHostBridge(options = {}) {
112
141
  const iapProductGrantTimeoutMs = normalizeIapProductGrantTimeout(options.iapProductGrantTimeoutMs);
113
142
  const promotionGrantsInFlight = new Map();
114
143
  const iapPurchasesInFlight = new Map();
144
+ /**
145
+ * The single IAP lock, shared by every bridge built on the same native IAP
146
+ * dependency (see `aitIapExclusiveOperations`). A purchase holds it from
147
+ * its attempt-marker check until its checkout reaches a terminal result,
148
+ * and a restore holds it from before preparation until reconciliation and
149
+ * the entitlement read finish. Only one of them may read, verify or
150
+ * acknowledge provider orders or change attempt markers at a time; the
151
+ * other gets `AIT_IAP_CHECKOUT_IN_PROGRESS` and nothing is charged.
152
+ */
153
+ const iapLockOwner = dependencies.iap;
115
154
  let completedIapAttemptRetention = Promise.resolve();
116
155
  const notificationSubscriptions = new Set();
117
156
  const loadedAdGroupIds = new Set();
@@ -154,6 +193,16 @@ export function createAitHostBridge(options = {}) {
154
193
  void (completedIapAttemptRetention = next);
155
194
  return next;
156
195
  };
196
+ /** Callers must hold the single IAP lock for the whole pass. */
197
+ const reconcilePendingIapOrders = async (verifier, timeoutMs) => {
198
+ return await reconcileAitIapPendingOrders({
199
+ dependencies,
200
+ products: iapProducts,
201
+ verifier,
202
+ retainCompletedAttempt: enqueueCompletedIapAttemptRetention,
203
+ timeoutMs,
204
+ });
205
+ };
157
206
  return {
158
207
  advertising: {
159
208
  id: nativeAds.id,
@@ -297,15 +346,19 @@ export function createAitHostBridge(options = {}) {
297
346
  }
298
347
  }
299
348
  // IAP is opt-in and server-authoritative. Never return demo grants.
300
- case 'commerce.getProducts':
301
- return ok(request, await listAitIapProducts({
349
+ case 'commerce.getProducts': {
350
+ const catalog = await listAitIapProducts({
302
351
  dependencies,
303
352
  products: iapProducts,
304
353
  prepare: options.prepareIap,
305
354
  verifier: options.verifyIapProductGrant,
306
355
  entitlementReader: options.readIapEntitlements,
307
356
  timeoutMs: iapProductGrantTimeoutMs,
308
- }));
357
+ });
358
+ return catalog.status === 'ok'
359
+ ? ok(request, catalog.products)
360
+ : createAitIapDiagnosticError(request.id, catalog.diagnostic, 'Apps in Toss IAP catalog is unavailable');
361
+ }
309
362
  case 'commerce.purchase': {
310
363
  const purchase = readCommercePurchase(request.payload);
311
364
  const product = iapProducts.byProductId.get(purchase.productId);
@@ -319,61 +372,101 @@ export function createAitHostBridge(options = {}) {
319
372
  entitlementReader: options.readIapEntitlements,
320
373
  timeoutMs: iapProductGrantTimeoutMs,
321
374
  });
322
- if (product === undefined
323
- || !iapSupported
375
+ if (!iapSupported
324
376
  || prepareIap === undefined
325
377
  || verifyIapProductGrant === undefined) {
326
- return ok(request, failedPurchase());
327
- }
328
- const persisted = await resolvePersistedAitIapPurchaseAttempt({
329
- dependencies,
330
- storage: dependencies.storage,
331
- product,
332
- idempotencyKey: purchase.idempotencyKey,
333
- timeoutMs: iapProductGrantTimeoutMs,
334
- });
335
- if (persisted !== undefined) {
336
- return ok(request, persisted);
378
+ return ok(request, failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.unavailable, false)));
337
379
  }
338
- const isOneTimeProduct = await isAitOneTimeIapProduct(dependencies, product, iapProductGrantTimeoutMs);
339
- if (!isOneTimeProduct) {
340
- return ok(request, failedPurchase());
380
+ if (product === undefined) {
381
+ return ok(request, failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.productNotConfigured, false)));
341
382
  }
342
383
  const purchaseRequestKey = createAitIapPurchaseRequestKey(purchase.productId, purchase.idempotencyKey);
343
384
  const existing = iapPurchasesInFlight.get(purchaseRequestKey);
344
385
  if (existing !== undefined) {
345
386
  return ok(request, await existing);
346
387
  }
347
- const pending = purchaseAitIapProduct({
348
- dependencies,
349
- product,
350
- idempotencyKey: purchase.idempotencyKey,
351
- prepare: prepareIap,
352
- verifier: verifyIapProductGrant,
353
- retainCompletedAttempt: enqueueCompletedIapAttemptRetention,
354
- timeoutMs: iapProductGrantTimeoutMs,
355
- });
388
+ // Check and claim the single IAP lock synchronously, before reading
389
+ // the attempt marker: a restore or another key must not reconcile,
390
+ // acknowledge or clear markers while this attempt decides whether to
391
+ // open a checkout or while its grant callback is active.
392
+ if (aitIapExclusiveOperations.has(iapLockOwner)) {
393
+ return ok(request, failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.checkoutInProgress, true)));
394
+ }
395
+ aitIapExclusiveOperations.add(iapLockOwner);
396
+ const pending = (async () => {
397
+ const persisted = await resolvePersistedAitIapPurchaseAttempt({
398
+ dependencies,
399
+ storage: dependencies.storage,
400
+ product,
401
+ idempotencyKey: purchase.idempotencyKey,
402
+ timeoutMs: iapProductGrantTimeoutMs,
403
+ });
404
+ if (persisted !== undefined) {
405
+ return persisted;
406
+ }
407
+ const productUnavailable = await checkAitOneTimeIapProduct(dependencies, product, iapProductGrantTimeoutMs);
408
+ if (productUnavailable !== undefined) {
409
+ return failedPurchase(productUnavailable);
410
+ }
411
+ return await purchaseAitIapProduct({
412
+ dependencies,
413
+ products: iapProducts,
414
+ product,
415
+ idempotencyKey: purchase.idempotencyKey,
416
+ prepare: prepareIap,
417
+ verifier: verifyIapProductGrant,
418
+ retainCompletedAttempt: enqueueCompletedIapAttemptRetention,
419
+ reconcilePendingOrders: (timeoutMs) => reconcilePendingIapOrders(verifyIapProductGrant, timeoutMs),
420
+ timeoutMs: iapProductGrantTimeoutMs,
421
+ });
422
+ })();
356
423
  iapPurchasesInFlight.set(purchaseRequestKey, pending);
357
424
  try {
358
425
  return ok(request, await pending);
359
426
  }
360
427
  finally {
428
+ aitIapExclusiveOperations.delete(iapLockOwner);
361
429
  if (iapPurchasesInFlight.get(purchaseRequestKey) === pending) {
362
430
  iapPurchasesInFlight.delete(purchaseRequestKey);
363
431
  }
364
432
  }
365
433
  }
366
- case 'commerce.restore':
367
- return ok(request, await restoreAitIapProducts({
368
- dependencies,
369
- products: iapProducts,
370
- prepare: options.prepareIap,
371
- verifier: options.verifyIapProductGrant,
372
- entitlementReader: options.readIapEntitlements,
373
- timeoutMs: iapProductGrantTimeoutMs,
374
- }));
375
- case 'commerce.getEntitlements':
376
- return ok(request, await readAitIapEntitlements(options.readIapEntitlements, iapProductGrantTimeoutMs));
434
+ case 'commerce.restore': {
435
+ // Claimed before preparation and held until restore finishes, so a
436
+ // purchase cannot start a checkout while restore is still preparing.
437
+ if (aitIapExclusiveOperations.has(iapLockOwner)) {
438
+ return createAitIapDiagnosticError(request.id, aitIapDiagnostic(aitIapDiagnosticCodes.checkoutInProgress, true), 'Apps in Toss purchase restore did not complete');
439
+ }
440
+ aitIapExclusiveOperations.add(iapLockOwner);
441
+ let restored;
442
+ try {
443
+ restored = await restoreAitIapProducts({
444
+ dependencies,
445
+ products: iapProducts,
446
+ prepare: options.prepareIap,
447
+ verifier: options.verifyIapProductGrant,
448
+ entitlementReader: options.readIapEntitlements,
449
+ reconcilePendingOrders: reconcilePendingIapOrders,
450
+ timeoutMs: iapProductGrantTimeoutMs,
451
+ });
452
+ }
453
+ finally {
454
+ aitIapExclusiveOperations.delete(iapLockOwner);
455
+ }
456
+ return restored.status === 'ok'
457
+ ? ok(request, restored.result)
458
+ : createAitIapDiagnosticError(request.id, restored.diagnostic, 'Apps in Toss purchase restore did not complete');
459
+ }
460
+ case 'commerce.getEntitlements': {
461
+ if (options.readIapEntitlements === undefined) {
462
+ return ok(request, []);
463
+ }
464
+ // A failed or timed-out authority read is not proof of no ownership.
465
+ const entitlements = await readAitIapEntitlementsIfAvailable(options.readIapEntitlements, iapProductGrantTimeoutMs);
466
+ return entitlements === undefined
467
+ ? createAitIapDiagnosticError(request.id, aitIapDiagnostic(aitIapDiagnosticCodes.entitlementReadFailed, true), 'Apps in Toss purchase entitlements are unavailable')
468
+ : ok(request, entitlements);
469
+ }
377
470
  case 'ads.preload': {
378
471
  const placementId = readPlacementId(request.payload);
379
472
  const adGroupId = adGroupIds.get(placementId);
@@ -1023,47 +1116,92 @@ function isAitIapSupported(input) {
1023
1116
  && isAitNativeMethodSupported(input.dependencies.iap.getPendingOrders)
1024
1117
  && isAitNativeMethodSupported(input.dependencies.iap.completeProductGrant);
1025
1118
  }
1119
+ /**
1120
+ * Product metadata is not authoritative, so a catalog that cannot be read is
1121
+ * never replaced with stale game-owned prices. Unconfigured or unsupported IAP
1122
+ * still lists no products; a configured catalog that fails reports a stable
1123
+ * diagnostic code instead of an indistinguishable empty list.
1124
+ */
1026
1125
  async function listAitIapProducts(input) {
1027
1126
  if (!isAitIapSupported(input)) {
1028
- return [];
1127
+ return { status: 'ok', products: [] };
1029
1128
  }
1030
- try {
1031
- const result = await waitForAitIapNativeCall(() => input.dependencies.iap.getProductItemList(), input.timeoutMs);
1032
- if (result === undefined) {
1033
- return [];
1129
+ const catalog = await readAitIapCatalog(input.dependencies, input.timeoutMs);
1130
+ if (catalog.status !== 'ok') {
1131
+ return catalog;
1132
+ }
1133
+ const products = [];
1134
+ for (const nativeProduct of catalog.products) {
1135
+ const configured = input.products.bySku.get(nativeProduct.sku);
1136
+ if (configured === undefined) {
1137
+ continue;
1034
1138
  }
1035
- const products = [];
1036
- for (const nativeProduct of result.products) {
1037
- const configured = input.products.bySku.get(nativeProduct.sku);
1038
- if (configured === undefined) {
1039
- continue;
1040
- }
1041
- const product = toAitIapProductInfo(nativeProduct, configured);
1042
- if (product !== undefined) {
1043
- products.push(product);
1044
- }
1139
+ const product = toAitIapProductInfo(nativeProduct, configured);
1140
+ if (product !== undefined) {
1141
+ products.push(product);
1045
1142
  }
1046
- return products;
1047
1143
  }
1048
- catch {
1049
- // Product metadata is not authoritative. If the native catalog cannot be
1050
- // read, hide it instead of rendering stale game-owned prices.
1051
- return [];
1144
+ if (products.length === 0) {
1145
+ return {
1146
+ status: 'failed',
1147
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.configuredSkusNotVisible, false),
1148
+ };
1149
+ }
1150
+ if (products.length < input.products.byProductId.size) {
1151
+ console.warn('AIT IAP catalog is missing configured products.', {
1152
+ configuredCount: input.products.byProductId.size,
1153
+ visibleCount: products.length,
1154
+ });
1155
+ }
1156
+ return { status: 'ok', products };
1157
+ }
1158
+ async function readAitIapCatalog(dependencies, timeoutMs) {
1159
+ const outcome = await callAitIapNative(() => joinAitIapNativeRead(dependencies.iap, 'getProductItemList', () => dependencies.iap.getProductItemList()), timeoutMs);
1160
+ if (outcome.status === 'timeout') {
1161
+ return {
1162
+ status: 'failed',
1163
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.catalogUnavailable, true),
1164
+ };
1052
1165
  }
1166
+ if (outcome.status === 'error') {
1167
+ return {
1168
+ status: 'failed',
1169
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.catalogUnavailable, true, outcome.providerCode),
1170
+ };
1171
+ }
1172
+ // The SDK resolves without a value when the Toss app is too old.
1173
+ const result = outcome.value;
1174
+ if (!isRecord(result)) {
1175
+ return {
1176
+ status: 'failed',
1177
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.unsupportedAppVersion, false),
1178
+ };
1179
+ }
1180
+ const products = Array.isArray(result.products)
1181
+ ? result.products.filter((product) => isRecord(product) && typeof product.sku === 'string')
1182
+ : [];
1183
+ if (products.length === 0) {
1184
+ return {
1185
+ status: 'failed',
1186
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.catalogEmpty, true),
1187
+ };
1188
+ }
1189
+ return { status: 'ok', products };
1053
1190
  }
1054
- async function readAitIapEntitlements(reader, timeoutMs) {
1191
+ /** Returns undefined when the game authority could not be read in time. */
1192
+ async function readAitIapEntitlementsIfAvailable(reader, timeoutMs) {
1055
1193
  if (reader === undefined || timeoutMs <= 0) {
1056
- return [];
1194
+ return undefined;
1057
1195
  }
1058
1196
  try {
1059
1197
  const entitlements = await waitForAitIapNativeCall(reader, timeoutMs);
1060
- return entitlements === undefined ? [] : [...entitlements];
1198
+ return entitlements === undefined ? undefined : [...entitlements];
1061
1199
  }
1062
1200
  catch (error) {
1063
1201
  // The game authority owns durable purchase state. A read failure must not
1064
1202
  // turn stale local data into a visible entitlement.
1065
1203
  console.warn('AIT IAP entitlement read failed; reporting no entitlements.', errorMessage(error));
1066
- return [];
1204
+ return undefined;
1067
1205
  }
1068
1206
  }
1069
1207
  /**
@@ -1072,18 +1210,15 @@ async function readAitIapEntitlements(reader, timeoutMs) {
1072
1210
  * coalescing map has been cleared.
1073
1211
  */
1074
1212
  async function resolvePersistedAitIapPurchaseAttempt(input) {
1213
+ const storageUnavailable = aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true);
1214
+ const attemptPending = aitIapDiagnostic(aitIapDiagnosticCodes.attemptPending, true);
1075
1215
  const storageKey = aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey);
1076
- let serialized;
1077
- try {
1078
- serialized = await waitForAitIapNativeCall(() => input.storage.getItem(storageKey), input.timeoutMs);
1079
- }
1080
- catch (error) {
1081
- console.warn('AIT IAP purchase attempt storage read failed; treating the attempt as pending.', errorMessage(error));
1082
- return pendingPurchase();
1083
- }
1084
- if (serialized === undefined) {
1085
- return pendingPurchase();
1216
+ const read = await readAitIapStorage(input.storage, storageKey, input.timeoutMs);
1217
+ if (read.status !== 'ok') {
1218
+ console.warn('AIT IAP purchase attempt storage read failed; treating the attempt as pending.');
1219
+ return pendingPurchase(undefined, storageUnavailable);
1086
1220
  }
1221
+ const serialized = read.value;
1087
1222
  if (serialized === null) {
1088
1223
  return undefined;
1089
1224
  }
@@ -1093,57 +1228,94 @@ async function resolvePersistedAitIapPurchaseAttempt(input) {
1093
1228
  }
1094
1229
  catch (error) {
1095
1230
  console.warn('AIT IAP purchase attempt marker is invalid; treating the attempt as pending.', errorMessage(error));
1096
- return pendingPurchase();
1231
+ return pendingPurchase(undefined, storageUnavailable);
1097
1232
  }
1098
- if (!isRecord(state)
1099
- || state.productId !== input.product.productId
1100
- || state.idempotencyKey !== input.idempotencyKey) {
1101
- return pendingPurchase();
1233
+ if (!isAitIapMarkerForAttempt(state, input.product, input.idempotencyKey)) {
1234
+ return pendingPurchase(undefined, storageUnavailable);
1102
1235
  }
1103
1236
  if (state.status === aitIapPurchaseAttemptStatus.pending) {
1104
1237
  if (typeof state.orderId === 'string' && isAitIapOrderId(state.orderId)) {
1105
- return pendingPurchase(state.orderId);
1238
+ return pendingPurchase(state.orderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true));
1106
1239
  }
1107
1240
  const pendingSince = normalizePendingSince(state.pendingSince);
1108
1241
  if (pendingSince === undefined
1109
1242
  || !isAitIapPendingAttemptPastRecoveryWindow(pendingSince)) {
1110
- return pendingPurchase();
1243
+ return pendingPurchase(undefined, attemptPending);
1111
1244
  }
1112
1245
  const hasPendingProviderOrder = await hasAitIapPendingOrderForSku(input.dependencies, input.product.sku, input.timeoutMs);
1113
1246
  if (hasPendingProviderOrder !== false) {
1114
- return pendingPurchase();
1247
+ return pendingPurchase(undefined, attemptPending);
1115
1248
  }
1116
1249
  const cleared = await clearAitIapPurchaseAttempt(input);
1117
- return cleared ? undefined : pendingPurchase();
1250
+ return cleared ? undefined : pendingPurchase(undefined, storageUnavailable);
1118
1251
  }
1119
1252
  if (state.status === aitIapPurchaseAttemptStatus.serverGranted
1120
1253
  && typeof state.orderId === 'string'
1121
1254
  && isAitIapOrderId(state.orderId)) {
1122
- return pendingPurchase(state.orderId);
1255
+ return pendingPurchase(state.orderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantCompletionFailed, true));
1123
1256
  }
1124
1257
  if (state.status === aitIapPurchaseAttemptStatus.completed
1125
1258
  && typeof state.orderId === 'string'
1126
1259
  && isAitIapOrderId(state.orderId)) {
1127
- return completedAitIapPurchase(input.product, state.orderId);
1260
+ return completedAitIapPurchase(input.product, state.orderId, state.source === 'pending-order-restore' ? 'pending-order-restore' : 'process-product-grant');
1128
1261
  }
1129
- return pendingPurchase();
1262
+ return pendingPurchase(undefined, storageUnavailable);
1263
+ }
1264
+ const completedAitIapAttemptTombstoneVersion = 1;
1265
+ /**
1266
+ * Whether a parsed marker belongs to this product and client key. Completed
1267
+ * tombstones omit the client key (their storage key carries it); full-form
1268
+ * markers, including completed ones written before tombstones, must match it.
1269
+ */
1270
+ function isAitIapMarkerForAttempt(state, product, idempotencyKey) {
1271
+ if (!isRecord(state) || state.productId !== product.productId) {
1272
+ return false;
1273
+ }
1274
+ if (state.v === completedAitIapAttemptTombstoneVersion) {
1275
+ return state.status === aitIapPurchaseAttemptStatus.completed
1276
+ && state.idempotencyKey === undefined;
1277
+ }
1278
+ return state.v === undefined && state.idempotencyKey === idempotencyKey;
1130
1279
  }
1131
1280
  async function persistAitIapPurchaseAttempt(input) {
1132
- return await writeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), JSON.stringify({
1133
- status: input.status,
1134
- productId: input.product.productId,
1135
- idempotencyKey: input.idempotencyKey,
1136
- ...(input.status === aitIapPurchaseAttemptStatus.pending
1137
- ? { pendingSince: new Date().toISOString() }
1138
- : {}),
1139
- ...(input.orderId === undefined ? {} : { orderId: input.orderId }),
1140
- }), input.timeoutMs);
1281
+ // A completed marker is kept forever as the idempotency record of its
1282
+ // client key, so it is a compact tombstone: the storage key already names
1283
+ // the product and client key, and replay needs only the order and source.
1284
+ const marker = input.status === aitIapPurchaseAttemptStatus.completed
1285
+ ? {
1286
+ v: completedAitIapAttemptTombstoneVersion,
1287
+ status: input.status,
1288
+ productId: input.product.productId,
1289
+ ...(input.orderId === undefined ? {} : { orderId: input.orderId }),
1290
+ ...(input.source === 'pending-order-restore' ? { source: input.source } : {}),
1291
+ }
1292
+ : {
1293
+ status: input.status,
1294
+ productId: input.product.productId,
1295
+ idempotencyKey: input.idempotencyKey,
1296
+ ...(input.status === aitIapPurchaseAttemptStatus.pending
1297
+ ? { pendingSince: new Date().toISOString() }
1298
+ : {}),
1299
+ ...(input.orderId === undefined ? {} : { orderId: input.orderId }),
1300
+ };
1301
+ return await writeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), JSON.stringify(marker), input.timeoutMs);
1141
1302
  }
1142
1303
  async function clearAitIapPurchaseAttempt(input) {
1143
1304
  return await removeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), input.timeoutMs);
1144
1305
  }
1145
1306
  async function verifyAndPersistAitIapProductGrant(input) {
1146
1307
  const startedAt = Date.now();
1308
+ // Best effort and not awaited here: the link only lets recovery pass the
1309
+ // client key for this order, so a slow write must never consume the grant
1310
+ // deadline. The purchase still settles it before releasing the IAP lock, so
1311
+ // restore never reads an absence that races this write.
1312
+ input.trackWrite(writeAitIapOrderAttemptLink({
1313
+ storage: input.storage,
1314
+ product: input.product,
1315
+ idempotencyKey: input.idempotencyKey,
1316
+ orderId: input.orderId,
1317
+ timeoutMs: input.timeoutMs,
1318
+ }));
1147
1319
  const correlated = await persistAitIapPurchaseAttempt({
1148
1320
  storage: input.storage,
1149
1321
  product: input.product,
@@ -1167,6 +1339,7 @@ async function verifyAndPersistAitIapProductGrant(input) {
1167
1339
  orderId: input.orderId,
1168
1340
  product: input.product,
1169
1341
  idempotencyKey: input.orderIdempotencyKey,
1342
+ clientIdempotencyKey: input.idempotencyKey,
1170
1343
  source: input.source,
1171
1344
  timeoutMs: verificationTimeoutMs,
1172
1345
  });
@@ -1190,27 +1363,10 @@ function remainingAitIapOperationTimeout(startedAt, timeoutMs) {
1190
1363
  return Math.max(0, timeoutMs - Math.max(0, Date.now() - startedAt));
1191
1364
  }
1192
1365
  async function writeAitIapStorage(storage, key, value, timeoutMs) {
1193
- return await waitForAitIapStorageOperation(() => storage.setItem(key, value), timeoutMs);
1366
+ return (await runAitIapStorageOperation(storage, key, () => storage.setItem(key, value), timeoutMs)).status === 'ok';
1194
1367
  }
1195
1368
  async function removeAitIapStorage(storage, key, timeoutMs) {
1196
- return await waitForAitIapStorageOperation(() => storage.removeItem(key), timeoutMs);
1197
- }
1198
- async function waitForAitIapStorageOperation(operation, timeoutMs) {
1199
- let timeout;
1200
- const timeoutResult = new Promise((resolve) => {
1201
- timeout = globalThis.setTimeout(() => resolve(false), timeoutMs);
1202
- });
1203
- const operationResult = Promise.resolve()
1204
- .then(operation)
1205
- .then(() => true, () => false);
1206
- try {
1207
- return await Promise.race([operationResult, timeoutResult]);
1208
- }
1209
- finally {
1210
- if (timeout !== undefined) {
1211
- globalThis.clearTimeout(timeout);
1212
- }
1213
- }
1369
+ return (await runAitIapStorageOperation(storage, key, () => storage.removeItem(key), timeoutMs)).status === 'ok';
1214
1370
  }
1215
1371
  function aitIapPurchaseAttemptStorageKey(productId, idempotencyKey) {
1216
1372
  return `${iapPurchaseAttemptStoragePrefix}${encodeURIComponent(productId)}:${encodeURIComponent(idempotencyKey)}`;
@@ -1222,11 +1378,26 @@ function isAitIapPendingAttemptPastRecoveryWindow(pendingSince) {
1222
1378
  }
1223
1379
  async function hasAitIapPendingOrderForSku(dependencies, sku, timeoutMs) {
1224
1380
  try {
1225
- const result = await waitForAitIapNativeCall(() => dependencies.iap.getPendingOrders(), timeoutMs);
1226
- if (result === undefined) {
1381
+ const result = await waitForAitIapNativeCall(() => joinAitIapNativeRead(dependencies.iap, 'getPendingOrders', () => dependencies.iap.getPendingOrders()), timeoutMs);
1382
+ const orders = isRecord(result) ? result.orders : undefined;
1383
+ if (!Array.isArray(orders)) {
1227
1384
  return undefined;
1228
1385
  }
1229
- return result.orders.some((order) => order.sku === sku && isAitIapOrderId(order.orderId));
1386
+ let found = false;
1387
+ for (const order of orders) {
1388
+ if (!isRecord(order) || typeof order.sku !== 'string') {
1389
+ // An entry that may be this SKU's order is not proof of absence.
1390
+ return undefined;
1391
+ }
1392
+ if (order.sku !== sku) {
1393
+ continue;
1394
+ }
1395
+ if (typeof order.orderId !== 'string' || !isAitIapOrderId(order.orderId)) {
1396
+ return undefined;
1397
+ }
1398
+ found = true;
1399
+ }
1400
+ return found;
1230
1401
  }
1231
1402
  catch (error) {
1232
1403
  console.warn('AIT IAP pending-order lookup failed; preserving the client retry barrier.', errorMessage(error));
@@ -1236,14 +1407,18 @@ async function hasAitIapPendingOrderForSku(dependencies, sku, timeoutMs) {
1236
1407
  /**
1237
1408
  * Keep the completed-attempt index bounded without deleting terminal retry
1238
1409
  * markers. A client idempotency key may be replayed indefinitely, so removing
1239
- * its durable result could open a second native checkout. The index is only
1240
- * bookkeeping for recent entries; all storage failures leave markers intact.
1410
+ * its durable result could open a second native checkout. Completed markers
1411
+ * are compact tombstones instead. The index is only bookkeeping for recent
1412
+ * entries; all storage failures leave markers intact.
1241
1413
  */
1242
1414
  async function retainCompletedAitIapPurchaseAttempt(storage, storageKey, timeoutMs) {
1243
1415
  const startedAt = Date.now();
1244
1416
  try {
1245
- const serialized = await waitForAitIapNativeCall(() => storage.getItem(iapCompletedPurchaseAttemptIndexStorageKey), timeoutMs);
1246
- const knownKeys = parseAitIapCompletedPurchaseAttemptIndex(serialized);
1417
+ const read = await readAitIapStorage(storage, iapCompletedPurchaseAttemptIndexStorageKey, timeoutMs);
1418
+ if (read.status !== 'ok') {
1419
+ return;
1420
+ }
1421
+ const knownKeys = parseAitIapCompletedPurchaseAttemptIndex(read.value);
1247
1422
  const keys = [...knownKeys.filter((knownKey) => knownKey !== storageKey), storageKey];
1248
1423
  const retainedKeys = keys.slice(-maximumIndexedCompletedIapPurchaseAttempts);
1249
1424
  const retentionTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
@@ -1276,14 +1451,13 @@ function parseAitIapCompletedPurchaseAttemptIndex(serialized) {
1276
1451
  }
1277
1452
  }
1278
1453
  async function readAitIapPendingOrderCursor(storage, timeoutMs) {
1279
- try {
1280
- const cursor = await waitForAitIapNativeCall(() => storage.getItem(iapPendingOrderCursorStorageKey), timeoutMs);
1281
- return typeof cursor === 'string' && isAitIapOrderId(cursor) ? cursor : undefined;
1282
- }
1283
- catch (error) {
1284
- console.warn('AIT IAP pending-order cursor read failed; starting from the first eligible order.', errorMessage(error));
1454
+ const read = await readAitIapStorage(storage, iapPendingOrderCursorStorageKey, timeoutMs);
1455
+ if (read.status !== 'ok') {
1456
+ console.warn('AIT IAP pending-order cursor read failed; starting from the first eligible order.');
1285
1457
  return undefined;
1286
1458
  }
1459
+ const cursor = read.value;
1460
+ return typeof cursor === 'string' && isAitIapOrderId(cursor) ? cursor : undefined;
1287
1461
  }
1288
1462
  async function writeAitIapPendingOrderCursor(storage, orderId, timeoutMs) {
1289
1463
  return await writeAitIapStorage(storage, iapPendingOrderCursorStorageKey, orderId, timeoutMs);
@@ -1313,7 +1487,14 @@ async function purchaseAitIapProduct(input) {
1313
1487
  platformSku: input.product.sku,
1314
1488
  }, input.timeoutMs);
1315
1489
  if (!prepared) {
1316
- return failedPurchase();
1490
+ return failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.preparationFailed, true));
1491
+ }
1492
+ // A paid order whose grant failed stays pending at the provider. Resolve it
1493
+ // before opening another checkout so the player is never charged twice for
1494
+ // a product they already paid for.
1495
+ const checkoutBarrier = resolveAitIapCheckoutBarrier(await input.reconcilePendingOrders(input.timeoutMs), input.product);
1496
+ if (checkoutBarrier !== undefined) {
1497
+ return failedPurchase(checkoutBarrier);
1317
1498
  }
1318
1499
  const started = await persistAitIapPurchaseAttempt({
1319
1500
  storage: input.dependencies.storage,
@@ -1325,10 +1506,29 @@ async function purchaseAitIapProduct(input) {
1325
1506
  if (!started) {
1326
1507
  // We cannot tell whether a delayed native-storage write eventually
1327
1508
  // succeeded. Do not open a checkout without a durable retry barrier.
1328
- return pendingPurchase();
1509
+ return pendingPurchase(undefined, aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true));
1329
1510
  }
1330
1511
  return new Promise((resolve) => {
1331
1512
  const grantAttempts = new Map();
1513
+ /**
1514
+ * Grant attempts, link writes and direct recovery that may outlive the
1515
+ * terminal callback. The result (and with it the IAP lock) is released
1516
+ * only after all of them settle; each is bounded by its own deadline.
1517
+ */
1518
+ const inFlightWork = new Set();
1519
+ const trackWrite = (work) => {
1520
+ inFlightWork.add(work);
1521
+ const forget = () => {
1522
+ inFlightWork.delete(work);
1523
+ };
1524
+ void work.then(forget, forget);
1525
+ };
1526
+ /** Waits until no tracked work remains, including work added while waiting. */
1527
+ const drainInFlightWork = async () => {
1528
+ while (inFlightWork.size > 0) {
1529
+ await Promise.allSettled([...inFlightWork]);
1530
+ }
1531
+ };
1332
1532
  let settled = false;
1333
1533
  let cleanup;
1334
1534
  let cleanupRequested = false;
@@ -1351,7 +1551,9 @@ async function purchaseAitIapProduct(input) {
1351
1551
  else {
1352
1552
  safelyCleanupAitIapPurchase(cleanup);
1353
1553
  }
1354
- resolve(result);
1554
+ void drainInFlightWork().then(() => {
1555
+ resolve(result);
1556
+ });
1355
1557
  };
1356
1558
  const startMarkerDeletion = (clearedResult) => {
1357
1559
  markerDeletionStarted = true;
@@ -1361,10 +1563,17 @@ async function purchaseAitIapProduct(input) {
1361
1563
  idempotencyKey: input.idempotencyKey,
1362
1564
  timeoutMs: input.timeoutMs,
1363
1565
  }).then((cleared) => {
1364
- finish(cleared ? clearedResult : pendingPurchase());
1566
+ finish(cleared
1567
+ ? clearedResult
1568
+ : pendingPurchase(undefined, aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true)));
1365
1569
  });
1366
1570
  };
1367
1571
  const processProductGrant = (orderId) => {
1572
+ if (settled) {
1573
+ // The terminal result is already decided; never start a grant whose
1574
+ // writes could land after the IAP lock is released.
1575
+ return Promise.resolve(false);
1576
+ }
1368
1577
  if (markerDeletionStarted) {
1369
1578
  // Cancellation won the serialization race. Do not let a later queued
1370
1579
  // grant callback commit after its retry barrier starts disappearing.
@@ -1387,6 +1596,7 @@ async function purchaseAitIapProduct(input) {
1387
1596
  idempotencyKey: input.idempotencyKey,
1388
1597
  orderIdempotencyKey: createAitIapOrderIdempotencyKey(orderId),
1389
1598
  source: 'process-product-grant',
1599
+ trackWrite,
1390
1600
  timeoutMs: input.timeoutMs,
1391
1601
  }).then((granted) => {
1392
1602
  if (granted) {
@@ -1395,13 +1605,80 @@ async function purchaseAitIapProduct(input) {
1395
1605
  return granted;
1396
1606
  });
1397
1607
  grantAttempts.set(orderId, attempt);
1608
+ trackWrite(attempt);
1398
1609
  return attempt;
1399
1610
  };
1611
+ const completeAttempt = async (orderId, source) => {
1612
+ const persisted = await persistAitIapPurchaseAttempt({
1613
+ storage: input.dependencies.storage,
1614
+ product: input.product,
1615
+ idempotencyKey: input.idempotencyKey,
1616
+ orderId,
1617
+ status: aitIapPurchaseAttemptStatus.completed,
1618
+ source,
1619
+ timeoutMs: input.timeoutMs,
1620
+ });
1621
+ if (!persisted) {
1622
+ return pendingPurchase(orderId, aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true));
1623
+ }
1624
+ void input.retainCompletedAttempt(input.dependencies.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey));
1625
+ return completedAitIapPurchase(input.product, orderId, source);
1626
+ };
1627
+ /**
1628
+ * The SDK reports PRODUCT_NOT_GRANTED_BY_PARTNER after a real payment whose
1629
+ * grant callback returned false or ran out of time. The pending-order list
1630
+ * can lag right after checkout, so verify this exact order once more and
1631
+ * acknowledge it directly instead of waiting for a later restore.
1632
+ */
1633
+ const recoverNotGrantedOrder = async (orderId, providerCode) => {
1634
+ const startedAt = Date.now();
1635
+ const priorAttempt = grantAttempts.get(orderId);
1636
+ let granted = priorAttempt === undefined ? false : await priorAttempt;
1637
+ if (!granted) {
1638
+ granted = await verifyAndPersistAitIapProductGrant({
1639
+ verifier: input.verifier,
1640
+ storage: input.dependencies.storage,
1641
+ orderId,
1642
+ product: input.product,
1643
+ idempotencyKey: input.idempotencyKey,
1644
+ orderIdempotencyKey: createAitIapOrderIdempotencyKey(orderId),
1645
+ source: 'pending-order-restore',
1646
+ trackWrite,
1647
+ timeoutMs: input.timeoutMs,
1648
+ });
1649
+ }
1650
+ if (!granted) {
1651
+ return pendingPurchase(orderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true, providerCode));
1652
+ }
1653
+ grantedOrderId = orderId;
1654
+ // Durably finish this client attempt before acknowledging: once
1655
+ // acknowledged, the order leaves the pending list and recovery could no
1656
+ // longer finish the marker. Without the marker, do not acknowledge.
1657
+ const completed = await completeAttempt(orderId, 'pending-order-restore');
1658
+ if (completed.status !== 'completed') {
1659
+ return completed;
1660
+ }
1661
+ const acknowledgement = await acknowledgeAitIapOrder({
1662
+ dependencies: input.dependencies,
1663
+ configuredSkus: input.products.bySku,
1664
+ orderId,
1665
+ product: input.product,
1666
+ idempotencyKey: input.idempotencyKey,
1667
+ timeoutMs: remainingAitIapOperationTimeout(startedAt, input.timeoutMs),
1668
+ });
1669
+ if (acknowledgement.status !== 'acknowledged') {
1670
+ // The server grant and the client marker are durable; restore will
1671
+ // acknowledge the still-pending provider order later.
1672
+ return pendingPurchase(orderId, acknowledgement.diagnostic);
1673
+ }
1674
+ await removeAitIapOrderAttemptLink(input.dependencies.storage, orderId, remainingAitIapOperationTimeout(startedAt, input.timeoutMs));
1675
+ return completed;
1676
+ };
1400
1677
  sessionTimeout = globalThis.setTimeout(() => {
1401
1678
  // The native SDK may have dispatched an order even when it fails to
1402
1679
  // deliver a terminal callback. Preserve the marker and let retry or
1403
1680
  // restore reconcile it instead of opening a second checkout.
1404
- finish(pendingPurchase(grantedOrderId));
1681
+ finish(pendingPurchase(grantedOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.checkoutTimeout, true)));
1405
1682
  }, defaultIapPurchaseSessionTimeoutMs);
1406
1683
  try {
1407
1684
  cleanup = input.dependencies.iap.createOneTimePurchaseOrder({
@@ -1410,26 +1687,50 @@ async function purchaseAitIapProduct(input) {
1410
1687
  processProductGrant: ({ orderId }) => processProductGrant(orderId),
1411
1688
  },
1412
1689
  onEvent: async ({ data }) => {
1690
+ if (settled) {
1691
+ // A late terminal callback must not write after the result is decided.
1692
+ return;
1693
+ }
1413
1694
  const granted = await (grantAttempts.get(data.orderId) ?? processProductGrant(data.orderId));
1414
- if (!granted) {
1415
- finish(pendingPurchase(providerOrderId));
1695
+ if (settled) {
1416
1696
  return;
1417
1697
  }
1418
- const result = completedAitIapPurchase(input.product, data.orderId);
1419
- const persisted = await persistAitIapPurchaseAttempt({
1420
- storage: input.dependencies.storage,
1421
- product: input.product,
1422
- idempotencyKey: input.idempotencyKey,
1423
- orderId: data.orderId,
1424
- status: aitIapPurchaseAttemptStatus.completed,
1425
- timeoutMs: input.timeoutMs,
1426
- });
1427
- if (persisted) {
1428
- void input.retainCompletedAttempt(input.dependencies.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey));
1698
+ if (!granted) {
1699
+ finish(pendingPurchase(providerOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true)));
1700
+ return;
1429
1701
  }
1430
- finish(persisted ? result : pendingPurchase(data.orderId));
1702
+ // Tracked before awaiting, so another terminal path that finishes
1703
+ // meanwhile still waits for these writes before releasing the lock.
1704
+ const completion = (async () => {
1705
+ const completed = await completeAttempt(data.orderId, 'process-product-grant');
1706
+ if (completed.status === 'completed') {
1707
+ // The SDK acknowledged the order after the grant callback, and
1708
+ // the attempt marker is completed: the link is no longer needed.
1709
+ await removeAitIapOrderAttemptLink(input.dependencies.storage, data.orderId, input.timeoutMs);
1710
+ }
1711
+ return completed;
1712
+ })();
1713
+ trackWrite(completion);
1714
+ finish(await completion);
1431
1715
  },
1432
1716
  onError: (error) => {
1717
+ if (settled) {
1718
+ // A late native error must not start recovery after the result
1719
+ // (and the IAP lock) is released.
1720
+ return;
1721
+ }
1722
+ const providerCode = readAitIapProviderCode(error);
1723
+ const knownOrderId = providerOrderId;
1724
+ if (providerCode === aitIapProductNotGrantedByPartnerCode
1725
+ && knownOrderId !== undefined
1726
+ && !markerDeletionStarted) {
1727
+ const recovery = recoverNotGrantedOrder(knownOrderId, providerCode);
1728
+ trackWrite(recovery);
1729
+ void recovery.then(finish, () => {
1730
+ finish(pendingPurchase(knownOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true, providerCode)));
1731
+ });
1732
+ return;
1733
+ }
1433
1734
  if (grantedOrderId !== undefined
1434
1735
  || providerOrderId !== undefined
1435
1736
  || grantAttempts.size > 0
@@ -1437,7 +1738,10 @@ async function purchaseAitIapProduct(input) {
1437
1738
  // A cancellation racing an authoritative grant is ambiguous. Keep
1438
1739
  // the durable client retry barrier until retry/restore observes
1439
1740
  // the verifier's eventual result.
1440
- finish(pendingPurchase(grantedOrderId ?? providerOrderId));
1741
+ const orderObserved = providerOrderId !== undefined || grantAttempts.size > 0;
1742
+ finish(pendingPurchase(grantedOrderId ?? providerOrderId, aitIapDiagnostic(orderObserved
1743
+ ? aitIapDiagnosticCodes.grantPending
1744
+ : aitIapDiagnosticCodes.nativePurchaseFailed, true, providerCode)));
1441
1745
  return;
1442
1746
  }
1443
1747
  startMarkerDeletion(cancelledPurchase());
@@ -1447,23 +1751,46 @@ async function purchaseAitIapProduct(input) {
1447
1751
  safelyCleanupAitIapPurchase(cleanup);
1448
1752
  }
1449
1753
  }
1450
- catch {
1754
+ catch (error) {
1451
1755
  // A malformed SDK can invoke a grant callback and then throw during
1452
1756
  // registration. Preserve that active order; only a throw with no grant
1453
1757
  // startup proves there is no authoritative commit racing deletion.
1758
+ const providerCode = readAitIapProviderCode(error);
1454
1759
  if (providerOrderId !== undefined || grantAttempts.size > 0) {
1455
- finish(pendingPurchase(providerOrderId));
1760
+ finish(pendingPurchase(providerOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true, providerCode)));
1456
1761
  return;
1457
1762
  }
1458
- startMarkerDeletion(failedPurchase());
1763
+ startMarkerDeletion(failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.checkoutStartFailed, true, providerCode)));
1459
1764
  }
1460
1765
  });
1461
1766
  }
1767
+ /**
1768
+ * Decides whether recovery found a reason not to open a new checkout for this
1769
+ * product. Orders for other configured products are still reconciled, but only
1770
+ * a same-product order can turn this purchase into a duplicate charge.
1771
+ */
1772
+ function resolveAitIapCheckoutBarrier(reconciliation, product) {
1773
+ if (reconciliation.status === 'unavailable') {
1774
+ return reconciliation.diagnostic.code === aitIapDiagnosticCodes.unsupportedAppVersion
1775
+ ? reconciliation.diagnostic
1776
+ : aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true, reconciliation.diagnostic.providerCode);
1777
+ }
1778
+ if (reconciliation.unresolvedOrders.some((order) => order.sku === product.sku)) {
1779
+ return aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderUnresolved, true, reconciliation.diagnostic?.providerCode);
1780
+ }
1781
+ if (reconciliation.settledPurchases.some((settlement) => settlement.productId === product.productId)) {
1782
+ return aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderRecovered, true);
1783
+ }
1784
+ return undefined;
1785
+ }
1462
1786
  async function restoreAitIapProducts(input) {
1463
1787
  const prepare = input.prepare;
1464
1788
  const verifier = input.verifier;
1465
1789
  if (!isAitIapSupported(input) || prepare === undefined || verifier === undefined) {
1466
- return { restoredEntitlements: [] };
1790
+ return {
1791
+ status: 'failed',
1792
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.unavailable, false),
1793
+ };
1467
1794
  }
1468
1795
  const restoreStartedAt = Date.now();
1469
1796
  const getRemainingRestoreTimeout = () => {
@@ -1471,53 +1798,212 @@ async function restoreAitIapProducts(input) {
1471
1798
  };
1472
1799
  const prepared = await prepareAitIap(prepare, { intent: 'restore' }, getRemainingRestoreTimeout());
1473
1800
  if (!prepared) {
1474
- return { restoredEntitlements: [] };
1801
+ return {
1802
+ status: 'failed',
1803
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.preparationFailed, true),
1804
+ };
1475
1805
  }
1476
1806
  const postPrepareTimeoutMs = getRemainingRestoreTimeout();
1477
1807
  const reservedEntitlementReadTimeoutMs = Math.max(1, Math.ceil(postPrepareTimeoutMs / 3));
1478
1808
  const reconciliationTimeoutMs = Math.max(0, postPrepareTimeoutMs - reservedEntitlementReadTimeoutMs);
1479
- const reconciliationStartedAt = Date.now();
1480
- const getRemainingReconciliationTimeout = () => {
1481
- return Math.min(getRemainingRestoreTimeout(), remainingAitIapOperationTimeout(reconciliationStartedAt, reconciliationTimeoutMs));
1482
- };
1483
- const finishRestore = async (locallyRestoredEntitlements = []) => {
1484
- const entitlementTimeoutMs = getRemainingRestoreTimeout();
1485
- const authoritativeEntitlements = await readAitIapEntitlements(input.entitlementReader, entitlementTimeoutMs);
1809
+ if (reconciliationTimeoutMs === 0) {
1486
1810
  return {
1487
- restoredEntitlements: mergeAitIapRestoredEntitlements(locallyRestoredEntitlements, authoritativeEntitlements, input.products),
1811
+ status: 'failed',
1812
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.restoreTimeout, true),
1488
1813
  };
1814
+ }
1815
+ const reconciliation = await input.reconcilePendingOrders(verifier, Math.min(reconciliationTimeoutMs, getRemainingRestoreTimeout()));
1816
+ if (reconciliation.status === 'unavailable') {
1817
+ // Restore exists to settle paid orders. If the provider list cannot be
1818
+ // read, report that instead of an empty success the game cannot tell
1819
+ // apart from "nothing to restore".
1820
+ return { status: 'failed', diagnostic: reconciliation.diagnostic };
1821
+ }
1822
+ const authoritativeEntitlements = await readAitIapEntitlementsIfAvailable(input.entitlementReader, getRemainingRestoreTimeout());
1823
+ const { settledPurchases } = reconciliation;
1824
+ if (authoritativeEntitlements === undefined && settledPurchases.length === 0) {
1825
+ return {
1826
+ status: 'failed',
1827
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.entitlementReadFailed, true),
1828
+ };
1829
+ }
1830
+ let diagnostic;
1831
+ if (authoritativeEntitlements === undefined) {
1832
+ diagnostic = aitIapDiagnostic(aitIapDiagnosticCodes.entitlementReadFailed, true);
1833
+ }
1834
+ else if (reconciliation.unresolvedOrders.length > 0) {
1835
+ diagnostic = aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderUnresolved, true, reconciliation.diagnostic?.providerCode);
1836
+ }
1837
+ return {
1838
+ status: 'ok',
1839
+ result: {
1840
+ restoredEntitlements: mergeAitIapRestoredEntitlements(reconciliation.restoredEntitlements, authoritativeEntitlements ?? [], input.products),
1841
+ ...(settledPurchases.length === 0 ? {} : { settledPurchases }),
1842
+ ...(diagnostic === undefined ? {} : { diagnostic }),
1843
+ },
1489
1844
  };
1490
- if (reconciliationTimeoutMs === 0) {
1491
- return await finishRestore();
1845
+ }
1846
+ /**
1847
+ * Verifies and acknowledges provider orders for configured SKUs that are paid
1848
+ * but not yet granted. Work is bounded per call and rotated across calls so a
1849
+ * permanently rejected order cannot starve later ones.
1850
+ */
1851
+ async function reconcileAitIapPendingOrders(input) {
1852
+ const startedAt = Date.now();
1853
+ const getRemainingTimeout = () => {
1854
+ return remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
1855
+ };
1856
+ const pendingOrderTimeoutMs = getRemainingTimeout();
1857
+ if (pendingOrderTimeoutMs === 0) {
1858
+ return {
1859
+ status: 'unavailable',
1860
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true),
1861
+ };
1492
1862
  }
1493
- let pendingOrders;
1494
- try {
1495
- const pendingOrderTimeoutMs = getRemainingReconciliationTimeout();
1496
- if (pendingOrderTimeoutMs === 0) {
1497
- return await finishRestore();
1498
- }
1499
- pendingOrders = await waitForAitIapNativeCall(() => input.dependencies.iap.getPendingOrders(), pendingOrderTimeoutMs);
1863
+ const pendingOrders = await callAitIapNative(() => joinAitIapNativeRead(input.dependencies.iap, 'getPendingOrders', () => input.dependencies.iap.getPendingOrders()), pendingOrderTimeoutMs);
1864
+ if (pendingOrders.status !== 'ok') {
1865
+ return {
1866
+ status: 'unavailable',
1867
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true, pendingOrders.status === 'error' ? pendingOrders.providerCode : undefined),
1868
+ };
1500
1869
  }
1501
- catch {
1502
- return await finishRestore();
1870
+ const pendingOrderResult = pendingOrders.value;
1871
+ if (!isRecord(pendingOrderResult)) {
1872
+ return {
1873
+ status: 'unavailable',
1874
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.unsupportedAppVersion, false),
1875
+ };
1503
1876
  }
1504
- if (pendingOrders === undefined) {
1505
- return await finishRestore();
1877
+ if (!Array.isArray(pendingOrderResult.orders)) {
1878
+ // A malformed list is not proof that no paid order exists.
1879
+ return {
1880
+ status: 'unavailable',
1881
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true),
1882
+ };
1506
1883
  }
1884
+ const nativeOrders = pendingOrderResult.orders;
1507
1885
  const eligibleOrders = [];
1508
1886
  const seenOrderIds = new Set();
1509
- for (const order of pendingOrders.orders) {
1887
+ const malformedPendingOrders = {
1888
+ status: 'unavailable',
1889
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true),
1890
+ };
1891
+ /** Provider-reported SKU per pending order id. */
1892
+ const providerPendingOrderSkus = new Map();
1893
+ for (const order of nativeOrders) {
1894
+ // An entry that might be a configured product's paid order is never
1895
+ // proof of absence. Only well-formed entries for other SKUs are ignored.
1896
+ if (!isRecord(order) || typeof order.sku !== 'string') {
1897
+ return malformedPendingOrders;
1898
+ }
1899
+ if (typeof order.orderId === 'string') {
1900
+ const knownSku = providerPendingOrderSkus.get(order.orderId);
1901
+ if (knownSku !== undefined && knownSku !== order.sku) {
1902
+ // One order id reported with two SKUs cannot be attributed safely.
1903
+ return malformedPendingOrders;
1904
+ }
1905
+ providerPendingOrderSkus.set(order.orderId, order.sku);
1906
+ }
1510
1907
  const product = input.products.bySku.get(order.sku);
1511
- if (product === undefined
1512
- || !isAitIapOrderId(order.orderId)
1513
- || seenOrderIds.has(order.orderId)) {
1908
+ if (product === undefined) {
1909
+ continue;
1910
+ }
1911
+ if (typeof order.orderId !== 'string' || !isAitIapOrderId(order.orderId)) {
1912
+ return malformedPendingOrders;
1913
+ }
1914
+ if (seenOrderIds.has(order.orderId)) {
1514
1915
  continue;
1515
1916
  }
1516
1917
  seenOrderIds.add(order.orderId);
1517
- eligibleOrders.push({ order, product });
1918
+ eligibleOrders.push({
1919
+ order: {
1920
+ orderId: order.orderId,
1921
+ sku: order.sku,
1922
+ paymentCompletedDate: typeof order.paymentCompletedDate === 'string'
1923
+ ? order.paymentCompletedDate
1924
+ : '',
1925
+ },
1926
+ product,
1927
+ });
1518
1928
  }
1929
+ const settledPurchases = [];
1519
1930
  const restoredEntitlements = [];
1520
- const cursorTimeoutMs = getRemainingReconciliationTimeout();
1931
+ const unresolvedOrders = [];
1932
+ let diagnostic;
1933
+ // A barrier whose order the provider no longer lists is settled only when
1934
+ // the server confirms the grant again; otherwise it stays an unresolved
1935
+ // same-product barrier. A settled barrier is reported (keeping the
1936
+ // same-product checkout barrier for this pass) and only then dropped,
1937
+ // together with its order link. Barriers for SKUs that are no longer
1938
+ // configured are kept but not resolved here.
1939
+ const acknowledgementReadTimeoutMs = getRemainingTimeout();
1940
+ const acknowledgements = acknowledgementReadTimeoutMs === 0
1941
+ ? undefined
1942
+ : await readAitIapAcknowledgementsInFlight(input.dependencies.storage, acknowledgementReadTimeoutMs);
1943
+ if (acknowledgements === undefined) {
1944
+ return malformedPendingOrders;
1945
+ }
1946
+ const confirmedAcknowledgements = [];
1947
+ for (const entry of acknowledgements) {
1948
+ const pendingSku = providerPendingOrderSkus.get(entry.orderId);
1949
+ if (pendingSku !== undefined) {
1950
+ if (pendingSku !== entry.sku) {
1951
+ // The provider lists this barrier's order under another product. Do
1952
+ // not let it stop guarding its own product: fail closed instead.
1953
+ return malformedPendingOrders;
1954
+ }
1955
+ continue;
1956
+ }
1957
+ const product = input.products.bySku.get(entry.sku);
1958
+ if (product === undefined) {
1959
+ continue;
1960
+ }
1961
+ // Absence alone never settles a barrier, whatever its phase: a phase
1962
+ // write can land late without a dispatch, and the list can lag. Only the
1963
+ // server confirming the grant for this order settles it.
1964
+ const verificationTimeoutMs = getRemainingTimeout();
1965
+ const confirmed = verificationTimeoutMs > 0 && await verifyAitIapProductGrant({
1966
+ verifier: input.verifier,
1967
+ orderId: entry.orderId,
1968
+ product,
1969
+ idempotencyKey: createAitIapOrderIdempotencyKey(entry.orderId),
1970
+ ...(entry.idempotencyKey === undefined
1971
+ ? {}
1972
+ : { clientIdempotencyKey: entry.idempotencyKey }),
1973
+ source: 'pending-order-restore',
1974
+ timeoutMs: verificationTimeoutMs,
1975
+ });
1976
+ if (!confirmed) {
1977
+ unresolvedOrders.push({ orderId: entry.orderId, sku: entry.sku });
1978
+ diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true);
1979
+ continue;
1980
+ }
1981
+ confirmedAcknowledgements.push(entry);
1982
+ settledPurchases.push({
1983
+ transactionId: entry.orderId,
1984
+ productId: product.productId,
1985
+ status: 'granted',
1986
+ ...(entry.idempotencyKey === undefined ? {} : { idempotencyKey: entry.idempotencyKey }),
1987
+ });
1988
+ restoredEntitlements.push({
1989
+ id: product.productId,
1990
+ source: 'purchase',
1991
+ grantedAt: new Date().toISOString(),
1992
+ });
1993
+ }
1994
+ if (confirmedAcknowledgements.length > 0) {
1995
+ const clearTimeoutMs = getRemainingTimeout();
1996
+ if (clearTimeoutMs > 0) {
1997
+ // A failed clear only reports the same settled order again next time.
1998
+ const cleared = await removeAitIapAcknowledgementsInFlight(input.dependencies.storage, confirmedAcknowledgements.map(({ orderId }) => orderId), clearTimeoutMs);
1999
+ if (cleared) {
2000
+ for (const { orderId } of confirmedAcknowledgements) {
2001
+ await removeAitIapOrderAttemptLink(input.dependencies.storage, orderId, getRemainingTimeout());
2002
+ }
2003
+ }
2004
+ }
2005
+ }
2006
+ const cursorTimeoutMs = getRemainingTimeout();
1521
2007
  const cursor = cursorTimeoutMs === 0
1522
2008
  ? undefined
1523
2009
  : await readAitIapPendingOrderCursor(input.dependencies.storage, cursorTimeoutMs);
@@ -1527,53 +2013,492 @@ async function restoreAitIapProducts(input) {
1527
2013
  limit: maximumPendingIapOrders,
1528
2014
  ignoredOrderCount: eligibleOrders.length - orders.length,
1529
2015
  });
2016
+ const selectedOrderIds = new Set(orders.map(({ order }) => order.orderId));
2017
+ for (const { order } of eligibleOrders) {
2018
+ if (!selectedOrderIds.has(order.orderId)) {
2019
+ unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
2020
+ }
2021
+ }
1530
2022
  }
1531
- for (const selectedOrder of orders) {
2023
+ for (const [index, selectedOrder] of orders.entries()) {
1532
2024
  const { order, product } = selectedOrder;
1533
- const cursorWriteTimeoutMs = getRemainingReconciliationTimeout();
2025
+ const deferRemaining = () => {
2026
+ for (const deferred of orders.slice(index)) {
2027
+ unresolvedOrders.push({ orderId: deferred.order.orderId, sku: deferred.order.sku });
2028
+ }
2029
+ diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.restoreTimeout, true);
2030
+ };
2031
+ const cursorWriteTimeoutMs = getRemainingTimeout();
1534
2032
  if (cursorWriteTimeoutMs === 0) {
2033
+ deferRemaining();
1535
2034
  break;
1536
2035
  }
1537
2036
  const cursorAdvanced = await writeAitIapPendingOrderCursor(input.dependencies.storage, order.orderId, cursorWriteTimeoutMs);
1538
2037
  if (!cursorAdvanced) {
2038
+ deferRemaining();
2039
+ break;
2040
+ }
2041
+ const linkTimeoutMs = getRemainingTimeout();
2042
+ if (linkTimeoutMs === 0) {
2043
+ deferRemaining();
1539
2044
  break;
1540
2045
  }
1541
- const verificationTimeoutMs = getRemainingReconciliationTimeout();
2046
+ const link = await readAitIapOrderAttemptLink({
2047
+ storage: input.dependencies.storage,
2048
+ product,
2049
+ orderId: order.orderId,
2050
+ timeoutMs: linkTimeoutMs,
2051
+ });
2052
+ if (link.status === 'unavailable') {
2053
+ unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
2054
+ diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true);
2055
+ continue;
2056
+ }
2057
+ const clientIdempotencyKey = link.status === 'linked' ? link.idempotencyKey : undefined;
2058
+ const verificationTimeoutMs = getRemainingTimeout();
1542
2059
  if (verificationTimeoutMs === 0) {
2060
+ deferRemaining();
1543
2061
  break;
1544
2062
  }
1545
2063
  const granted = await verifyAitIapProductGrant({
1546
- verifier,
2064
+ verifier: input.verifier,
1547
2065
  orderId: order.orderId,
1548
2066
  product,
1549
2067
  idempotencyKey: createAitIapOrderIdempotencyKey(order.orderId),
2068
+ ...(clientIdempotencyKey === undefined ? {} : { clientIdempotencyKey }),
1550
2069
  source: 'pending-order-restore',
1551
2070
  timeoutMs: verificationTimeoutMs,
1552
2071
  });
1553
2072
  if (!granted) {
2073
+ unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
2074
+ diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true);
1554
2075
  continue;
1555
2076
  }
1556
- try {
1557
- const completionTimeoutMs = getRemainingReconciliationTimeout();
1558
- if (completionTimeoutMs === 0) {
1559
- break;
1560
- }
1561
- const completed = await waitForAitIapNativeCall(() => input.dependencies.iap.completeProductGrant({
1562
- params: { orderId: order.orderId },
1563
- }), completionTimeoutMs);
1564
- if (completed === true) {
1565
- restoredEntitlements.push({
1566
- id: product.productId,
1567
- source: 'purchase',
1568
- grantedAt: normalizeAitIapGrantTime(order.paymentCompletedDate),
2077
+ if (clientIdempotencyKey !== undefined) {
2078
+ // Secure the client attempt before acknowledging: once acknowledged, the
2079
+ // order leaves the pending list and could no longer finish this marker.
2080
+ const markerTimeoutMs = getRemainingTimeout();
2081
+ const marker = markerTimeoutMs === 0
2082
+ ? 'failed'
2083
+ : await completeLinkedAitIapPurchaseAttempt({
2084
+ storage: input.dependencies.storage,
2085
+ product,
2086
+ idempotencyKey: clientIdempotencyKey,
2087
+ orderId: order.orderId,
2088
+ retainCompletedAttempt: input.retainCompletedAttempt,
2089
+ timeoutMs: markerTimeoutMs,
1569
2090
  });
2091
+ if (marker === 'failed') {
2092
+ unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
2093
+ diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true);
2094
+ continue;
1570
2095
  }
1571
2096
  }
1572
- catch {
2097
+ const completionTimeoutMs = getRemainingTimeout();
2098
+ if (completionTimeoutMs === 0) {
2099
+ deferRemaining();
2100
+ break;
2101
+ }
2102
+ const acknowledgement = await acknowledgeAitIapOrder({
2103
+ dependencies: input.dependencies,
2104
+ configuredSkus: input.products.bySku,
2105
+ orderId: order.orderId,
2106
+ product,
2107
+ ...(clientIdempotencyKey === undefined ? {} : { idempotencyKey: clientIdempotencyKey }),
2108
+ timeoutMs: completionTimeoutMs,
2109
+ });
2110
+ if (acknowledgement.status !== 'acknowledged') {
1573
2111
  // Keep provider state pending when its completion acknowledgement fails.
2112
+ unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
2113
+ diagnostic ??= acknowledgement.diagnostic;
2114
+ continue;
2115
+ }
2116
+ if (link.status === 'linked') {
2117
+ await removeAitIapOrderAttemptLink(input.dependencies.storage, order.orderId, getRemainingTimeout());
2118
+ }
2119
+ settledPurchases.push({
2120
+ transactionId: order.orderId,
2121
+ productId: product.productId,
2122
+ status: 'granted',
2123
+ ...(clientIdempotencyKey === undefined ? {} : { idempotencyKey: clientIdempotencyKey }),
2124
+ });
2125
+ restoredEntitlements.push({
2126
+ id: product.productId,
2127
+ source: 'purchase',
2128
+ grantedAt: normalizeAitIapGrantTime(order.paymentCompletedDate),
2129
+ });
2130
+ }
2131
+ return {
2132
+ status: 'reconciled',
2133
+ settledPurchases,
2134
+ restoredEntitlements,
2135
+ unresolvedOrders,
2136
+ ...(diagnostic === undefined ? {} : { diagnostic }),
2137
+ };
2138
+ }
2139
+ /**
2140
+ * Finishes the retry barrier of the client attempt that opened a recovered
2141
+ * order, so replaying that client key reports the completed purchase instead
2142
+ * of staying pending or reopening a checkout. It runs after the server grant
2143
+ * and before the provider acknowledgement. `failed` means the marker could not
2144
+ * be read or written and the order must stay unacknowledged; `not-applicable`
2145
+ * means no marker for this order needs finishing.
2146
+ */
2147
+ async function completeLinkedAitIapPurchaseAttempt(input) {
2148
+ const startedAt = Date.now();
2149
+ const storageKey = aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey);
2150
+ const read = await readAitIapStorage(input.storage, storageKey, input.timeoutMs);
2151
+ if (read.status !== 'ok') {
2152
+ return 'failed';
2153
+ }
2154
+ // A linked attempt without a marker would let a replay of its key open a
2155
+ // new checkout for an order that is about to be acknowledged, so write the
2156
+ // completed marker in that case too.
2157
+ if (read.value !== null) {
2158
+ let state;
2159
+ try {
2160
+ state = JSON.parse(read.value);
2161
+ }
2162
+ catch {
2163
+ return 'failed';
2164
+ }
2165
+ if (!isAitIapMarkerForAttempt(state, input.product, input.idempotencyKey)) {
2166
+ // A corrupted linked marker cannot be finished; keep the order pending.
2167
+ return 'failed';
2168
+ }
2169
+ if (!isLinkedAitIapAttemptMarker(state, input.orderId)) {
2170
+ return 'not-applicable';
2171
+ }
2172
+ }
2173
+ const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
2174
+ if (remainingTimeoutMs === 0) {
2175
+ return 'failed';
2176
+ }
2177
+ const persisted = await persistAitIapPurchaseAttempt({
2178
+ storage: input.storage,
2179
+ product: input.product,
2180
+ idempotencyKey: input.idempotencyKey,
2181
+ orderId: input.orderId,
2182
+ status: aitIapPurchaseAttemptStatus.completed,
2183
+ source: 'pending-order-restore',
2184
+ timeoutMs: remainingTimeoutMs,
2185
+ });
2186
+ if (!persisted) {
2187
+ return 'failed';
2188
+ }
2189
+ void input.retainCompletedAttempt(input.storage, storageKey);
2190
+ return 'completed';
2191
+ }
2192
+ /**
2193
+ * Acknowledges a server-granted order with `completeProductGrant()`. The
2194
+ * order is first recorded as in flight, so a timed-out acknowledgement that
2195
+ * succeeds later still leaves a same-product checkout barrier. The record is
2196
+ * removed after a confirmed success or a definitive rejection; after a
2197
+ * timeout it stays until reconciliation sees the order leave the provider
2198
+ * list. Without the record, the order is not acknowledged.
2199
+ */
2200
+ /**
2201
+ * Native `completeProductGrant()` calls that have not settled yet, per IAP
2202
+ * dependency object and order. A retry for the same order joins the running
2203
+ * call instead of dispatching a second acknowledgement.
2204
+ */
2205
+ const aitIapAcknowledgementsRunning = new WeakMap();
2206
+ function dispatchAitIapAcknowledgement(iap, orderId) {
2207
+ let running = aitIapAcknowledgementsRunning.get(iap);
2208
+ if (running === undefined) {
2209
+ running = new Map();
2210
+ aitIapAcknowledgementsRunning.set(iap, running);
2211
+ }
2212
+ const existing = running.get(orderId);
2213
+ if (existing !== undefined) {
2214
+ return existing;
2215
+ }
2216
+ const calls = running;
2217
+ const call = Promise.resolve()
2218
+ .then(() => iap.completeProductGrant({ params: { orderId } }))
2219
+ .then((value) => ({ status: 'ok', value: value === true }), (error) => {
2220
+ const providerCode = readAitIapProviderCode(error);
2221
+ return providerCode === undefined ? { status: 'error' } : { status: 'error', providerCode };
2222
+ })
2223
+ .finally(() => {
2224
+ if (calls.get(orderId) === call) {
2225
+ calls.delete(orderId);
2226
+ }
2227
+ });
2228
+ calls.set(orderId, call);
2229
+ return call;
2230
+ }
2231
+ function isAitIapAcknowledgementRunning(iap, orderId) {
2232
+ return aitIapAcknowledgementsRunning.get(iap)?.has(orderId) === true;
2233
+ }
2234
+ async function acknowledgeAitIapOrder(input) {
2235
+ const startedAt = Date.now();
2236
+ const remaining = () => remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
2237
+ const barrierUnavailable = {
2238
+ status: 'not-acknowledged',
2239
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true),
2240
+ };
2241
+ const storage = input.dependencies.storage;
2242
+ const entries = remaining() === 0
2243
+ ? undefined
2244
+ : await readAitIapAcknowledgementsInFlight(storage, remaining());
2245
+ if (entries === undefined) {
2246
+ return barrierUnavailable;
2247
+ }
2248
+ const iap = input.dependencies.iap;
2249
+ const existing = entries.find((entry) => entry.orderId === input.orderId);
2250
+ const otherEntries = entries.filter((entry) => entry.orderId !== input.orderId);
2251
+ const activeCount = otherEntries
2252
+ .filter((entry) => input.configuredSkus.has(entry.sku)).length;
2253
+ if (activeCount >= maximumIapAcknowledgementsInFlight) {
2254
+ return barrierUnavailable;
2255
+ }
2256
+ const retired = otherEntries.filter((entry) => !input.configuredSkus.has(entry.sku));
2257
+ const droppedRetiredOrderIds = new Set(retired
2258
+ .slice(0, Math.max(0, retired.length - maximumRetiredIapAcknowledgements))
2259
+ .map((entry) => entry.orderId));
2260
+ const others = otherEntries.filter((entry) => !droppedRetiredOrderIds.has(entry.orderId));
2261
+ // A `started` barrier stays as written: a running acknowledgement is
2262
+ // joined, and a fresh dispatch (only possible once no call is running, for
2263
+ // example after a reload) reuses the existing barrier.
2264
+ const barrierStarted = existing?.phase === 'started';
2265
+ if (!barrierStarted && isAitIapAcknowledgementRunning(iap, input.orderId)) {
2266
+ // A call is running without a durable `started` barrier; never touch it.
2267
+ return barrierUnavailable;
2268
+ }
2269
+ const withPhase = (phase) => JSON.stringify([
2270
+ ...others,
2271
+ {
2272
+ orderId: input.orderId,
2273
+ sku: input.product.sku,
2274
+ ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
2275
+ phase,
2276
+ },
2277
+ ]);
2278
+ if (!barrierStarted) {
2279
+ // Two durable steps: the barrier first, then the dispatch mark. Only a
2280
+ // record marked `started` may later be confirmed by the order's absence.
2281
+ const recorded = remaining() > 0 && await writeAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, withPhase('recorded'), remaining());
2282
+ if (!recorded) {
2283
+ return barrierUnavailable;
2284
+ }
2285
+ const dispatchMarked = remaining() > 0 && await writeAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, withPhase('started'), remaining());
2286
+ if (!dispatchMarked) {
2287
+ return barrierUnavailable;
1574
2288
  }
1575
2289
  }
1576
- return await finishRestore(restoredEntitlements);
2290
+ const completionTimeoutMs = remaining();
2291
+ const completion = completionTimeoutMs === 0
2292
+ ? { status: 'timeout' }
2293
+ : await callAitIapNative(() => dispatchAitIapAcknowledgement(iap, input.orderId), completionTimeoutMs).then((outcome) => (outcome.status === 'ok' ? outcome.value : outcome));
2294
+ if (completion.status === 'ok' && completion.value) {
2295
+ // Only a confirmed acknowledgement clears a `started` barrier here.
2296
+ await removeAitIapAcknowledgementsInFlight(storage, [input.orderId], Math.max(1, remaining()));
2297
+ return { status: 'acknowledged' };
2298
+ }
2299
+ // A timeout, `false` or an error leaves the barrier `started`: an earlier
2300
+ // or still-running dispatch may yet succeed. The order stays unresolved
2301
+ // until a confirmed acknowledgement or the order leaving the provider list.
2302
+ if (completion.status === 'timeout') {
2303
+ return {
2304
+ status: 'not-acknowledged',
2305
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.grantCompletionFailed, true),
2306
+ };
2307
+ }
2308
+ return {
2309
+ status: 'not-acknowledged',
2310
+ diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.grantCompletionFailed, true, completion.status === 'error' ? completion.providerCode : undefined),
2311
+ };
2312
+ }
2313
+ /** Returns undefined when the list cannot be read or is malformed. */
2314
+ async function readAitIapAcknowledgementsInFlight(storage, timeoutMs) {
2315
+ const read = await readAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, timeoutMs);
2316
+ if (read.status !== 'ok') {
2317
+ return undefined;
2318
+ }
2319
+ if (read.value === null) {
2320
+ return [];
2321
+ }
2322
+ try {
2323
+ const value = JSON.parse(read.value);
2324
+ if (!Array.isArray(value)) {
2325
+ return undefined;
2326
+ }
2327
+ const entries = [];
2328
+ for (const entry of value) {
2329
+ if (!isRecord(entry)
2330
+ || typeof entry.orderId !== 'string'
2331
+ || !isAitIapOrderId(entry.orderId)
2332
+ || typeof entry.sku !== 'string'
2333
+ || (entry.idempotencyKey !== undefined
2334
+ && !isAitIapClientIdempotencyKey(entry.idempotencyKey))
2335
+ || (entry.phase !== 'recorded' && entry.phase !== 'started')) {
2336
+ return undefined;
2337
+ }
2338
+ entries.push({
2339
+ orderId: entry.orderId,
2340
+ sku: entry.sku,
2341
+ ...(typeof entry.idempotencyKey === 'string'
2342
+ ? { idempotencyKey: entry.idempotencyKey }
2343
+ : {}),
2344
+ phase: entry.phase,
2345
+ });
2346
+ }
2347
+ return entries;
2348
+ }
2349
+ catch {
2350
+ return undefined;
2351
+ }
2352
+ }
2353
+ async function removeAitIapAcknowledgementsInFlight(storage, orderIds, timeoutMs) {
2354
+ const startedAt = Date.now();
2355
+ const entries = await readAitIapAcknowledgementsInFlight(storage, timeoutMs);
2356
+ const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
2357
+ if (entries === undefined || remainingTimeoutMs === 0) {
2358
+ return false;
2359
+ }
2360
+ const removed = new Set(orderIds);
2361
+ return await writeAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, JSON.stringify(entries.filter((entry) => !removed.has(entry.orderId))), remainingTimeoutMs);
2362
+ }
2363
+ /**
2364
+ * Per native storage object and adapter-reserved key, operations run strictly
2365
+ * one after another. Each starts only after every earlier operation on that
2366
+ * key has actually settled, not merely timed out, so a late-landing write can
2367
+ * never overwrite a newer one and a read never observes an absence that races
2368
+ * an in-flight write. A caller whose turn does not come within its deadline
2369
+ * gets `timeout` and its operation is never started. Idle keys are forgotten.
2370
+ */
2371
+ const aitIapStorageQueues = new WeakMap();
2372
+ async function runAitIapStorageOperation(storage, key, operation, timeoutMs) {
2373
+ const startedAt = Date.now();
2374
+ let queues = aitIapStorageQueues.get(storage);
2375
+ if (queues === undefined) {
2376
+ queues = new Map();
2377
+ aitIapStorageQueues.set(storage, queues);
2378
+ }
2379
+ const keyQueues = queues;
2380
+ let queue = keyQueues.get(key);
2381
+ if (queue === undefined) {
2382
+ queue = { tail: Promise.resolve(), active: 0, hung: false };
2383
+ keyQueues.set(key, queue);
2384
+ }
2385
+ const current = queue;
2386
+ if (current.hung) {
2387
+ return { status: 'timeout' };
2388
+ }
2389
+ const previous = current.tail;
2390
+ let release = () => { };
2391
+ const turn = new Promise((resolve) => {
2392
+ let released = false;
2393
+ release = () => {
2394
+ if (released) {
2395
+ return;
2396
+ }
2397
+ released = true;
2398
+ current.active -= 1;
2399
+ if (current.active === 0 && keyQueues.get(key) === current) {
2400
+ keyQueues.delete(key);
2401
+ }
2402
+ resolve();
2403
+ };
2404
+ });
2405
+ current.active += 1;
2406
+ current.tail = previous.then(() => turn);
2407
+ const ready = await callAitIapNative(() => previous, timeoutMs);
2408
+ const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
2409
+ if (ready.status !== 'ok' || remainingTimeoutMs === 0) {
2410
+ // Give up this turn without starting the operation. Later operations
2411
+ // still wait for the earlier ones through the tail chain.
2412
+ if (ready.status !== 'ok') {
2413
+ current.hung = true;
2414
+ }
2415
+ void previous.then(release);
2416
+ return { status: 'timeout' };
2417
+ }
2418
+ let started;
2419
+ try {
2420
+ started = Promise.resolve(operation());
2421
+ }
2422
+ catch (error) {
2423
+ release();
2424
+ const providerCode = readAitIapProviderCode(error);
2425
+ return providerCode === undefined ? { status: 'error' } : { status: 'error', providerCode };
2426
+ }
2427
+ void started.then(release, release);
2428
+ const outcome = await callAitIapNative(() => started, remainingTimeoutMs);
2429
+ if (outcome.status === 'timeout') {
2430
+ current.hung = true;
2431
+ }
2432
+ return outcome;
2433
+ }
2434
+ async function readAitIapStorage(storage, key, timeoutMs) {
2435
+ const read = await runAitIapStorageOperation(storage, key, () => storage.getItem(key), timeoutMs);
2436
+ if (read.status !== 'ok') {
2437
+ return read;
2438
+ }
2439
+ const value = read.value;
2440
+ return value === null || typeof value === 'string'
2441
+ ? { status: 'ok', value }
2442
+ : { status: 'error' };
2443
+ }
2444
+ /**
2445
+ * The caller already validated the order link for this client key and
2446
+ * product. A pending marker without an order id is the same attempt whose
2447
+ * order-correlation write failed while the link write succeeded.
2448
+ */
2449
+ function isLinkedAitIapAttemptMarker(state, orderId) {
2450
+ if (state.status === aitIapPurchaseAttemptStatus.serverGranted) {
2451
+ return state.orderId === orderId;
2452
+ }
2453
+ return state.status === aitIapPurchaseAttemptStatus.pending
2454
+ && (state.orderId === orderId || state.orderId === undefined);
2455
+ }
2456
+ async function writeAitIapOrderAttemptLink(input) {
2457
+ return await writeAitIapStorage(input.storage, aitIapOrderAttemptLinkStorageKey(input.orderId), JSON.stringify({
2458
+ productId: input.product.productId,
2459
+ idempotencyKey: input.idempotencyKey,
2460
+ }), input.timeoutMs);
2461
+ }
2462
+ /** Best effort: a link has no recovery role once its order is terminal. */
2463
+ async function removeAitIapOrderAttemptLink(storage, orderId, timeoutMs) {
2464
+ if (timeoutMs > 0) {
2465
+ await removeAitIapStorage(storage, aitIapOrderAttemptLinkStorageKey(orderId), timeoutMs);
2466
+ }
2467
+ }
2468
+ /**
2469
+ * Only a successful read that returns no value proves the order has no client
2470
+ * attempt link. A read also waits for any in-flight write of the same link. A failed, timed-out or unreadable link must keep the order
2471
+ * unacknowledged, or its attempt marker could later be cleared and reopen a
2472
+ * checkout for an order that was already granted.
2473
+ */
2474
+ async function readAitIapOrderAttemptLink(input) {
2475
+ const read = await readAitIapStorage(input.storage, aitIapOrderAttemptLinkStorageKey(input.orderId), input.timeoutMs);
2476
+ if (read.status !== 'ok') {
2477
+ return { status: 'unavailable' };
2478
+ }
2479
+ if (read.value === null) {
2480
+ return { status: 'absent' };
2481
+ }
2482
+ try {
2483
+ const link = JSON.parse(read.value);
2484
+ return isRecord(link)
2485
+ && link.productId === input.product.productId
2486
+ && isAitIapClientIdempotencyKey(link.idempotencyKey)
2487
+ ? { status: 'linked', idempotencyKey: link.idempotencyKey }
2488
+ : { status: 'unavailable' };
2489
+ }
2490
+ catch {
2491
+ return { status: 'unavailable' };
2492
+ }
2493
+ }
2494
+ function aitIapOrderAttemptLinkStorageKey(orderId) {
2495
+ return `${iapOrderAttemptLinkStoragePrefix}${encodeURIComponent(orderId)}`;
2496
+ }
2497
+ function isAitIapClientIdempotencyKey(value) {
2498
+ return typeof value === 'string'
2499
+ && value.length > 0
2500
+ && value.length <= 256
2501
+ && !/[\p{Cc}\p{Cf}]/u.test(value);
1577
2502
  }
1578
2503
  function mergeAitIapRestoredEntitlements(restoredEntitlements, authoritativeEntitlements, products) {
1579
2504
  const entitlementsById = new Map();
@@ -1609,22 +2534,73 @@ async function prepareAitIap(prepare, input, timeoutMs) {
1609
2534
  }
1610
2535
  }
1611
2536
  }
1612
- async function isAitOneTimeIapProduct(dependencies, configured, timeoutMs) {
1613
- try {
1614
- const result = await waitForAitIapNativeCall(() => dependencies.iap.getProductItemList(), timeoutMs);
1615
- if (result === undefined) {
1616
- return false;
1617
- }
1618
- const nativeProduct = result.products.find(({ sku }) => sku === configured.sku);
1619
- if (nativeProduct === undefined) {
1620
- return false;
2537
+ /**
2538
+ * The provider catalog is the authoritative source for the purchase flow. Do
2539
+ * not route an unknown or unavailable SKU into a one-time order; return why.
2540
+ */
2541
+ async function checkAitOneTimeIapProduct(dependencies, configured, timeoutMs) {
2542
+ const catalog = await readAitIapCatalog(dependencies, timeoutMs);
2543
+ if (catalog.status !== 'ok') {
2544
+ return catalog.diagnostic;
2545
+ }
2546
+ const nativeProduct = catalog.products.find(({ sku }) => sku === configured.sku);
2547
+ if (nativeProduct === undefined) {
2548
+ return aitIapDiagnostic(aitIapDiagnosticCodes.configuredSkusNotVisible, false);
2549
+ }
2550
+ return nativeProduct.type === 'CONSUMABLE' || nativeProduct.type === 'NON_CONSUMABLE'
2551
+ ? undefined
2552
+ : aitIapDiagnostic(aitIapDiagnosticCodes.productTypeUnsupported, false);
2553
+ }
2554
+ /**
2555
+ * Native IAP reads that have not settled yet, per IAP dependency object and
2556
+ * method. A retry while an earlier read is still running (for example a hung
2557
+ * native call) joins it instead of starting another native request, so
2558
+ * retries cannot pile up uncancelled native calls.
2559
+ */
2560
+ const aitIapNativeReadsRunning = new WeakMap();
2561
+ function joinAitIapNativeRead(owner, method, read) {
2562
+ let running = aitIapNativeReadsRunning.get(owner);
2563
+ if (running === undefined) {
2564
+ running = new Map();
2565
+ aitIapNativeReadsRunning.set(owner, running);
2566
+ }
2567
+ const existing = running.get(method);
2568
+ if (existing !== undefined) {
2569
+ return existing;
2570
+ }
2571
+ const reads = running;
2572
+ const call = Promise.resolve().then(read);
2573
+ const forget = () => {
2574
+ if (reads.get(method) === call) {
2575
+ reads.delete(method);
1621
2576
  }
1622
- return nativeProduct.type === 'CONSUMABLE' || nativeProduct.type === 'NON_CONSUMABLE';
2577
+ };
2578
+ void call.then(forget, forget);
2579
+ reads.set(method, call);
2580
+ return call;
2581
+ }
2582
+ /**
2583
+ * Like waitForAitIapNativeCall, but keeps a timeout, a thrown provider error
2584
+ * and a resolved `undefined` (an unsupported Toss app) distinguishable.
2585
+ */
2586
+ async function callAitIapNative(operation, timeoutMs) {
2587
+ const timedOut = Symbol('timed-out');
2588
+ let timeout;
2589
+ const timeoutResult = new Promise((resolve) => {
2590
+ timeout = globalThis.setTimeout(() => resolve(timedOut), timeoutMs);
2591
+ });
2592
+ try {
2593
+ const value = await Promise.race([operation(), timeoutResult]);
2594
+ return value === timedOut ? { status: 'timeout' } : { status: 'ok', value: value };
1623
2595
  }
1624
- catch {
1625
- // The provider catalog is the authoritative source for the purchase flow.
1626
- // Do not route an unknown or unavailable SKU into a one-time order.
1627
- return false;
2596
+ catch (error) {
2597
+ const providerCode = readAitIapProviderCode(error);
2598
+ return providerCode === undefined ? { status: 'error' } : { status: 'error', providerCode };
2599
+ }
2600
+ finally {
2601
+ if (timeout !== undefined) {
2602
+ globalThis.clearTimeout(timeout);
2603
+ }
1628
2604
  }
1629
2605
  }
1630
2606
  async function waitForAitIapNativeCall(operation, timeoutMs) {
@@ -1657,6 +2633,9 @@ async function verifyAitIapProductGrant(input) {
1657
2633
  productId: input.product.productId,
1658
2634
  platformSku: input.product.sku,
1659
2635
  idempotencyKey: input.idempotencyKey,
2636
+ ...(input.clientIdempotencyKey === undefined
2637
+ ? {}
2638
+ : { clientIdempotencyKey: input.clientIdempotencyKey }),
1660
2639
  source: input.source,
1661
2640
  purchasedAt: new Date().toISOString(),
1662
2641
  signal: controller.signal,
@@ -1672,27 +2651,76 @@ async function verifyAitIapProductGrant(input) {
1672
2651
  }
1673
2652
  }
1674
2653
  }
1675
- function completedAitIapPurchase(product, orderId) {
2654
+ function completedAitIapPurchase(product, orderId, source = 'process-product-grant') {
1676
2655
  return {
1677
2656
  status: 'completed',
1678
2657
  transactionId: orderId,
1679
2658
  entitlementIds: [product.productId],
1680
- evidence: createAitIapEvidence(orderId, product.sku, 'process-product-grant'),
2659
+ evidence: createAitIapEvidence(orderId, product.sku, source),
1681
2660
  };
1682
2661
  }
1683
- function failedPurchase() {
1684
- return { status: 'failed', entitlementIds: [] };
2662
+ function failedPurchase(diagnostic) {
2663
+ return {
2664
+ status: 'failed',
2665
+ entitlementIds: [],
2666
+ ...(diagnostic === undefined ? {} : { diagnostic }),
2667
+ };
1685
2668
  }
1686
2669
  function cancelledPurchase() {
1687
2670
  return { status: 'cancelled', entitlementIds: [] };
1688
2671
  }
1689
- function pendingPurchase(transactionId) {
2672
+ function pendingPurchase(transactionId, diagnostic) {
1690
2673
  return {
1691
2674
  status: 'pending',
1692
2675
  ...(transactionId === undefined ? {} : { transactionId }),
1693
2676
  entitlementIds: [],
2677
+ ...(diagnostic === undefined ? {} : { diagnostic }),
2678
+ };
2679
+ }
2680
+ function aitIapDiagnostic(code, retryable, providerCode) {
2681
+ return {
2682
+ code,
2683
+ retryable,
2684
+ ...(providerCode === undefined ? {} : { providerCode }),
1694
2685
  };
1695
2686
  }
2687
+ function createAitIapDiagnosticError(id, diagnostic, summary) {
2688
+ const providerDetail = diagnostic.providerCode === undefined
2689
+ ? ''
2690
+ : `; provider code ${diagnostic.providerCode}`;
2691
+ return createBridgeError(id, diagnostic.code, `${summary} (${diagnostic.code}${providerDetail}).`, diagnostic.retryable);
2692
+ }
2693
+ /**
2694
+ * Native SDK codes that may be recognized in error text when the SDK does not
2695
+ * attach a structured code. Nothing else is ever read from a message.
2696
+ */
2697
+ const aitIapKnownMessageProviderCodes = [aitIapProductNotGrantedByPartnerCode];
2698
+ /**
2699
+ * Reads a native SDK error code from structured `code`/`errorCode` fields.
2700
+ * Free-form error text can contain order or account identifiers, so it is
2701
+ * only checked for an exact, allowlisted SDK code.
2702
+ */
2703
+ function readAitIapProviderCode(error) {
2704
+ if (isRecord(error)) {
2705
+ for (const candidate of [error.code, error.errorCode]) {
2706
+ if (isAitIapProviderCode(candidate)) {
2707
+ return candidate;
2708
+ }
2709
+ }
2710
+ }
2711
+ let message = '';
2712
+ if (error instanceof Error) {
2713
+ message = error.message;
2714
+ }
2715
+ else if (typeof error === 'string') {
2716
+ message = error;
2717
+ }
2718
+ const tokens = new Set(message.split(/[^A-Z0-9_]+/u));
2719
+ return aitIapKnownMessageProviderCodes.find((code) => tokens.has(code));
2720
+ }
2721
+ function isAitIapProviderCode(value) {
2722
+ return typeof value === 'string' && /^[A-Z][A-Z0-9_:-]{0,127}$/u.test(value);
2723
+ }
1696
2724
  function createAitIapEvidence(orderId, sku, source) {
1697
2725
  return {
1698
2726
  schema: iapEvidenceSchema,
@@ -1777,6 +2805,9 @@ function toAitIapProductInfo(nativeProduct, configured) {
1777
2805
  };
1778
2806
  }
1779
2807
  function normalizeAitIapDisplayText(value) {
2808
+ if (typeof value !== 'string') {
2809
+ return undefined;
2810
+ }
1780
2811
  const normalized = value.trim();
1781
2812
  return normalized.length > 0 && normalized.length <= 2_048 && !/[\p{Cc}\p{Cf}]/u.test(normalized)
1782
2813
  ? normalized