@mpgd/adapter-ait 0.8.0 → 0.9.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.d.ts CHANGED
@@ -1,20 +1,75 @@
1
- import { getTossShareLink, grantPromotionRewardForGame as grantPromotionReward, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore } from '@apps-in-toss/web-framework';
2
- import type { NotificationTopic, ShareResult } from '@mpgd/platform';
1
+ import { getTossShareLink, grantPromotionRewardForGame as grantPromotionReward, IAP, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore } from '@apps-in-toss/web-framework';
2
+ import type { Entitlement, LogicalProductId, NotificationTopic, ShareResult } from '@mpgd/platform';
3
3
  import type { GamePlatformBridge } from './index.js';
4
4
  export type AitIdentityProvider = () => Promise<unknown>;
5
+ /**
6
+ * The AIT SDK adds support-version metadata to native functions over time.
7
+ * The host only depends on the callable surface and an optional support probe
8
+ * so adapter tests remain compatible with older wrappers and newer SDKs.
9
+ */
10
+ type AitOptionalCapabilityMethod<T extends (...args: never[]) => unknown> = (...args: Parameters<T>) => ReturnType<T>;
11
+ interface AitCapabilityProbe {
12
+ readonly isSupported?: () => boolean;
13
+ }
14
+ type AitNativeMethod<T extends (...args: never[]) => unknown> = AitOptionalCapabilityMethod<T> & AitCapabilityProbe;
15
+ interface AitIapDependencies {
16
+ readonly createOneTimePurchaseOrder: AitNativeMethod<typeof IAP.createOneTimePurchaseOrder>;
17
+ readonly getProductItemList: AitNativeMethod<typeof IAP.getProductItemList>;
18
+ readonly getPendingOrders: AitNativeMethod<typeof IAP.getPendingOrders>;
19
+ readonly completeProductGrant: AitNativeMethod<typeof IAP.completeProductGrant>;
20
+ }
5
21
  export interface AitHostDependencies {
6
22
  readonly identityProvider: AitIdentityProvider;
7
23
  readonly storage: Pick<typeof Storage, 'getItem' | 'removeItem' | 'setItem'>;
8
24
  readonly getTossShareLink: typeof getTossShareLink;
9
25
  readonly share: typeof share;
10
- readonly grantPromotionReward: typeof grantPromotionReward;
11
- readonly requestNotificationAgreement: typeof requestNotificationAgreement;
26
+ readonly grantPromotionReward: AitNativeMethod<typeof grantPromotionReward>;
27
+ readonly requestNotificationAgreement: AitNativeMethod<typeof requestNotificationAgreement>;
12
28
  readonly isMinVersionSupported: typeof isMinVersionSupported;
13
- readonly loadFullScreenAd: typeof loadFullScreenAd;
14
- readonly showFullScreenAd: typeof showFullScreenAd;
15
- readonly openGameCenterLeaderboard: typeof openGameCenterLeaderboard;
16
- readonly submitGameCenterLeaderBoardScore: typeof submitGameCenterLeaderBoardScore;
29
+ readonly loadFullScreenAd: AitNativeMethod<typeof loadFullScreenAd>;
30
+ readonly showFullScreenAd: AitNativeMethod<typeof showFullScreenAd>;
31
+ readonly openGameCenterLeaderboard: AitNativeMethod<typeof openGameCenterLeaderboard>;
32
+ readonly submitGameCenterLeaderBoardScore: AitNativeMethod<typeof submitGameCenterLeaderBoardScore>;
33
+ readonly iap: AitIapDependencies;
34
+ }
35
+ /** Game-owned logical product mapped to a console-issued Apps in Toss SKU. */
36
+ export interface AitIapProductConfig {
37
+ readonly productId: LogicalProductId;
38
+ readonly sku: string;
39
+ /** Apps in Toss currently displays KRW prices; override only for a supported future catalog. */
40
+ readonly currencyCode?: string;
41
+ }
42
+ export interface AitIapPreparationInput {
43
+ readonly intent: 'purchase' | 'restore';
44
+ readonly productId?: LogicalProductId;
45
+ readonly platformSku?: string;
46
+ }
47
+ /**
48
+ * Game-owned server gate that ensures the anonymous game key is linked to a
49
+ * Toss login user before a purchase or pending-order recovery can proceed.
50
+ */
51
+ export type AitIapPreparer = (input: AitIapPreparationInput) => Promise<boolean>;
52
+ export interface AitIapProductGrantVerificationInput {
53
+ readonly orderId: string;
54
+ readonly productId: LogicalProductId;
55
+ readonly platformSku: string;
56
+ readonly idempotencyKey: string;
57
+ readonly source: 'process-product-grant' | 'pending-order-restore';
58
+ /** Client callback time only; the game authority must use the order status time. */
59
+ readonly purchasedAt: string;
60
+ readonly signal: AbortSignal;
61
+ readonly timeoutMs: number;
17
62
  }
63
+ /**
64
+ * A server-backed verifier. Returning false keeps the provider-side grant
65
+ * pending; the bridge never manufactures a client-side entitlement.
66
+ */
67
+ export type AitIapProductGrantVerifier = (input: AitIapProductGrantVerificationInput) => Promise<boolean>;
68
+ /**
69
+ * Reads durable purchase entitlements from the game authority. Native product
70
+ * callbacks are intentionally not treated as a source of ownership state.
71
+ */
72
+ export type AitIapEntitlementReader = () => Promise<readonly Entitlement[]>;
18
73
  export interface AitPromotionRewardConfig {
19
74
  readonly promotionCode: string;
20
75
  readonly amount: number;
@@ -61,6 +116,14 @@ export interface InstallAitHostBridgeOptions {
61
116
  readonly promotionRewards?: Readonly<Record<string, AitPromotionRewardConfig>>;
62
117
  readonly authorizePromotionGrant?: AitPromotionGrantAuthorizer;
63
118
  readonly resolvePendingPromotionGrant?: AitPendingPromotionGrantResolver;
119
+ /** Enable IAP only with logical-to-native SKU mappings and both server hooks. */
120
+ readonly iapProducts?: readonly AitIapProductConfig[];
121
+ readonly prepareIap?: AitIapPreparer;
122
+ readonly verifyIapProductGrant?: AitIapProductGrantVerifier;
123
+ /** Required to expose native IAP so ownership survives a wrapper restart. */
124
+ readonly readIapEntitlements?: AitIapEntitlementReader;
125
+ /** May be shortened, but never extended beyond the native callback-safe default. */
126
+ readonly iapProductGrantTimeoutMs?: number;
64
127
  /** Logical notification topics mapped to approved Apps in Toss template codes. */
65
128
  readonly notificationTemplateCodes?: Partial<Readonly<Record<NotificationTopic, string>>>;
66
129
  /** Maximum total wait for the native show request before display is observed. */
package/dist/host.js CHANGED
@@ -1,14 +1,33 @@
1
- import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as grantPromotionReward, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore, } from '@apps-in-toss/web-framework';
1
+ import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as grantPromotionReward, IAP, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore, } from '@apps-in-toss/web-framework';
2
2
  import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
3
3
  import { dispatchAitLifecycleEvent } from './lifecycle.js';
4
4
  const defaultAdTimeoutMs = 60000;
5
5
  const defaultAdLoadQueueTimeoutMs = 5000;
6
6
  const defaultAdDisplayStartTimeoutMs = 60000;
7
7
  const defaultAdMaximumDisplayMs = 30 * 60000;
8
+ /** Leaves five seconds of headroom inside the native IAP 30-second grant callback. */
9
+ const defaultIapProductGrantTimeoutMs = 25000;
10
+ /** Bounds abandoned checkout UI while still allowing the user to finish an interactive purchase. */
11
+ const defaultIapPurchaseSessionTimeoutMs = 30 * 60000;
12
+ /** A provider response is untrusted input; keep restore work bounded per launch. */
13
+ const maximumPendingIapOrders = 20;
8
14
  const invalidBridgeRequestId = 'ait-invalid-request';
9
15
  const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
16
+ const iapEvidenceSchema = 'apps-in-toss.iap.callback.v1';
10
17
  const minimumPromotionTossAppVersion = '5.232.0';
11
18
  const promotionGrantStoragePrefix = 'mpgd:ait:promotion-grant:v1:';
