@mpgd/adapter-ait 0.7.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 +116 -7
- package/dist/host.js +1316 -14
- package/dist/index.js +9 -0
- package/dist/local-mock.d.ts +55 -0
- package/dist/local-mock.js +51 -0
- package/package.json +9 -5
package/dist/host.js
CHANGED
|
@@ -1,12 +1,39 @@
|
|
|
1
|
-
import { getTossShareLink, getUserKeyForGame, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, 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';
|
|
17
|
+
const minimumPromotionTossAppVersion = '5.232.0';
|
|
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';
|
|
31
|
+
const defaultNotificationAgreementTimeoutMs = 120000;
|
|
32
|
+
const notificationTopics = new Set([
|
|
33
|
+
'daily-ready',
|
|
34
|
+
'streak-at-risk',
|
|
35
|
+
'friend-challenge',
|
|
36
|
+
]);
|
|
10
37
|
const launchEntries = new Set([
|
|
11
38
|
'home',
|
|
12
39
|
'daily',
|
|
@@ -21,11 +48,14 @@ const defaultDependencies = {
|
|
|
21
48
|
storage: Storage,
|
|
22
49
|
getTossShareLink,
|
|
23
50
|
share,
|
|
51
|
+
grantPromotionReward,
|
|
52
|
+
requestNotificationAgreement,
|
|
24
53
|
isMinVersionSupported,
|
|
25
54
|
loadFullScreenAd,
|
|
26
55
|
showFullScreenAd,
|
|
27
56
|
openGameCenterLeaderboard,
|
|
28
57
|
submitGameCenterLeaderBoardScore,
|
|
58
|
+
iap: IAP,
|
|
29
59
|
};
|
|
30
60
|
export function installAitHostBridge(options = {}) {
|
|
31
61
|
const bridge = createAitHostBridge(options);
|
|
@@ -37,6 +67,16 @@ export function createAitHostBridge(options = {}) {
|
|
|
37
67
|
const appName = normalizeAppName(options.appName ?? 'mpgd-kit');
|
|
38
68
|
const adGroupIds = normalizeAdGroupIds(options.adGroupIds);
|
|
39
69
|
const adPlacementTypes = normalizeAdPlacementTypes(options.adPlacementTypes);
|
|
70
|
+
const promotionRewards = normalizePromotionRewards(options.promotionRewards);
|
|
71
|
+
const authorizePromotionGrant = options.authorizePromotionGrant;
|
|
72
|
+
const resolvePendingPromotionGrant = options.resolvePendingPromotionGrant;
|
|
73
|
+
const notificationTemplateCodes = normalizeNotificationTemplateCodes(options.notificationTemplateCodes);
|
|
74
|
+
const iapProducts = normalizeIapProducts(options.iapProducts);
|
|
75
|
+
const iapProductGrantTimeoutMs = normalizeIapProductGrantTimeout(options.iapProductGrantTimeoutMs);
|
|
76
|
+
const promotionGrantsInFlight = new Map();
|
|
77
|
+
const iapPurchasesInFlight = new Map();
|
|
78
|
+
let completedIapAttemptRetention = Promise.resolve();
|
|
79
|
+
const notificationSubscriptions = new Set();
|
|
40
80
|
const loadedAdGroupIds = new Set();
|
|
41
81
|
const loadingAdGroups = new Map();
|
|
42
82
|
const activeAdGroupIds = new Set();
|
|
@@ -49,6 +89,15 @@ export function createAitHostBridge(options = {}) {
|
|
|
49
89
|
active: undefined,
|
|
50
90
|
waitTimeoutMs: adLoadQueueTimeoutMs,
|
|
51
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
|
+
};
|
|
52
101
|
return {
|
|
53
102
|
async request(input) {
|
|
54
103
|
try {
|
|
@@ -67,12 +116,21 @@ export function createAitHostBridge(options = {}) {
|
|
|
67
116
|
&& hasConfiguredAdType(adGroupIds, adPlacementTypes, 'rewarded');
|
|
68
117
|
const interstitialAds = adsSupported
|
|
69
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
|
+
});
|
|
70
127
|
return ok(request, {
|
|
71
|
-
nativeIap
|
|
128
|
+
nativeIap,
|
|
72
129
|
nativeAds: rewardedAds || interstitialAds,
|
|
73
130
|
rewardedAds,
|
|
74
131
|
interstitialAds,
|
|
75
132
|
nativeLeaderboard: isGameCenterSupported(dependencies),
|
|
133
|
+
remoteLeaderboard: false,
|
|
76
134
|
achievements: false,
|
|
77
135
|
cloudSave: false,
|
|
78
136
|
socialShare: true,
|
|
@@ -111,18 +169,145 @@ export function createAitHostBridge(options = {}) {
|
|
|
111
169
|
case 'share.readInboundShare':
|
|
112
170
|
return ok(request, readInboundShare());
|
|
113
171
|
case 'notifications.getStatus':
|
|
114
|
-
return ok(request,
|
|
115
|
-
case 'notifications.requestSubscription':
|
|
116
|
-
|
|
117
|
-
|
|
172
|
+
return ok(request, getNotificationStatus(readNotificationTopic(request.payload), notificationTemplateCodes, notificationSubscriptions, dependencies));
|
|
173
|
+
case 'notifications.requestSubscription': {
|
|
174
|
+
const topic = readNotificationTopic(request.payload);
|
|
175
|
+
const templateCode = notificationTemplateCodes.get(topic);
|
|
176
|
+
if (templateCode === undefined
|
|
177
|
+
|| !isAitNativeMethodSupported(dependencies.requestNotificationAgreement)) {
|
|
178
|
+
return ok(request, 'unavailable');
|
|
179
|
+
}
|
|
180
|
+
const result = await requestAitNotificationAgreement(dependencies.requestNotificationAgreement, templateCode);
|
|
181
|
+
if (result === 'subscribed') {
|
|
182
|
+
notificationSubscriptions.add(topic);
|
|
183
|
+
}
|
|
184
|
+
return ok(request, result);
|
|
185
|
+
}
|
|
186
|
+
case 'promotions.getAvailability': {
|
|
187
|
+
const campaignId = readCampaignId(request.payload);
|
|
188
|
+
const availability = getPromotionAvailability(campaignId, promotionRewards, authorizePromotionGrant, dependencies);
|
|
189
|
+
return ok(request, availability);
|
|
190
|
+
}
|
|
191
|
+
case 'promotions.grantReward': {
|
|
192
|
+
const campaignId = readCampaignId(request.payload);
|
|
193
|
+
const reward = promotionRewards.get(campaignId);
|
|
194
|
+
const idempotencyKey = readRequiredIdempotencyKey(request.payload);
|
|
195
|
+
if (reward === undefined
|
|
196
|
+
|| !isPromotionSupported(dependencies)) {
|
|
197
|
+
return ok(request, { status: 'unavailable' });
|
|
198
|
+
}
|
|
199
|
+
const persisted = await resolvePersistedPromotionGrant({
|
|
200
|
+
campaignId,
|
|
201
|
+
idempotencyKey,
|
|
202
|
+
storage: dependencies.storage,
|
|
203
|
+
...(resolvePendingPromotionGrant === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { resolver: resolvePendingPromotionGrant }),
|
|
206
|
+
});
|
|
207
|
+
if (persisted.result !== undefined) {
|
|
208
|
+
return ok(request, persisted.result);
|
|
209
|
+
}
|
|
210
|
+
const existing = promotionGrantsInFlight.get(idempotencyKey);
|
|
211
|
+
if (existing !== undefined) {
|
|
212
|
+
return ok(request, await existing);
|
|
213
|
+
}
|
|
214
|
+
const pending = authorizeAndGrantAitPromotionReward({
|
|
215
|
+
campaignId,
|
|
216
|
+
idempotencyKey,
|
|
217
|
+
reward,
|
|
218
|
+
dependencies,
|
|
219
|
+
...(authorizePromotionGrant === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { authorizer: authorizePromotionGrant }),
|
|
222
|
+
});
|
|
223
|
+
promotionGrantsInFlight.set(idempotencyKey, pending);
|
|
224
|
+
try {
|
|
225
|
+
return ok(request, await pending);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (promotionGrantsInFlight.get(idempotencyKey) === pending) {
|
|
229
|
+
promotionGrantsInFlight.delete(idempotencyKey);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// IAP is opt-in and server-authoritative. Never return demo grants.
|
|
118
234
|
case 'commerce.getProducts':
|
|
119
|
-
return ok(request,
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
122
300
|
case 'commerce.restore':
|
|
123
|
-
return ok(request, {
|
|
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
|
+
}));
|
|
124
309
|
case 'commerce.getEntitlements':
|
|
125
|
-
return ok(request,
|
|
310
|
+
return ok(request, await readAitIapEntitlements(options.readIapEntitlements, iapProductGrantTimeoutMs));
|
|
126
311
|
case 'ads.preload': {
|
|
127
312
|
const placementId = readPlacementId(request.payload);
|
|
128
313
|
const adGroupId = adGroupIds.get(placementId);
|
|
@@ -130,7 +315,7 @@ export function createAitHostBridge(options = {}) {
|
|
|
130
315
|
if (adGroupId === undefined || placementType === undefined) {
|
|
131
316
|
return createBridgeError(request.id, 'AIT_AD_UNAVAILABLE', `AIT ad placement is unavailable: ${placementId}`);
|
|
132
317
|
}
|
|
133
|
-
if (!
|
|
318
|
+
if (!isAitNativeMethodSupported(dependencies.loadFullScreenAd)) {
|
|
134
319
|
if (!warnedUnsupportedAdPreload) {
|
|
135
320
|
warnedUnsupportedAdPreload = true;
|
|
136
321
|
console.warn('AIT full-screen ads are not supported; configured preload is a no-op.', placementId);
|
|
@@ -228,6 +413,227 @@ export function createAitHostBridge(options = {}) {
|
|
|
228
413
|
}
|
|
229
414
|
}
|
|
230
415
|
}
|
|
416
|
+
function getNotificationStatus(topic, templateCodes, subscriptions, dependencies) {
|
|
417
|
+
if (!templateCodes.has(topic)) {
|
|
418
|
+
return 'configuration-required';
|
|
419
|
+
}
|
|
420
|
+
if (!isAitNativeMethodSupported(dependencies.requestNotificationAgreement)) {
|
|
421
|
+
return 'unsupported';
|
|
422
|
+
}
|
|
423
|
+
return subscriptions.has(topic) ? 'subscribed' : 'not-subscribed';
|
|
424
|
+
}
|
|
425
|
+
function requestAitNotificationAgreement(requestAgreement, templateCode) {
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
let settled = false;
|
|
428
|
+
let cleanup = () => { };
|
|
429
|
+
const finish = (result) => {
|
|
430
|
+
if (settled) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
settled = true;
|
|
434
|
+
globalThis.clearTimeout(timer);
|
|
435
|
+
cleanup();
|
|
436
|
+
resolve(result);
|
|
437
|
+
};
|
|
438
|
+
const timer = globalThis.setTimeout(() => finish('unavailable'), defaultNotificationAgreementTimeoutMs);
|
|
439
|
+
try {
|
|
440
|
+
const unregister = requestAgreement({
|
|
441
|
+
options: { templateCode },
|
|
442
|
+
onEvent: ({ type }) => {
|
|
443
|
+
finish(type === 'agreementRejected' ? 'rejected' : 'subscribed');
|
|
444
|
+
},
|
|
445
|
+
onError: () => finish('unavailable'),
|
|
446
|
+
});
|
|
447
|
+
cleanup = unregister;
|
|
448
|
+
if (settled) {
|
|
449
|
+
cleanup();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
finish('unavailable');
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function getPromotionAvailability(campaignId, rewards, authorizer, dependencies) {
|
|
458
|
+
if (!rewards.has(campaignId) || authorizer === undefined) {
|
|
459
|
+
return 'configuration-required';
|
|
460
|
+
}
|
|
461
|
+
return isPromotionSupported(dependencies) ? 'available' : 'unsupported';
|
|
462
|
+
}
|
|
463
|
+
async function authorizeAndGrantAitPromotionReward(input) {
|
|
464
|
+
if (input.authorizer === undefined) {
|
|
465
|
+
return { status: 'unavailable' };
|
|
466
|
+
}
|
|
467
|
+
let authorization;
|
|
468
|
+
try {
|
|
469
|
+
authorization = await input.authorizer({
|
|
470
|
+
campaignId: input.campaignId,
|
|
471
|
+
idempotencyKey: input.idempotencyKey,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
console.warn('AIT promotion grant authorization failed; keeping the claim pending.', input.campaignId, error);
|
|
476
|
+
return { status: 'pending' };
|
|
477
|
+
}
|
|
478
|
+
if (authorization.status !== 'authorized') {
|
|
479
|
+
return {
|
|
480
|
+
status: authorization.status === 'pending' ? 'pending' : 'unavailable',
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return grantAitPromotionReward(input.dependencies, input.reward, input.idempotencyKey);
|
|
484
|
+
}
|
|
485
|
+
function isPromotionSupported(dependencies) {
|
|
486
|
+
return isCapabilitySupported(() => dependencies.isMinVersionSupported({
|
|
487
|
+
android: minimumPromotionTossAppVersion,
|
|
488
|
+
ios: minimumPromotionTossAppVersion,
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
async function grantAitPromotionReward(dependencies, reward, idempotencyKey) {
|
|
492
|
+
const storageKey = promotionGrantStorageKey(idempotencyKey);
|
|
493
|
+
await dependencies.storage.setItem(storageKey, JSON.stringify({
|
|
494
|
+
status: 'pending',
|
|
495
|
+
pendingSince: new Date().toISOString(),
|
|
496
|
+
}));
|
|
497
|
+
try {
|
|
498
|
+
const response = await dependencies.grantPromotionReward({
|
|
499
|
+
params: {
|
|
500
|
+
promotionCode: reward.promotionCode,
|
|
501
|
+
amount: reward.amount,
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
if (isExplicitPromotionFailure(response)) {
|
|
505
|
+
return clearFailedPromotionGrant(dependencies.storage, storageKey);
|
|
506
|
+
}
|
|
507
|
+
if (!isRecord(response)
|
|
508
|
+
|| typeof response.key !== 'string'
|
|
509
|
+
|| response.key.trim().length === 0) {
|
|
510
|
+
// An undocumented shape is ambiguous after native dispatch. Preserve the
|
|
511
|
+
// marker so a backend resolver can determine whether a grant happened.
|
|
512
|
+
return { status: 'pending' };
|
|
513
|
+
}
|
|
514
|
+
const result = { status: 'granted', receiptKey: response.key };
|
|
515
|
+
try {
|
|
516
|
+
await dependencies.storage.setItem(storageKey, JSON.stringify(result));
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
console.warn('AIT native promotion receipt could not be cached; keeping it terminal.', reward.promotionCode, error);
|
|
520
|
+
}
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// Keep the pending marker. A provider error after dispatch is ambiguous and
|
|
525
|
+
// retrying the same promotion blindly can double-grant Toss points.
|
|
526
|
+
return { status: 'pending' };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function isExplicitPromotionFailure(value) {
|
|
530
|
+
if (value === 'ERROR') {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
return isRecord(value)
|
|
534
|
+
&& typeof value.errorCode === 'string'
|
|
535
|
+
&& value.errorCode.trim().length > 0
|
|
536
|
+
&& typeof value.message === 'string';
|
|
537
|
+
}
|
|
538
|
+
async function clearFailedPromotionGrant(storage, storageKey) {
|
|
539
|
+
try {
|
|
540
|
+
await storage.removeItem(storageKey);
|
|
541
|
+
return { status: 'failed' };
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
console.warn('AIT failed promotion marker could not be cleared; keeping the claim pending.', storageKey, error);
|
|
545
|
+
return { status: 'pending' };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function resolvePersistedPromotionGrant(input) {
|
|
549
|
+
const storageKey = promotionGrantStorageKey(input.idempotencyKey);
|
|
550
|
+
const serialized = await input.storage.getItem(storageKey);
|
|
551
|
+
if (serialized === null) {
|
|
552
|
+
return {};
|
|
553
|
+
}
|
|
554
|
+
let state;
|
|
555
|
+
try {
|
|
556
|
+
state = JSON.parse(serialized);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
console.warn('AIT persisted promotion state is unreadable; keeping the claim pending.', input.campaignId, error);
|
|
560
|
+
return { result: { status: 'pending' } };
|
|
561
|
+
}
|
|
562
|
+
if (!isRecord(state)) {
|
|
563
|
+
console.warn('AIT persisted promotion state is malformed; keeping the claim pending.', input.campaignId);
|
|
564
|
+
return { result: { status: 'pending' } };
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
if (state.status === 'granted'
|
|
568
|
+
&& typeof state.receiptKey === 'string'
|
|
569
|
+
&& state.receiptKey.trim().length > 0) {
|
|
570
|
+
return { result: { status: 'granted', receiptKey: state.receiptKey } };
|
|
571
|
+
}
|
|
572
|
+
if (state.status === 'pending') {
|
|
573
|
+
const pendingSince = normalizePendingSince(state.pendingSince);
|
|
574
|
+
if (pendingSince === undefined) {
|
|
575
|
+
return { result: { status: 'pending' } };
|
|
576
|
+
}
|
|
577
|
+
if (input.resolver === undefined) {
|
|
578
|
+
return { result: { status: 'pending' } };
|
|
579
|
+
}
|
|
580
|
+
let resolution;
|
|
581
|
+
try {
|
|
582
|
+
resolution = await input.resolver({
|
|
583
|
+
campaignId: input.campaignId,
|
|
584
|
+
idempotencyKey: input.idempotencyKey,
|
|
585
|
+
pendingSince,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
console.warn('AIT pending promotion grant resolver failed; keeping the claim pending.', input.campaignId, error);
|
|
590
|
+
return { result: { status: 'pending' } };
|
|
591
|
+
}
|
|
592
|
+
if (resolution.status === 'pending') {
|
|
593
|
+
return { result: { status: 'pending' } };
|
|
594
|
+
}
|
|
595
|
+
if (resolution.status === 'granted') {
|
|
596
|
+
if (typeof resolution.receiptKey !== 'string'
|
|
597
|
+
|| resolution.receiptKey.trim().length === 0) {
|
|
598
|
+
return { result: { status: 'pending' } };
|
|
599
|
+
}
|
|
600
|
+
const result = { status: 'granted', receiptKey: resolution.receiptKey };
|
|
601
|
+
try {
|
|
602
|
+
await input.storage.setItem(storageKey, JSON.stringify(result));
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
console.warn('AIT reconciled promotion receipt could not be cached; keeping it terminal.', input.campaignId, error);
|
|
606
|
+
}
|
|
607
|
+
return { result };
|
|
608
|
+
}
|
|
609
|
+
return removeInvalidPersistedPromotionGrant(input.storage, storageKey);
|
|
610
|
+
}
|
|
611
|
+
console.warn('AIT persisted promotion state has an unknown status; keeping the claim pending.', input.campaignId);
|
|
612
|
+
return { result: { status: 'pending' } };
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
console.warn('AIT persisted promotion state could not be reconciled; keeping the claim pending.', input.campaignId, error);
|
|
616
|
+
return { result: { status: 'pending' } };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function removeInvalidPersistedPromotionGrant(storage, storageKey) {
|
|
620
|
+
try {
|
|
621
|
+
await storage.removeItem(storageKey);
|
|
622
|
+
return {};
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return { result: { status: 'pending' } };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function promotionGrantStorageKey(idempotencyKey) {
|
|
629
|
+
return `${promotionGrantStoragePrefix}${encodeURIComponent(idempotencyKey)}`;
|
|
630
|
+
}
|
|
631
|
+
function normalizePendingSince(value) {
|
|
632
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
return Number.isFinite(Date.parse(value)) ? value : undefined;
|
|
636
|
+
}
|
|
231
637
|
export async function shareIntent(payload, dependencies) {
|
|
232
638
|
const intent = readPayloadRecord(payload);
|
|
233
639
|
if (typeof intent.text !== 'string' || typeof intent.deepLink !== 'string') {
|
|
@@ -606,9 +1012,833 @@ function isGameCenterSupported(dependencies) {
|
|
|
606
1012
|
ios: '5.221.0',
|
|
607
1013
|
}));
|
|
608
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
|
+
}
|
|
609
1836
|
function areFullScreenAdsSupported(dependencies) {
|
|
610
|
-
return
|
|
611
|
-
&&
|
|
1837
|
+
return isAitNativeMethodSupported(dependencies.loadFullScreenAd)
|
|
1838
|
+
&& isAitNativeMethodSupported(dependencies.showFullScreenAd);
|
|
1839
|
+
}
|
|
1840
|
+
function isAitNativeMethodSupported(method) {
|
|
1841
|
+
return isCapabilitySupported(() => method.isSupported?.() === true);
|
|
612
1842
|
}
|
|
613
1843
|
function isCapabilitySupported(check) {
|
|
614
1844
|
try {
|
|
@@ -655,6 +1885,32 @@ function normalizeAdPlacementTypes(input) {
|
|
|
655
1885
|
.map(([placementId, type]) => [placementId.trim(), type])
|
|
656
1886
|
.filter(([placementId]) => placementId.length > 0));
|
|
657
1887
|
}
|
|
1888
|
+
function normalizePromotionRewards(input) {
|
|
1889
|
+
const rewards = new Map();
|
|
1890
|
+
for (const [rawCampaignId, rawReward] of Object.entries(input ?? {})) {
|
|
1891
|
+
const campaignId = rawCampaignId.trim();
|
|
1892
|
+
const promotionCode = rawReward.promotionCode.trim();
|
|
1893
|
+
if (campaignId.length === 0 || promotionCode.length === 0) {
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
if (!Number.isSafeInteger(rawReward.amount) || rawReward.amount <= 0) {
|
|
1897
|
+
console.warn(`AIT promotion amount must be a positive safe integer; disabling campaign: ${campaignId}`);
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
rewards.set(campaignId, { promotionCode, amount: rawReward.amount });
|
|
1901
|
+
}
|
|
1902
|
+
return rewards;
|
|
1903
|
+
}
|
|
1904
|
+
function normalizeNotificationTemplateCodes(input) {
|
|
1905
|
+
const templateCodes = new Map();
|
|
1906
|
+
for (const topic of notificationTopics) {
|
|
1907
|
+
const templateCode = input?.[topic]?.trim();
|
|
1908
|
+
if (templateCode !== undefined && templateCode.length > 0) {
|
|
1909
|
+
templateCodes.set(topic, templateCode);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
return templateCodes;
|
|
1913
|
+
}
|
|
658
1914
|
function normalizeAppName(value) {
|
|
659
1915
|
const normalized = value.trim();
|
|
660
1916
|
if (!/^[A-Za-z0-9-]+$/u.test(normalized)) {
|
|
@@ -674,6 +1930,13 @@ function normalizeDisplayStartTimeout(value) {
|
|
|
674
1930
|
function normalizeMaximumDisplayTimeout(value) {
|
|
675
1931
|
return normalizePositiveTimeout(value, defaultAdMaximumDisplayMs, 'AIT ad maximum display timeout must be a positive finite number.');
|
|
676
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
|
+
}
|
|
677
1940
|
function normalizePositiveTimeout(value, defaultValue, message) {
|
|
678
1941
|
if (value === undefined) {
|
|
679
1942
|
return defaultValue;
|
|
@@ -701,6 +1964,45 @@ function readIdempotencyKey(payload, fallback) {
|
|
|
701
1964
|
const value = readPayloadRecord(payload).idempotencyKey;
|
|
702
1965
|
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
703
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
|
+
}
|
|
1985
|
+
function readRequiredIdempotencyKey(payload) {
|
|
1986
|
+
const value = readPayloadRecord(payload).idempotencyKey;
|
|
1987
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
|
1988
|
+
throw new TypeError('AIT promotion idempotencyKey must contain 1 to 256 characters.');
|
|
1989
|
+
}
|
|
1990
|
+
return value;
|
|
1991
|
+
}
|
|
1992
|
+
function readCampaignId(payload) {
|
|
1993
|
+
const value = readPayloadRecord(payload).campaignId;
|
|
1994
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 128) {
|
|
1995
|
+
throw new TypeError('AIT promotion campaignId must contain 1 to 128 characters.');
|
|
1996
|
+
}
|
|
1997
|
+
return value;
|
|
1998
|
+
}
|
|
1999
|
+
function readNotificationTopic(payload) {
|
|
2000
|
+
const value = readPayloadRecord(payload).topic;
|
|
2001
|
+
if (typeof value !== 'string' || !notificationTopics.has(value)) {
|
|
2002
|
+
throw new TypeError('AIT notification topic is invalid.');
|
|
2003
|
+
}
|
|
2004
|
+
return value;
|
|
2005
|
+
}
|
|
704
2006
|
function readFiniteScore(payload) {
|
|
705
2007
|
const score = readPayloadRecord(payload).score;
|
|
706
2008
|
if (typeof score !== 'number' || !Number.isFinite(score)) {
|