19
+ /**
20
+ * A client idempotency key must survive a bridge reload. The authoritative
21
+ * backend still owns entitlements; this marker only prevents a second native
22
+ * checkout while the first checkout is known to be terminal or ambiguous.
23
+ */
24
+ const iapPurchaseAttemptStoragePrefix = 'mpgd:ait:iap-purchase-attempt:v1:';
25
+ const iapCompletedPurchaseAttemptIndexStorageKey = 'mpgd:ait:iap-completed-purchase-index:v1';
26
+ const maximumIndexedCompletedIapPurchaseAttempts = 64;
27
+ /** A crashed pre-checkout attempt can be retried only after provider recovery finds no order. */
28
+ const pendingIapPurchaseAttemptRecoveryAgeMs = defaultIapPurchaseSessionTimeoutMs;
29
+ /** Rotates bounded pending-order recovery so an early rejected order cannot starve later work. */
30
+ const iapPendingOrderCursorStorageKey = 'mpgd:ait:pending-order-cursor:v1';
12
31
  const defaultNotificationAgreementTimeoutMs = 120000;
13
32
  const notificationTopics = new Set([
14
33
  'daily-ready',
@@ -36,6 +55,7 @@ const defaultDependencies = {
36
55
  showFullScreenAd,
37
56
  openGameCenterLeaderboard,
38
57
  submitGameCenterLeaderBoardScore,
58
+ iap: IAP,
39
59
  };
40
60
  export function installAitHostBridge(options = {}) {
41
61
  const bridge = createAitHostBridge(options);
@@ -51,7 +71,11 @@ export function createAitHostBridge(options = {}) {
51
71
  const authorizePromotionGrant = options.authorizePromotionGrant;
52
72
  const resolvePendingPromotionGrant = options.resolvePendingPromotionGrant;
53
73
  const notificationTemplateCodes = normalizeNotificationTemplateCodes(options.notificationTemplateCodes);
74
+ const iapProducts = normalizeIapProducts(options.iapProducts);
75
+ const iapProductGrantTimeoutMs = normalizeIapProductGrantTimeout(options.iapProductGrantTimeoutMs);
54
76
  const promotionGrantsInFlight = new Map();
77
+ const iapPurchasesInFlight = new Map();
78
+ let completedIapAttemptRetention = Promise.resolve();
55
79
  const notificationSubscriptions = new Set();
56
80
  const loadedAdGroupIds = new Set();
57
81
  const loadingAdGroups = new Map();
@@ -65,6 +89,15 @@ export function createAitHostBridge(options = {}) {
65
89
  active: undefined,
66
90
  waitTimeoutMs: adLoadQueueTimeoutMs,
67
91
  };
92
+ const enqueueCompletedIapAttemptRetention = (storage, storageKey) => {
93
+ const scheduled = completedIapAttemptRetention.then(async () => {
94
+ await retainCompletedAitIapPurchaseAttempt(storage, storageKey, iapProductGrantTimeoutMs);
95
+ });
96
+ const next = scheduled.then(() => undefined, () => undefined);
97
+ void next;
98
+ void (completedIapAttemptRetention = next);
99
+ return next;
100
+ };
68
101
  return {
69
102
  async request(input) {
70
103
  try {
@@ -83,12 +116,21 @@ export function createAitHostBridge(options = {}) {
83
116
  && hasConfiguredAdType(adGroupIds, adPlacementTypes, 'rewarded');
84
117
  const interstitialAds = adsSupported
85
118
  && hasConfiguredAdType(adGroupIds, adPlacementTypes, 'interstitial');
119
+ const nativeIap = isAitIapSupported({
120
+ dependencies,
121
+ products: iapProducts,
122
+ prepare: options.prepareIap,
123
+ verifier: options.verifyIapProductGrant,
124
+ entitlementReader: options.readIapEntitlements,
125
+ timeoutMs: iapProductGrantTimeoutMs,
126
+ });
86
127
  return ok(request, {
87
- nativeIap: false,
128
+ nativeIap,
88
129
  nativeAds: rewardedAds || interstitialAds,
89
130
  rewardedAds,
90
131
  interstitialAds,
91
132
  nativeLeaderboard: isGameCenterSupported(dependencies),
133
+ remoteLeaderboard: false,
92
134
  achievements: false,
93
135
  cloudSave: false,
94
136
  socialShare: true,
@@ -132,7 +174,7 @@ export function createAitHostBridge(options = {}) {
132
174
  const topic = readNotificationTopic(request.payload);
133
175
  const templateCode = notificationTemplateCodes.get(topic);
134
176
  if (templateCode === undefined
135
- || !isCapabilitySupported(() => dependencies.requestNotificationAgreement.isSupported())) {
177
+ || !isAitNativeMethodSupported(dependencies.requestNotificationAgreement)) {
136
178
  return ok(request, 'unavailable');
137
179
  }
138
180
  const result = await requestAitNotificationAgreement(dependencies.requestNotificationAgreement, templateCode);
@@ -188,15 +230,84 @@ export function createAitHostBridge(options = {}) {
188
230
  }
189
231
  }
190
232
  }
191
- // IAP must be installed with game-owned server verification. Never return demo grants.
233
+ // IAP is opt-in and server-authoritative. Never return demo grants.
192
234
  case 'commerce.getProducts':
193
- return ok(request, []);
194
- case 'commerce.purchase':
195
- return ok(request, { status: 'failed', entitlementIds: [] });
235
+ return ok(request, await listAitIapProducts({
236
+ dependencies,
237
+ products: iapProducts,
238
+ prepare: options.prepareIap,
239
+ verifier: options.verifyIapProductGrant,
240
+ entitlementReader: options.readIapEntitlements,
241
+ timeoutMs: iapProductGrantTimeoutMs,
242
+ }));
243
+ case 'commerce.purchase': {
244
+ const purchase = readCommercePurchase(request.payload);
245
+ const product = iapProducts.byProductId.get(purchase.productId);
246
+ const prepareIap = options.prepareIap;
247
+ const verifyIapProductGrant = options.verifyIapProductGrant;
248
+ const iapSupported = isAitIapSupported({
249
+ dependencies,
250
+ products: iapProducts,
251
+ prepare: prepareIap,
252
+ verifier: verifyIapProductGrant,
253
+ entitlementReader: options.readIapEntitlements,
254
+ timeoutMs: iapProductGrantTimeoutMs,
255
+ });
256
+ if (product === undefined
257
+ || !iapSupported
258
+ || prepareIap === undefined
259
+ || verifyIapProductGrant === undefined) {
260
+ return ok(request, failedPurchase());
261
+ }
262
+ const persisted = await resolvePersistedAitIapPurchaseAttempt({
263
+ dependencies,
264
+ storage: dependencies.storage,
265
+ product,
266
+ idempotencyKey: purchase.idempotencyKey,
267
+ timeoutMs: iapProductGrantTimeoutMs,
268
+ });
269
+ if (persisted !== undefined) {
270
+ return ok(request, persisted);
271
+ }
272
+ const isOneTimeProduct = await isAitOneTimeIapProduct(dependencies, product, iapProductGrantTimeoutMs);
273
+ if (!isOneTimeProduct) {
274
+ return ok(request, failedPurchase());
275
+ }
276
+ const purchaseRequestKey = createAitIapPurchaseRequestKey(purchase.productId, purchase.idempotencyKey);
277
+ const existing = iapPurchasesInFlight.get(purchaseRequestKey);
278
+ if (existing !== undefined) {
279
+ return ok(request, await existing);
280
+ }
281
+ const pending = purchaseAitIapProduct({
282
+ dependencies,
283
+ product,
284
+ idempotencyKey: purchase.idempotencyKey,
285
+ prepare: prepareIap,
286
+ verifier: verifyIapProductGrant,
287
+ retainCompletedAttempt: enqueueCompletedIapAttemptRetention,
288
+ timeoutMs: iapProductGrantTimeoutMs,
289
+ });
290
+ iapPurchasesInFlight.set(purchaseRequestKey, pending);
291
+ try {
292
+ return ok(request, await pending);
293
+ }
294
+ finally {
295
+ if (iapPurchasesInFlight.get(purchaseRequestKey) === pending) {
296
+ iapPurchasesInFlight.delete(purchaseRequestKey);
297
+ }
298
+ }
299
+ }
196
300
  case 'commerce.restore':
197
- return ok(request, { restoredEntitlements: [] });
301
+ return ok(request, await restoreAitIapProducts({
302
+ dependencies,
303
+ products: iapProducts,
304
+ prepare: options.prepareIap,
305
+ verifier: options.verifyIapProductGrant,
306
+ entitlementReader: options.readIapEntitlements,
307
+ timeoutMs: iapProductGrantTimeoutMs,
308
+ }));
198
309
  case 'commerce.getEntitlements':
199
- return ok(request, []);
310
+ return ok(request, await readAitIapEntitlements(options.readIapEntitlements, iapProductGrantTimeoutMs));
200
311
  case 'ads.preload': {
201
312
  const placementId = readPlacementId(request.payload);
202
313
  const adGroupId = adGroupIds.get(placementId);
@@ -204,7 +315,7 @@ export function createAitHostBridge(options = {}) {
204
315
  if (adGroupId === undefined || placementType === undefined) {
205
316
  return createBridgeError(request.id, 'AIT_AD_UNAVAILABLE', `AIT ad placement is unavailable: ${placementId}`);
206
317
  }
207
- if (!isCapabilitySupported(() => dependencies.loadFullScreenAd.isSupported())) {
318
+ if (!isAitNativeMethodSupported(dependencies.loadFullScreenAd)) {
208
319
  if (!warnedUnsupportedAdPreload) {
209
320
  warnedUnsupportedAdPreload = true;
210
321
  console.warn('AIT full-screen ads are not supported; configured preload is a no-op.', placementId);
@@ -306,7 +417,7 @@ function getNotificationStatus(topic, templateCodes, subscriptions, dependencies
306
417
  if (!templateCodes.has(topic)) {
307
418
  return 'configuration-required';
308
419
  }
309
- if (!isCapabilitySupported(() => dependencies.requestNotificationAgreement.isSupported())) {
420
+ if (!isAitNativeMethodSupported(dependencies.requestNotificationAgreement)) {
310
421
  return 'unsupported';
311
422
  }
312
423
  return subscriptions.has(topic) ? 'subscribed' : 'not-subscribed';
@@ -901,9 +1012,833 @@ function isGameCenterSupported(dependencies) {
901
1012
  ios: '5.221.0',
902
1013
  }));
903
1014
  }
1015
+ const aitIapPurchaseAttemptStatus = {
1016
+ pending: 'pending',
1017
+ serverGranted: 'server-granted',
1018
+ completed: 'completed',
1019
+ };
1020
+ function isAitIapSupported(input) {
1021
+ if (input.products.byProductId.size === 0
1022
+ || input.prepare === undefined
1023
+ || input.verifier === undefined
1024
+ || input.entitlementReader === undefined) {
1025
+ return false;
1026
+ }
1027
+ return isAitNativeMethodSupported(input.dependencies.iap.createOneTimePurchaseOrder)
1028
+ && isAitNativeMethodSupported(input.dependencies.iap.getProductItemList)
1029
+ && isAitNativeMethodSupported(input.dependencies.iap.getPendingOrders)
1030
+ && isAitNativeMethodSupported(input.dependencies.iap.completeProductGrant);
1031
+ }
1032
+ async function listAitIapProducts(input) {
1033
+ if (!isAitIapSupported(input)) {
1034
+ return [];
1035
+ }
1036
+ try {
1037
+ const result = await waitForAitIapNativeCall(() => input.dependencies.iap.getProductItemList(), input.timeoutMs);
1038
+ if (result === undefined) {
1039
+ return [];
1040
+ }
1041
+ const products = [];
1042
+ for (const nativeProduct of result.products) {
1043
+ const configured = input.products.bySku.get(nativeProduct.sku);
1044
+ if (configured === undefined) {
1045
+ continue;
1046
+ }
1047
+ const product = toAitIapProductInfo(nativeProduct, configured);
1048
+ if (product !== undefined) {
1049
+ products.push(product);
1050
+ }
1051
+ }
1052
+ return products;
1053
+ }
1054
+ catch {
1055
+ // Product metadata is not authoritative. If the native catalog cannot be
1056
+ // read, hide it instead of rendering stale game-owned prices.
1057
+ return [];
1058
+ }
1059
+ }
1060
+ async function readAitIapEntitlements(reader, timeoutMs) {
1061
+ if (reader === undefined || timeoutMs <= 0) {
1062
+ return [];
1063
+ }
1064
+ try {
1065
+ const entitlements = await waitForAitIapNativeCall(reader, timeoutMs);
1066
+ return entitlements === undefined ? [] : [...entitlements];
1067
+ }
1068
+ catch (error) {
1069
+ // The game authority owns durable purchase state. A read failure must not
1070
+ // turn stale local data into a visible entitlement.
1071
+ console.warn('AIT IAP entitlement read failed; reporting no entitlements.', errorMessage(error));
1072
+ return [];
1073
+ }
1074
+ }
1075
+ /**
1076
+ * Return a durable result before consulting the provider catalog. This makes a
1077
+ * repeated client idempotency key safe after a reload or after the in-memory
1078
+ * coalescing map has been cleared.
1079
+ */
1080
+ async function resolvePersistedAitIapPurchaseAttempt(input) {
1081
+ const storageKey = aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey);
1082
+ let serialized;
1083
+ try {
1084
+ serialized = await waitForAitIapNativeCall(() => input.storage.getItem(storageKey), input.timeoutMs);
1085
+ }
1086
+ catch (error) {
1087
+ console.warn('AIT IAP purchase attempt storage read failed; treating the attempt as pending.', errorMessage(error));
1088
+ return pendingPurchase();
1089
+ }
1090
+ if (serialized === undefined) {
1091
+ return pendingPurchase();
1092
+ }
1093
+ if (serialized === null) {
1094
+ return undefined;
1095
+ }
1096
+ let state;
1097
+ try {
1098
+ state = JSON.parse(serialized);
1099
+ }
1100
+ catch (error) {
1101
+ console.warn('AIT IAP purchase attempt marker is invalid; treating the attempt as pending.', errorMessage(error));
1102
+ return pendingPurchase();
1103
+ }
1104
+ if (!isRecord(state)
1105
+ || state.productId !== input.product.productId
1106
+ || state.idempotencyKey !== input.idempotencyKey) {
1107
+ return pendingPurchase();
1108
+ }
1109
+ if (state.status === aitIapPurchaseAttemptStatus.pending) {
1110
+ if (typeof state.orderId === 'string' && isAitIapOrderId(state.orderId)) {
1111
+ return pendingPurchase(state.orderId);
1112
+ }
1113
+ const pendingSince = normalizePendingSince(state.pendingSince);
1114
+ if (pendingSince === undefined
1115
+ || !isAitIapPendingAttemptPastRecoveryWindow(pendingSince)) {
1116
+ return pendingPurchase();
1117
+ }
1118
+ const hasPendingProviderOrder = await hasAitIapPendingOrderForSku(input.dependencies, input.product.sku, input.timeoutMs);
1119
+ if (hasPendingProviderOrder !== false) {
1120
+ return pendingPurchase();
1121
+ }
1122
+ const cleared = await clearAitIapPurchaseAttempt(input);
1123
+ return cleared ? undefined : pendingPurchase();
1124
+ }
1125
+ if (state.status === aitIapPurchaseAttemptStatus.serverGranted
1126
+ && typeof state.orderId === 'string'
1127
+ && isAitIapOrderId(state.orderId)) {
1128
+ return pendingPurchase(state.orderId);
1129
+ }
1130
+ if (state.status === aitIapPurchaseAttemptStatus.completed
1131
+ && typeof state.orderId === 'string'
1132
+ && isAitIapOrderId(state.orderId)) {
1133
+ return completedAitIapPurchase(input.product, state.orderId);
1134
+ }
1135
+ return pendingPurchase();
1136
+ }
1137
+ async function persistAitIapPurchaseAttempt(input) {
1138
+ return await writeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), JSON.stringify({
1139
+ status: input.status,
1140
+ productId: input.product.productId,
1141
+ idempotencyKey: input.idempotencyKey,
1142
+ ...(input.status === aitIapPurchaseAttemptStatus.pending
1143
+ ? { pendingSince: new Date().toISOString() }
1144
+ : {}),
1145
+ ...(input.orderId === undefined ? {} : { orderId: input.orderId }),
1146
+ }), input.timeoutMs);
1147
+ }
1148
+ async function clearAitIapPurchaseAttempt(input) {
1149
+ return await removeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), input.timeoutMs);
1150
+ }
1151
+ async function verifyAndPersistAitIapProductGrant(input) {
1152
+ const startedAt = Date.now();
1153
+ const correlated = await persistAitIapPurchaseAttempt({
1154
+ storage: input.storage,
1155
+ product: input.product,
1156
+ idempotencyKey: input.idempotencyKey,
1157
+ orderId: input.orderId,
1158
+ status: aitIapPurchaseAttemptStatus.pending,
1159
+ timeoutMs: input.timeoutMs,
1160
+ });
1161
+ if (!correlated) {
1162
+ // Never let the backend commit a grant unless the provider order is first
1163
+ // durably tied to the client attempt. Otherwise a lost response could let
1164
+ // the same client idempotency key open a second checkout after restore.
1165
+ return false;
1166
+ }
1167
+ const verificationTimeoutMs = remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
1168
+ if (verificationTimeoutMs === 0) {
1169
+ return false;
1170
+ }
1171
+ const granted = await verifyAitIapProductGrant({
1172
+ verifier: input.verifier,
1173
+ orderId: input.orderId,
1174
+ product: input.product,
1175
+ idempotencyKey: input.orderIdempotencyKey,
1176
+ source: input.source,
1177
+ timeoutMs: verificationTimeoutMs,
1178
+ });
1179
+ if (!granted) {
1180
+ return false;
1181
+ }
1182
+ const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
1183
+ if (remainingTimeoutMs === 0) {
1184
+ return false;
1185
+ }
1186
+ return await persistAitIapPurchaseAttempt({
1187
+ storage: input.storage,
1188
+ product: input.product,
1189
+ idempotencyKey: input.idempotencyKey,
1190
+ orderId: input.orderId,
1191
+ status: aitIapPurchaseAttemptStatus.serverGranted,
1192
+ timeoutMs: remainingTimeoutMs,
1193
+ });
1194
+ }
1195
+ function remainingAitIapOperationTimeout(startedAt, timeoutMs) {
1196
+ return Math.max(0, timeoutMs - Math.max(0, Date.now() - startedAt));
1197
+ }
1198
+ async function writeAitIapStorage(storage, key, value, timeoutMs) {
1199
+ return await waitForAitIapStorageOperation(() => storage.setItem(key, value), timeoutMs);
1200
+ }
1201
+ async function removeAitIapStorage(storage, key, timeoutMs) {
1202
+ return await waitForAitIapStorageOperation(() => storage.removeItem(key), timeoutMs);
1203
+ }
1204
+ async function waitForAitIapStorageOperation(operation, timeoutMs) {
1205
+ let timeout;
1206
+ const timeoutResult = new Promise((resolve) => {
1207
+ timeout = globalThis.setTimeout(() => resolve(false), timeoutMs);
1208
+ });
1209
+ const operationResult = Promise.resolve()
1210
+ .then(operation)
1211
+ .then(() => true, () => false);
1212
+ try {
1213
+ return await Promise.race([operationResult, timeoutResult]);
1214
+ }
1215
+ finally {
1216
+ if (timeout !== undefined) {
1217
+ globalThis.clearTimeout(timeout);
1218
+ }
1219
+ }
1220
+ }
1221
+ function aitIapPurchaseAttemptStorageKey(productId, idempotencyKey) {
1222
+ return `${iapPurchaseAttemptStoragePrefix}${encodeURIComponent(productId)}:${encodeURIComponent(idempotencyKey)}`;
1223
+ }
1224
+ function isAitIapPendingAttemptPastRecoveryWindow(pendingSince) {
1225
+ const startedAt = Date.parse(pendingSince);
1226
+ return Number.isFinite(startedAt)
1227
+ && Date.now() - startedAt >= pendingIapPurchaseAttemptRecoveryAgeMs;
1228
+ }
1229
+ async function hasAitIapPendingOrderForSku(dependencies, sku, timeoutMs) {
1230
+ try {
1231
+ const result = await waitForAitIapNativeCall(() => dependencies.iap.getPendingOrders(), timeoutMs);
1232
+ if (result === undefined) {
1233
+ return undefined;
1234
+ }
1235
+ return result.orders.some((order) => order.sku === sku && isAitIapOrderId(order.orderId));
1236
+ }
1237
+ catch (error) {
1238
+ console.warn('AIT IAP pending-order lookup failed; preserving the client retry barrier.', errorMessage(error));
1239
+ return undefined;
1240
+ }
1241
+ }
1242
+ /**
1243
+ * Keep the completed-attempt index bounded without deleting terminal retry
1244
+ * markers. A client idempotency key may be replayed indefinitely, so removing
1245
+ * its durable result could open a second native checkout. The index is only
1246
+ * bookkeeping for recent entries; all storage failures leave markers intact.
1247
+ */
1248
+ async function retainCompletedAitIapPurchaseAttempt(storage, storageKey, timeoutMs) {
1249
+ const startedAt = Date.now();
1250
+ try {
1251
+ const serialized = await waitForAitIapNativeCall(() => storage.getItem(iapCompletedPurchaseAttemptIndexStorageKey), timeoutMs);
1252
+ const knownKeys = parseAitIapCompletedPurchaseAttemptIndex(serialized);
1253
+ const keys = [...knownKeys.filter((knownKey) => knownKey !== storageKey), storageKey];
1254
+ const retainedKeys = keys.slice(-maximumIndexedCompletedIapPurchaseAttempts);
1255
+ const retentionTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
1256
+ if (retentionTimeoutMs === 0) {
1257
+ return;
1258
+ }
1259
+ await writeAitIapStorage(storage, iapCompletedPurchaseAttemptIndexStorageKey, JSON.stringify(retainedKeys), retentionTimeoutMs);
1260
+ }
1261
+ catch {
1262
+ // Index maintenance is best effort. The terminal marker remains the
1263
+ // durable retry barrier even when its recent-entry index cannot be written.
1264
+ }
1265
+ }
1266
+ function parseAitIapCompletedPurchaseAttemptIndex(serialized) {
1267
+ if (serialized === null || serialized === undefined) {
1268
+ return [];
1269
+ }
1270
+ try {
1271
+ const value = JSON.parse(serialized);
1272
+ if (!Array.isArray(value)) {
1273
+ return [];
1274
+ }
1275
+ const keys = value.filter((key) => (typeof key === 'string'
1276
+ && key.startsWith(iapPurchaseAttemptStoragePrefix)
1277
+ && key.length <= 4096));
1278
+ return [...new Set(keys)].slice(-maximumIndexedCompletedIapPurchaseAttempts);
1279
+ }
1280
+ catch {
1281
+ return [];
1282
+ }
1283
+ }
1284
+ async function readAitIapPendingOrderCursor(storage, timeoutMs) {
1285
+ try {
1286
+ const cursor = await waitForAitIapNativeCall(() => storage.getItem(iapPendingOrderCursorStorageKey), timeoutMs);
1287
+ return typeof cursor === 'string' && isAitIapOrderId(cursor) ? cursor : undefined;
1288
+ }
1289
+ catch (error) {
1290
+ console.warn('AIT IAP pending-order cursor read failed; starting from the first eligible order.', errorMessage(error));
1291
+ return undefined;
1292
+ }
1293
+ }
1294
+ async function writeAitIapPendingOrderCursor(storage, orderId, timeoutMs) {
1295
+ return await writeAitIapStorage(storage, iapPendingOrderCursorStorageKey, orderId, timeoutMs);
1296
+ }
1297
+ function rotateAitIapPendingOrders(orders, cursor, limit) {
1298
+ if (orders.length === 0) {
1299
+ return [];
1300
+ }
1301
+ const cursorIndex = cursor === undefined
1302
+ ? -1
1303
+ : orders.findIndex(({ order }) => order.orderId === cursor);
1304
+ const startIndex = cursorIndex < 0 ? 0 : (cursorIndex + 1) % orders.length;
1305
+ const selected = [];
1306
+ const count = Math.min(limit, orders.length);
1307
+ for (let offset = 0; offset < count; offset += 1) {
1308
+ const next = orders[(startIndex + offset) % orders.length];
1309
+ if (next !== undefined) {
1310
+ selected.push(next);
1311
+ }
1312
+ }
1313
+ return selected;
1314
+ }
1315
+ async function purchaseAitIapProduct(input) {
1316
+ const prepared = await prepareAitIap(input.prepare, {
1317
+ intent: 'purchase',
1318
+ productId: input.product.productId,
1319
+ platformSku: input.product.sku,
1320
+ }, input.timeoutMs);
1321
+ if (!prepared) {
1322
+ return failedPurchase();
1323
+ }
1324
+ const started = await persistAitIapPurchaseAttempt({
1325
+ storage: input.dependencies.storage,
1326
+ product: input.product,
1327
+ idempotencyKey: input.idempotencyKey,
1328
+ status: aitIapPurchaseAttemptStatus.pending,
1329
+ timeoutMs: input.timeoutMs,
1330
+ });
1331
+ if (!started) {
1332
+ // We cannot tell whether a delayed native-storage write eventually
1333
+ // succeeded. Do not open a checkout without a durable retry barrier.
1334
+ return pendingPurchase();
1335
+ }
1336
+ return new Promise((resolve) => {
1337
+ const grantAttempts = new Map();
1338
+ let settled = false;
1339
+ let cleanup;
1340
+ let cleanupRequested = false;
1341
+ let sessionTimeout;
1342
+ let providerOrderId;
1343
+ let grantedOrderId;
1344
+ let markerDeletionStarted = false;
1345
+ const finish = (result) => {
1346
+ if (settled) {
1347
+ return;
1348
+ }
1349
+ settled = true;
1350
+ if (sessionTimeout !== undefined) {
1351
+ globalThis.clearTimeout(sessionTimeout);
1352
+ sessionTimeout = undefined;
1353
+ }
1354
+ if (cleanup === undefined) {
1355
+ cleanupRequested = true;
1356
+ }
1357
+ else {
1358
+ safelyCleanupAitIapPurchase(cleanup);
1359
+ }
1360
+ resolve(result);
1361
+ };
1362
+ const startMarkerDeletion = (clearedResult) => {
1363
+ markerDeletionStarted = true;
1364
+ void clearAitIapPurchaseAttempt({
1365
+ storage: input.dependencies.storage,
1366
+ product: input.product,
1367
+ idempotencyKey: input.idempotencyKey,
1368
+ timeoutMs: input.timeoutMs,
1369
+ }).then((cleared) => {
1370
+ finish(cleared ? clearedResult : pendingPurchase());
1371
+ });
1372
+ };
1373
+ const processProductGrant = (orderId) => {
1374
+ if (markerDeletionStarted) {
1375
+ // Cancellation won the serialization race. Do not let a later queued
1376
+ // grant callback commit after its retry barrier starts disappearing.
1377
+ return Promise.resolve(false);
1378
+ }
1379
+ const existing = grantAttempts.get(orderId);
1380
+ if (existing !== undefined) {
1381
+ return existing;
1382
+ }
1383
+ if (!isAitIapOrderId(orderId)
1384
+ || (providerOrderId !== undefined && providerOrderId !== orderId)) {
1385
+ return Promise.resolve(false);
1386
+ }
1387
+ providerOrderId = orderId;
1388
+ const attempt = verifyAndPersistAitIapProductGrant({
1389
+ verifier: input.verifier,
1390
+ storage: input.dependencies.storage,
1391
+ orderId,
1392
+ product: input.product,
1393
+ idempotencyKey: input.idempotencyKey,
1394
+ orderIdempotencyKey: createAitIapOrderIdempotencyKey(orderId),
1395
+ source: 'process-product-grant',
1396
+ timeoutMs: input.timeoutMs,
1397
+ }).then((granted) => {
1398
+ if (granted) {
1399
+ grantedOrderId = orderId;
1400
+ }
1401
+ return granted;
1402
+ });
1403
+ grantAttempts.set(orderId, attempt);
1404
+ return attempt;
1405
+ };
1406
+ sessionTimeout = globalThis.setTimeout(() => {
1407
+ // The native SDK may have dispatched an order even when it fails to
1408
+ // deliver a terminal callback. Preserve the marker and let retry or
1409
+ // restore reconcile it instead of opening a second checkout.
1410
+ finish(pendingPurchase(grantedOrderId));
1411
+ }, defaultIapPurchaseSessionTimeoutMs);
1412
+ try {
1413
+ cleanup = input.dependencies.iap.createOneTimePurchaseOrder({
1414
+ options: {
1415
+ sku: input.product.sku,
1416
+ processProductGrant: ({ orderId }) => processProductGrant(orderId),
1417
+ },
1418
+ onEvent: async ({ data }) => {
1419
+ const granted = await (grantAttempts.get(data.orderId) ?? processProductGrant(data.orderId));
1420
+ if (!granted) {
1421
+ finish(pendingPurchase(providerOrderId));
1422
+ return;
1423
+ }
1424
+ const result = completedAitIapPurchase(input.product, data.orderId);
1425
+ const persisted = await persistAitIapPurchaseAttempt({
1426
+ storage: input.dependencies.storage,
1427
+ product: input.product,
1428
+ idempotencyKey: input.idempotencyKey,
1429
+ orderId: data.orderId,
1430
+ status: aitIapPurchaseAttemptStatus.completed,
1431
+ timeoutMs: input.timeoutMs,
1432
+ });
1433
+ if (persisted) {
1434
+ void input.retainCompletedAttempt(input.dependencies.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey));
1435
+ }
1436
+ finish(persisted ? result : pendingPurchase(data.orderId));
1437
+ },
1438
+ onError: (error) => {
1439
+ if (grantedOrderId !== undefined
1440
+ || providerOrderId !== undefined
1441
+ || grantAttempts.size > 0
1442
+ || !isAitIapCancellation(error)) {
1443
+ // A cancellation racing an authoritative grant is ambiguous. Keep
1444
+ // the durable client retry barrier until retry/restore observes
1445
+ // the verifier's eventual result.
1446
+ finish(pendingPurchase(grantedOrderId ?? providerOrderId));
1447
+ return;
1448
+ }
1449
+ startMarkerDeletion(cancelledPurchase());
1450
+ },
1451
+ });
1452
+ if (cleanupRequested) {
1453
+ safelyCleanupAitIapPurchase(cleanup);
1454
+ }
1455
+ }
1456
+ catch {
1457
+ // A malformed SDK can invoke a grant callback and then throw during
1458
+ // registration. Preserve that active order; only a throw with no grant
1459
+ // startup proves there is no authoritative commit racing deletion.
1460
+ if (providerOrderId !== undefined || grantAttempts.size > 0) {
1461
+ finish(pendingPurchase(providerOrderId));
1462
+ return;
1463
+ }
1464
+ startMarkerDeletion(failedPurchase());
1465
+ }
1466
+ });
1467
+ }
1468
+ async function restoreAitIapProducts(input) {
1469
+ const prepare = input.prepare;
1470
+ const verifier = input.verifier;
1471
+ if (!isAitIapSupported(input) || prepare === undefined || verifier === undefined) {
1472
+ return { restoredEntitlements: [] };
1473
+ }
1474
+ const restoreStartedAt = Date.now();
1475
+ const getRemainingRestoreTimeout = () => {
1476
+ return remainingAitIapOperationTimeout(restoreStartedAt, input.timeoutMs);
1477
+ };
1478
+ const prepared = await prepareAitIap(prepare, { intent: 'restore' }, getRemainingRestoreTimeout());
1479
+ if (!prepared) {
1480
+ return { restoredEntitlements: [] };
1481
+ }
1482
+ const postPrepareTimeoutMs = getRemainingRestoreTimeout();
1483
+ const reservedEntitlementReadTimeoutMs = Math.max(1, Math.ceil(postPrepareTimeoutMs / 3));
1484
+ const reconciliationTimeoutMs = Math.max(0, postPrepareTimeoutMs - reservedEntitlementReadTimeoutMs);
1485
+ const reconciliationStartedAt = Date.now();
1486
+ const getRemainingReconciliationTimeout = () => {
1487
+ return Math.min(getRemainingRestoreTimeout(), remainingAitIapOperationTimeout(reconciliationStartedAt, reconciliationTimeoutMs));
1488
+ };
1489
+ const finishRestore = async (locallyRestoredEntitlements = []) => {
1490
+ const entitlementTimeoutMs = getRemainingRestoreTimeout();
1491
+ const authoritativeEntitlements = await readAitIapEntitlements(input.entitlementReader, entitlementTimeoutMs);
1492
+ return {
1493
+ restoredEntitlements: mergeAitIapRestoredEntitlements(locallyRestoredEntitlements, authoritativeEntitlements, input.products),
1494
+ };
1495
+ };
1496
+ if (reconciliationTimeoutMs === 0) {
1497
+ return await finishRestore();
1498
+ }
1499
+ let pendingOrders;
1500
+ try {
1501
+ const pendingOrderTimeoutMs = getRemainingReconciliationTimeout();
1502
+ if (pendingOrderTimeoutMs === 0) {
1503
+ return await finishRestore();
1504
+ }
1505
+ pendingOrders = await waitForAitIapNativeCall(() => input.dependencies.iap.getPendingOrders(), pendingOrderTimeoutMs);
1506
+ }
1507
+ catch {
1508
+ return await finishRestore();
1509
+ }
1510
+ if (pendingOrders === undefined) {
1511
+ return await finishRestore();
1512
+ }
1513
+ const eligibleOrders = [];
1514
+ const seenOrderIds = new Set();
1515
+ for (const order of pendingOrders.orders) {
1516
+ const product = input.products.bySku.get(order.sku);
1517
+ if (product === undefined
1518
+ || !isAitIapOrderId(order.orderId)
1519
+ || seenOrderIds.has(order.orderId)) {
1520
+ continue;
1521
+ }
1522
+ seenOrderIds.add(order.orderId);
1523
+ eligibleOrders.push({ order, product });
1524
+ }
1525
+ const restoredEntitlements = [];
1526
+ const cursorTimeoutMs = getRemainingReconciliationTimeout();
1527
+ const cursor = cursorTimeoutMs === 0
1528
+ ? undefined
1529
+ : await readAitIapPendingOrderCursor(input.dependencies.storage, cursorTimeoutMs);
1530
+ const orders = rotateAitIapPendingOrders(eligibleOrders, cursor, maximumPendingIapOrders);
1531
+ if (eligibleOrders.length > orders.length) {
1532
+ console.warn('AIT IAP pending-order restore reached its per-launch limit.', {
1533
+ limit: maximumPendingIapOrders,
1534
+ ignoredOrderCount: eligibleOrders.length - orders.length,
1535
+ });
1536
+ }
1537
+ for (const selectedOrder of orders) {
1538
+ const { order, product } = selectedOrder;
1539
+ const cursorWriteTimeoutMs = getRemainingReconciliationTimeout();
1540
+ if (cursorWriteTimeoutMs === 0) {
1541
+ break;
1542
+ }
1543
+ const cursorAdvanced = await writeAitIapPendingOrderCursor(input.dependencies.storage, order.orderId, cursorWriteTimeoutMs);
1544
+ if (!cursorAdvanced) {
1545
+ break;
1546
+ }
1547
+ const verificationTimeoutMs = getRemainingReconciliationTimeout();
1548
+ if (verificationTimeoutMs === 0) {
1549
+ break;
1550
+ }
1551
+ const granted = await verifyAitIapProductGrant({
1552
+ verifier,
1553
+ orderId: order.orderId,
1554
+ product,
1555
+ idempotencyKey: createAitIapOrderIdempotencyKey(order.orderId),
1556
+ source: 'pending-order-restore',
1557
+ timeoutMs: verificationTimeoutMs,
1558
+ });
1559
+ if (!granted) {
1560
+ continue;
1561
+ }
1562
+ try {
1563
+ const completionTimeoutMs = getRemainingReconciliationTimeout();
1564
+ if (completionTimeoutMs === 0) {
1565
+ break;
1566
+ }
1567
+ const completed = await waitForAitIapNativeCall(() => input.dependencies.iap.completeProductGrant({
1568
+ params: { orderId: order.orderId },
1569
+ }), completionTimeoutMs);
1570
+ if (completed === true) {
1571
+ restoredEntitlements.push({
1572
+ id: product.productId,
1573
+ source: 'purchase',
1574
+ grantedAt: normalizeAitIapGrantTime(order.paymentCompletedDate),
1575
+ });
1576
+ }
1577
+ }
1578
+ catch {
1579
+ // Keep provider state pending when its completion acknowledgement fails.
1580
+ }
1581
+ }
1582
+ return await finishRestore(restoredEntitlements);
1583
+ }
1584
+ function mergeAitIapRestoredEntitlements(restoredEntitlements, authoritativeEntitlements, products) {
1585
+ const entitlementsById = new Map();
1586
+ for (const entitlement of restoredEntitlements) {
1587
+ entitlementsById.set(entitlement.id, entitlement);
1588
+ }
1589
+ for (const entitlement of authoritativeEntitlements) {
1590
+ if (entitlement.source !== 'purchase'
1591
+ || !products.byProductId.has(entitlement.id)) {
1592
+ continue;
1593
+ }
1594
+ entitlementsById.set(entitlement.id, entitlement);
1595
+ }
1596
+ return [...entitlementsById.values()];
1597
+ }
1598
+ async function prepareAitIap(prepare, input, timeoutMs) {
1599
+ if (prepare === undefined) {
1600
+ return false;
1601
+ }
1602
+ let timeout;
1603
+ const timeoutResult = new Promise((resolve) => {
1604
+ timeout = globalThis.setTimeout(() => resolve(false), timeoutMs);
1605
+ });
1606
+ const preparationResult = Promise.resolve()
1607
+ .then(async () => prepare(input))
1608
+ .then((result) => result === true, () => false);
1609
+ try {
1610
+ return await Promise.race([preparationResult, timeoutResult]);
1611
+ }
1612
+ finally {
1613
+ if (timeout !== undefined) {
1614
+ globalThis.clearTimeout(timeout);
1615
+ }
1616
+ }
1617
+ }
1618
+ async function isAitOneTimeIapProduct(dependencies, configured, timeoutMs) {
1619
+ try {
1620
+ const result = await waitForAitIapNativeCall(() => dependencies.iap.getProductItemList(), timeoutMs);
1621
+ if (result === undefined) {
1622
+ return false;
1623
+ }
1624
+ const nativeProduct = result.products.find(({ sku }) => sku === configured.sku);
1625
+ if (nativeProduct === undefined) {
1626
+ return false;
1627
+ }
1628
+ return nativeProduct.type === 'CONSUMABLE' || nativeProduct.type === 'NON_CONSUMABLE';
1629
+ }
1630
+ catch {
1631
+ // The provider catalog is the authoritative source for the purchase flow.
1632
+ // Do not route an unknown or unavailable SKU into a one-time order.
1633
+ return false;
1634
+ }
1635
+ }
1636
+ async function waitForAitIapNativeCall(operation, timeoutMs) {
1637
+ let timeout;
1638
+ const timeoutResult = new Promise((resolve) => {
1639
+ timeout = globalThis.setTimeout(() => resolve(undefined), timeoutMs);
1640
+ });
1641
+ try {
1642
+ const operationResult = operation();
1643
+ return await Promise.race([operationResult, timeoutResult]);
1644
+ }
1645
+ finally {
1646
+ if (timeout !== undefined) {
1647
+ globalThis.clearTimeout(timeout);
1648
+ }
1649
+ }
1650
+ }
1651
+ async function verifyAitIapProductGrant(input) {
1652
+ const controller = new AbortController();
1653
+ let timeout;
1654
+ const timeoutResult = new Promise((resolve) => {
1655
+ timeout = globalThis.setTimeout(() => {
1656
+ controller.abort();
1657
+ resolve(false);
1658
+ }, input.timeoutMs);
1659
+ });
1660
+ const verifierResult = Promise.resolve()
1661
+ .then(async () => input.verifier({
1662
+ orderId: input.orderId,
1663
+ productId: input.product.productId,
1664
+ platformSku: input.product.sku,
1665
+ idempotencyKey: input.idempotencyKey,
1666
+ source: input.source,
1667
+ purchasedAt: new Date().toISOString(),
1668
+ signal: controller.signal,
1669
+ timeoutMs: input.timeoutMs,
1670
+ }))
1671
+ .then((result) => result === true, () => false);
1672
+ try {
1673
+ return await Promise.race([verifierResult, timeoutResult]);
1674
+ }
1675
+ finally {
1676
+ if (timeout !== undefined) {
1677
+ globalThis.clearTimeout(timeout);
1678
+ }
1679
+ }
1680
+ }
1681
+ function completedAitIapPurchase(product, orderId) {
1682
+ return {
1683
+ status: 'completed',
1684
+ transactionId: orderId,
1685
+ entitlementIds: [product.productId],
1686
+ evidence: createAitIapEvidence(orderId, product.sku, 'process-product-grant'),
1687
+ };
1688
+ }
1689
+ function failedPurchase() {
1690
+ return { status: 'failed', entitlementIds: [] };
1691
+ }
1692
+ function cancelledPurchase() {
1693
+ return { status: 'cancelled', entitlementIds: [] };
1694
+ }
1695
+ function pendingPurchase(transactionId) {
1696
+ return {
1697
+ status: 'pending',
1698
+ ...(transactionId === undefined ? {} : { transactionId }),
1699
+ entitlementIds: [],
1700
+ };
1701
+ }
1702
+ function createAitIapEvidence(orderId, sku, source) {
1703
+ return {
1704
+ schema: iapEvidenceSchema,
1705
+ payload: { orderId, sku, source },
1706
+ };
1707
+ }
1708
+ function createAitIapOrderIdempotencyKey(orderId) {
1709
+ return `apps-in-toss:purchase:${encodeURIComponent(orderId)}`;
1710
+ }
1711
+ function createAitIapPurchaseRequestKey(productId, idempotencyKey) {
1712
+ return `${encodeURIComponent(productId)}:${encodeURIComponent(idempotencyKey)}`;
1713
+ }
1714
+ function safelyCleanupAitIapPurchase(cleanup) {
1715
+ try {
1716
+ cleanup();
1717
+ }
1718
+ catch {
1719
+ // The bridge has already reached a terminal result; cleanup failures must
1720
+ // not turn a completed server grant into a client-visible failure.
1721
+ }
1722
+ }
1723
+ function isAitIapOrderId(value) {
1724
+ return value.length > 0 && value.length <= 2048 && !/[\p{Cc}\p{Cf}]/u.test(value);
1725
+ }
1726
+ function isAitIapCancellation(error) {
1727
+ if (isAbortError(error)) {
1728
+ return true;
1729
+ }
1730
+ if (!isRecord(error)) {
1731
+ return false;
1732
+ }
1733
+ const code = typeof error.code === 'string' ? error.code : '';
1734
+ return /CANCEL|ABORT/u.test(code);
1735
+ }
1736
+ function normalizeAitIapGrantTime(value) {
1737
+ if (value.length === 0 || value.length > 2048 || /[\p{Cc}\p{Cf}]/u.test(value)) {
1738
+ return new Date().toISOString();
1739
+ }
1740
+ const parsed = Date.parse(value);
1741
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString();
1742
+ }
1743
+ function toAitIapProductInfo(nativeProduct, configured) {
1744
+ const title = normalizeAitIapDisplayText(nativeProduct.displayName);
1745
+ const description = normalizeAitIapDisplayText(nativeProduct.description);
1746
+ const formattedPrice = normalizeAitIapDisplayText(nativeProduct.displayAmount);
1747
+ if (title === undefined || description === undefined || formattedPrice === undefined) {
1748
+ return undefined;
1749
+ }
1750
+ if (nativeProduct.type === 'SUBSCRIPTION') {
1751
+ // Subscription creation, renewal, and expiry need a different native and
1752
+ // server-authority lifecycle. This bridge only owns one-time orders.
1753
+ console.warn('AIT subscription IAP is unavailable through the one-time order bridge.', {
1754
+ productId: configured.productId,
1755
+ sku: nativeProduct.sku,
1756
+ });
1757
+ return undefined;
1758
+ }
1759
+ let type;
1760
+ if (nativeProduct.type === 'CONSUMABLE') {
1761
+ type = 'consumable';
1762
+ }
1763
+ else if (nativeProduct.type === 'NON_CONSUMABLE') {
1764
+ type = 'non_consumable';
1765
+ }
1766
+ if (type === undefined) {
1767
+ console.warn('AIT native IAP product has an unsupported type; hiding product.', {
1768
+ productId: configured.productId,
1769
+ sku: nativeProduct.sku,
1770
+ nativeType: nativeProduct.type,
1771
+ });
1772
+ return undefined;
1773
+ }
1774
+ return {
1775
+ id: configured.productId,
1776
+ type,
1777
+ title,
1778
+ description,
1779
+ price: {
1780
+ formatted: formattedPrice,
1781
+ currencyCode: configured.currencyCode,
1782
+ },
1783
+ };
1784
+ }
1785
+ function normalizeAitIapDisplayText(value) {
1786
+ const normalized = value.trim();
1787
+ return normalized.length > 0 && normalized.length <= 2048 && !/[\p{Cc}\p{Cf}]/u.test(normalized)
1788
+ ? normalized
1789
+ : undefined;
1790
+ }
1791
+ function normalizeIapProducts(input) {
1792
+ const byProductId = new Map();
1793
+ const bySku = new Map();
1794
+ for (const rawProduct of input ?? []) {
1795
+ const productId = typeof rawProduct.productId === 'string'
1796
+ ? normalizeAitIapConfigIdentifier(rawProduct.productId, 'productId', 128)
1797
+ : undefined;
1798
+ const sku = typeof rawProduct.sku === 'string'
1799
+ ? normalizeAitIapConfigIdentifier(rawProduct.sku, 'sku', 2048)
1800
+ : undefined;
1801
+ const currencyCode = normalizeAitIapCurrencyCode(rawProduct.currencyCode);
1802
+ if (productId === undefined || sku === undefined || currencyCode === undefined) {
1803
+ console.warn('AIT IAP product configuration is invalid; disabling one product.', {
1804
+ productId: rawProduct.productId,
1805
+ sku: rawProduct.sku,
1806
+ });
1807
+ continue;
1808
+ }
1809
+ if (byProductId.has(productId) || bySku.has(sku)) {
1810
+ console.warn('AIT IAP product configuration has a duplicate product id or SKU; disabling one product.', { productId, sku });
1811
+ continue;
1812
+ }
1813
+ const product = {
1814
+ productId: productId,
1815
+ sku,
1816
+ currencyCode,
1817
+ };
1818
+ byProductId.set(product.productId, product);
1819
+ bySku.set(product.sku, product);
1820
+ }
1821
+ return { byProductId, bySku };
1822
+ }
1823
+ function normalizeAitIapConfigIdentifier(value, field, maximumLength) {
1824
+ const normalized = value.trim();
1825
+ if (normalized.length === 0
1826
+ || normalized.length > maximumLength
1827
+ || /[\p{Cc}\p{Cf}]/u.test(normalized)) {
1828
+ return undefined;
1829
+ }
1830
+ return normalized;
1831
+ }
1832
+ function normalizeAitIapCurrencyCode(value) {
1833
+ const normalized = value?.trim().toUpperCase() ?? 'KRW';
1834
+ return /^[A-Z]{3}$/u.test(normalized) ? normalized : undefined;
1835
+ }
904
1836
  function areFullScreenAdsSupported(dependencies) {
905
- return isCapabilitySupported(() => dependencies.loadFullScreenAd.isSupported())
906
- && isCapabilitySupported(() => dependencies.showFullScreenAd.isSupported());
1837
+ return isAitNativeMethodSupported(dependencies.loadFullScreenAd)
1838
+ && isAitNativeMethodSupported(dependencies.showFullScreenAd);
1839
+ }
1840
+ function isAitNativeMethodSupported(method) {
1841
+ return isCapabilitySupported(() => method.isSupported?.() === true);
907
1842
  }
908
1843
  function isCapabilitySupported(check) {
909
1844
  try {
@@ -995,6 +1930,13 @@ function normalizeDisplayStartTimeout(value) {
995
1930
  function normalizeMaximumDisplayTimeout(value) {
996
1931
  return normalizePositiveTimeout(value, defaultAdMaximumDisplayMs, 'AIT ad maximum display timeout must be a positive finite number.');
997
1932
  }
1933
+ function normalizeIapProductGrantTimeout(value) {
1934
+ const timeout = normalizePositiveTimeout(value, defaultIapProductGrantTimeoutMs, 'AIT IAP product-grant timeout must be a positive finite number.');
1935
+ if (timeout > defaultIapProductGrantTimeoutMs) {
1936
+ throw new Error(`AIT IAP product-grant timeout cannot exceed ${defaultIapProductGrantTimeoutMs}ms.`);
1937
+ }
1938
+ return timeout;
1939
+ }
998
1940
  function normalizePositiveTimeout(value, defaultValue, message) {
999
1941
  if (value === undefined) {
1000
1942
  return defaultValue;
@@ -1022,6 +1964,24 @@ function readIdempotencyKey(payload, fallback) {
1022
1964
  const value = readPayloadRecord(payload).idempotencyKey;
1023
1965
  return typeof value === 'string' && value.length > 0 ? value : fallback;
1024
1966
  }
1967
+ function readCommercePurchase(payload) {
1968
+ const value = readPayloadRecord(payload);
1969
+ const productId = value.productId;
1970
+ const idempotencyKey = value.idempotencyKey;
1971
+ if (typeof productId !== 'string'
1972
+ || productId.length === 0
1973
+ || productId.length > 128
1974
+ || /[\p{Cc}\p{Cf}]/u.test(productId)) {
1975
+ throw new TypeError('AIT IAP productId must contain 1 to 128 visible characters.');
1976
+ }
1977
+ if (typeof idempotencyKey !== 'string'
1978
+ || idempotencyKey.length === 0
1979
+ || idempotencyKey.length > 256
1980
+ || /[\p{Cc}\p{Cf}]/u.test(idempotencyKey)) {
1981
+ throw new TypeError('AIT IAP idempotencyKey must contain 1 to 256 visible characters.');
1982
+ }
1983
+ return { productId: productId, idempotencyKey };
1984
+ }
1025
1985
  function readRequiredIdempotencyKey(payload) {
1026
1986
  const value = readPayloadRecord(payload).idempotencyKey;
1027
1987
  if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
package/dist/index.js CHANGED
@@ -98,6 +98,7 @@ export function createAitSandboxBridge(options = {}) {
98
98
  rewardedAds: true,
99
99
  interstitialAds: true,
100
100
  nativeLeaderboard: true,
101
+ remoteLeaderboard: false,
101
102
  achievements: false,
102
103
  cloudSave: false,
103
104
  socialShare: true,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Browser-only SDK 3 development substitute.
3
+ *
4
+ * Vite aliases the native Apps in Toss module to this file only for explicit
5
+ * local wrapper development. It intentionally exposes no ads, promotions, or
6
+ * commerce: those paths must be verified in the Toss app sandbox/console.
7
+ */
8
+ declare function unsupported(): boolean;
9
+ export declare const Storage: {
10
+ getItem(key: string): Promise<string | null>;
11
+ setItem(key: string, value: string): Promise<void>;
12
+ removeItem(key: string): Promise<void>;
13
+ };
14
+ export declare function getUserKeyForGame(): Promise<{
15
+ readonly type: 'HASH';
16
+ readonly hash: string;
17
+ }>;
18
+ export declare function getTossShareLink(): Promise<string>;
19
+ export declare function share(): Promise<void>;
20
+ export declare function isMinVersionSupported(): boolean;
21
+ export declare const grantPromotionRewardForGame: (() => Promise<'ERROR'>) & {
22
+ isSupported: typeof unsupported;
23
+ };
24
+ export declare const requestNotificationAgreement: ((_callbacks: unknown) => (() => void)) & {
25
+ isSupported: typeof unsupported;
26
+ };
27
+ export declare const loadFullScreenAd: ((_callbacks: unknown) => (() => void)) & {
28
+ isSupported: typeof unsupported;
29
+ };
30
+ export declare const showFullScreenAd: ((_callbacks: unknown) => (() => void)) & {
31
+ isSupported: typeof unsupported;
32
+ };
33
+ export declare function openGameCenterLeaderboard(): Promise<void>;
34
+ export declare function submitGameCenterLeaderBoardScore(): Promise<{
35
+ readonly statusCode: 'ERROR';
36
+ }>;
37
+ export declare const IAP: {
38
+ createOneTimePurchaseOrder: ((_callbacks: unknown) => (() => void)) & {
39
+ isSupported: typeof unsupported;
40
+ };
41
+ getProductItemList: (() => Promise<{
42
+ readonly products: readonly unknown[];
43
+ }>) & {
44
+ isSupported: typeof unsupported;
45
+ };
46
+ getPendingOrders: (() => Promise<{
47
+ readonly orders: readonly unknown[];
48
+ }>) & {
49
+ isSupported: typeof unsupported;
50
+ };
51
+ completeProductGrant: (() => Promise<boolean>) & {
52
+ isSupported: typeof unsupported;
53
+ };
54
+ };
55
+ export {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Browser-only SDK 3 development substitute.
3
+ *
4
+ * Vite aliases the native Apps in Toss module to this file only for explicit
5
+ * local wrapper development. It intentionally exposes no ads, promotions, or
6
+ * commerce: those paths must be verified in the Toss app sandbox/console.
7
+ */
8
+ const localStorageValues = new Map();
9
+ function unsupported() {
10
+ return false;
11
+ }
12
+ function noOpSubscription() {
13
+ return () => { };
14
+ }
15
+ export const Storage = {
16
+ async getItem(key) {
17
+ return localStorageValues.get(key) ?? null;
18
+ },
19
+ async setItem(key, value) {
20
+ localStorageValues.set(key, value);
21
+ },
22
+ async removeItem(key) {
23
+ localStorageValues.delete(key);
24
+ },
25
+ };
26
+ export async function getUserKeyForGame() {
27
+ return { type: 'HASH', hash: 'ait-local-player' };
28
+ }
29
+ export async function getTossShareLink() {
30
+ return globalThis.location?.href ?? 'https://localhost/';
31
+ }
32
+ export async function share() { }
33
+ export function isMinVersionSupported() {
34
+ return false;
35
+ }
36
+ export const grantPromotionRewardForGame = Object.assign(async () => 'ERROR', {
37
+ isSupported: unsupported,
38
+ });
39
+ export const requestNotificationAgreement = Object.assign((_callbacks) => noOpSubscription(), { isSupported: unsupported });
40
+ export const loadFullScreenAd = Object.assign((_callbacks) => noOpSubscription(), { isSupported: unsupported });
41
+ export const showFullScreenAd = Object.assign((_callbacks) => noOpSubscription(), { isSupported: unsupported });
42
+ export async function openGameCenterLeaderboard() { }
43
+ export async function submitGameCenterLeaderBoardScore() {
44
+ return { statusCode: 'ERROR' };
45
+ }
46
+ export const IAP = {
47
+ createOneTimePurchaseOrder: Object.assign((_callbacks) => noOpSubscription(), { isSupported: unsupported }),
48
+ getProductItemList: Object.assign(async () => ({ products: [] }), { isSupported: unsupported }),
49
+ getPendingOrders: Object.assign(async () => ({ orders: [] }), { isSupported: unsupported }),
50
+ completeProductGrant: Object.assign(async () => false, { isSupported: unsupported }),
51
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-ait",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Apps in Toss WebView platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,17 +41,21 @@
41
41
  "types": "./dist/ad-config.d.ts",
42
42
  "default": "./dist/ad-config.js"
43
43
  },
44
+ "./local-mock": {
45
+ "types": "./dist/local-mock.d.ts",
46
+ "default": "./dist/local-mock.js"
47
+ },
44
48
  "./package.json": "./package.json"
45
49
  },
46
50
  "files": [
47
51
  "dist"
48
52
  ],
49
53
  "dependencies": {
50
- "@mpgd/platform": "0.8.0",
51
- "@mpgd/bridge": "0.7.0"
54
+ "@mpgd/bridge": "0.8.0",
55
+ "@mpgd/platform": "0.9.0"
52
56
  },
53
57
  "peerDependencies": {
54
- "@apps-in-toss/web-framework": ">=3.0.0-beta <4"
58
+ "@apps-in-toss/web-framework": ">=3.0.0 <4"
55
59
  },
56
60
  "peerDependenciesMeta": {
57
61
  "@apps-in-toss/web-framework": {
@@ -59,7 +63,7 @@
59
63
  }
60
64
  },
61
65
  "devDependencies": {
62
- "@apps-in-toss/web-framework": "3.0.0-beta.da14818",
66
+ "@apps-in-toss/web-framework": "3.0.2",
63
67
  "ttsc": "0.18.4",
64
68
  "typescript": "7.0.2",
65
69
  "vitest": "^4.0.0"