@nosslabs/iap 0.3.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/CHANGELOG.md +114 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/index.cjs +2074 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1134 -0
- package/dist/index.d.ts +1134 -0
- package/dist/index.js +2067 -0
- package/dist/index.js.map +1 -0
- package/package.json +90 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2067 @@
|
|
|
1
|
+
import { Capacitor } from '@capacitor/core';
|
|
2
|
+
import 'cordova-plugin-purchase';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { Preferences } from '@capacitor/preferences';
|
|
5
|
+
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name in all)
|
|
13
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// src/lib/errors.ts
|
|
17
|
+
function errorHint(code) {
|
|
18
|
+
return HINTS[code];
|
|
19
|
+
}
|
|
20
|
+
function isIAPError(error) {
|
|
21
|
+
return error instanceof IAPError;
|
|
22
|
+
}
|
|
23
|
+
function toIAPError(error, fallbackMessage, fallbackCode) {
|
|
24
|
+
if (isIAPError(error)) return error;
|
|
25
|
+
return new IAPError({
|
|
26
|
+
code: fallbackCode,
|
|
27
|
+
message: fallbackMessage,
|
|
28
|
+
cause: error
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
var IAPErrorCode, RECOVERABLE_CODES, HINTS, IAPError;
|
|
32
|
+
var init_errors = __esm({
|
|
33
|
+
"src/lib/errors.ts"() {
|
|
34
|
+
IAPErrorCode = {
|
|
35
|
+
// Configuration
|
|
36
|
+
INVALID_CONFIG: "INVALID_CONFIG",
|
|
37
|
+
NOT_INITIALIZED: "NOT_INITIALIZED",
|
|
38
|
+
// Native plugin
|
|
39
|
+
PLATFORM_NOT_SUPPORTED: "PLATFORM_NOT_SUPPORTED",
|
|
40
|
+
BILLING_NOT_AVAILABLE: "BILLING_NOT_AVAILABLE",
|
|
41
|
+
PRODUCT_NOT_FOUND: "PRODUCT_NOT_FOUND",
|
|
42
|
+
USER_CANCELLED: "USER_CANCELLED",
|
|
43
|
+
PURCHASE_PENDING: "PURCHASE_PENDING",
|
|
44
|
+
ALREADY_PURCHASED: "ALREADY_PURCHASED",
|
|
45
|
+
STORE_ERROR: "STORE_ERROR",
|
|
46
|
+
UNACKNOWLEDGED_PENDING: "UNACKNOWLEDGED_PENDING",
|
|
47
|
+
// Concurrency
|
|
48
|
+
ALREADY_IN_PROGRESS: "ALREADY_IN_PROGRESS",
|
|
49
|
+
// Backend
|
|
50
|
+
BACKEND_UNAVAILABLE: "BACKEND_UNAVAILABLE",
|
|
51
|
+
BACKEND_TIMEOUT: "BACKEND_TIMEOUT",
|
|
52
|
+
BACKEND_AUTH_FAILED: "BACKEND_AUTH_FAILED",
|
|
53
|
+
/** Backend reachable but the response was rejected (non-transient 4xx other
|
|
54
|
+
* than auth, malformed JSON, schema violation, 204 No Content on a JSON
|
|
55
|
+
* endpoint). Fix the request shape or the backend; do not retry. */
|
|
56
|
+
BACKEND_BAD_RESPONSE: "BACKEND_BAD_RESPONSE",
|
|
57
|
+
VERIFICATION_REJECTED: "VERIFICATION_REJECTED",
|
|
58
|
+
// Storage
|
|
59
|
+
STORAGE_ERROR: "STORAGE_ERROR",
|
|
60
|
+
// appUserId pre-attach
|
|
61
|
+
/**
|
|
62
|
+
* The supplied `appUserId` (literal or fetcher-returned) is not a valid
|
|
63
|
+
* UUID v4. Apple requires a UUID for `appAccountToken`; we enforce the
|
|
64
|
+
* same constraint cross-platform for a consistent contract.
|
|
65
|
+
*/
|
|
66
|
+
INVALID_APP_USER_ID: "INVALID_APP_USER_ID",
|
|
67
|
+
/**
|
|
68
|
+
* The async `appUserId` fetcher threw or rejected. Original error is
|
|
69
|
+
* attached as `cause` so the caller can introspect (network failure,
|
|
70
|
+
* backend 5xx, etc.).
|
|
71
|
+
*/
|
|
72
|
+
APP_USER_ID_FETCH_FAILED: "APP_USER_ID_FETCH_FAILED"
|
|
73
|
+
};
|
|
74
|
+
RECOVERABLE_CODES = /* @__PURE__ */ new Set([
|
|
75
|
+
IAPErrorCode.BACKEND_UNAVAILABLE,
|
|
76
|
+
IAPErrorCode.BACKEND_TIMEOUT,
|
|
77
|
+
IAPErrorCode.STORAGE_ERROR,
|
|
78
|
+
IAPErrorCode.UNACKNOWLEDGED_PENDING
|
|
79
|
+
]);
|
|
80
|
+
HINTS = {
|
|
81
|
+
// Configuration
|
|
82
|
+
INVALID_CONFIG: "Check the field paths reported above against the IAPConfig schema (see /api/types).",
|
|
83
|
+
NOT_INITIALIZED: "Call iap.initialize() before this method, or recreate the instance after destroy().",
|
|
84
|
+
// Native plugin
|
|
85
|
+
PLATFORM_NOT_SUPPORTED: "In-app purchases run on iOS/Android only. Web is no-op by design \u2014 guard your purchase UI behind Capacitor.isNativePlatform().",
|
|
86
|
+
BILLING_NOT_AVAILABLE: "cordova-plugin-purchase failed to initialize. Confirm the plugin is installed and `npx cap sync` has run; check device sandbox/test account is signed in.",
|
|
87
|
+
PRODUCT_NOT_FOUND: "Ensure the productId is registered in App Store Connect / Play Console AND in your createIAP({ products }) config.",
|
|
88
|
+
USER_CANCELLED: "No action needed \u2014 the user dismissed the native purchase sheet.",
|
|
89
|
+
PURCHASE_PENDING: "Android only: payment is awaiting external clearance (e.g. cash payment, bank verification). The backend will receive a Google RTDN webhook when it clears; call iap.refresh() afterward.",
|
|
90
|
+
ALREADY_PURCHASED: "The user already owns this non-consumable. Use iap.restorePurchases() to re-grant entitlement, or query iap.hasEntitlement(key) before showing the purchase CTA.",
|
|
91
|
+
STORE_ERROR: "Native store reported an error. Check device connectivity, sandbox account state, and the cause field for the underlying plugin error.",
|
|
92
|
+
UNACKNOWLEDGED_PENDING: "A Google purchase has been unacknowledged for >2 days and is at risk of auto-refund. Verify the backend can be reached and call iap.refresh() to retry acknowledgement.",
|
|
93
|
+
// Concurrency
|
|
94
|
+
ALREADY_IN_PROGRESS: "Await the in-flight iap.purchase(productId) before starting another for the same product.",
|
|
95
|
+
// Backend
|
|
96
|
+
BACKEND_UNAVAILABLE: "Backend is unreachable or returning 5xx. Retry will happen automatically per config.retries; if persistent, check your server.",
|
|
97
|
+
BACKEND_TIMEOUT: "Backend did not respond within timeoutMs. Increase config.backend.timeoutMs or check server response time.",
|
|
98
|
+
BACKEND_AUTH_FAILED: "Backend returned 401/403. Check that getAuthHeaders() returns a valid Bearer token and that the backend recognizes it.",
|
|
99
|
+
BACKEND_BAD_RESPONSE: "Backend response did not match the expected shape. Confirm /api/iap/* endpoints follow the contract documented at /guide/backend-contract.",
|
|
100
|
+
VERIFICATION_REJECTED: "Backend rejected the transaction (valid:false). The transaction stays in unfinished_transactions for retry; the user may have switched accounts or the receipt may be invalid.",
|
|
101
|
+
// Storage
|
|
102
|
+
STORAGE_ERROR: "Capacitor Preferences write failed. Check device storage availability; the in-memory state is still updated, only persistence failed.",
|
|
103
|
+
// appUserId pre-attach
|
|
104
|
+
INVALID_APP_USER_ID: "appUserId must be a UUID v4 (e.g. crypto.randomUUID()). Apple requires this for appAccountToken; we enforce the same on Android for consistency.",
|
|
105
|
+
APP_USER_ID_FETCH_FAILED: "The async appUserId fetcher threw or rejected. Inspect the cause field for the underlying error (network failure, backend non-2xx, parse failure)."
|
|
106
|
+
};
|
|
107
|
+
IAPError = class _IAPError extends Error {
|
|
108
|
+
code;
|
|
109
|
+
recoverable;
|
|
110
|
+
cause;
|
|
111
|
+
constructor(options) {
|
|
112
|
+
const hint = options.includeHint === false ? "" : HINTS[options.code] ?? "";
|
|
113
|
+
const fullMessage = hint ? `${options.message}
|
|
114
|
+
|
|
115
|
+
Hint: ${hint}` : options.message;
|
|
116
|
+
super(fullMessage);
|
|
117
|
+
this.name = "IAPError";
|
|
118
|
+
this.code = options.code;
|
|
119
|
+
this.cause = options.cause;
|
|
120
|
+
this.recoverable = options.recoverable ?? RECOVERABLE_CODES.has(options.code);
|
|
121
|
+
Object.setPrototypeOf(this, _IAPError.prototype);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
function getPlatform() {
|
|
127
|
+
const platform = Capacitor.getPlatform();
|
|
128
|
+
if (platform === "ios" || platform === "android") return platform;
|
|
129
|
+
return "web";
|
|
130
|
+
}
|
|
131
|
+
function isNative() {
|
|
132
|
+
const platform = getPlatform();
|
|
133
|
+
return platform === "ios" || platform === "android";
|
|
134
|
+
}
|
|
135
|
+
var init_platform = __esm({
|
|
136
|
+
"src/lib/platform.ts"() {
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// src/adapters/native/cdv/native-adapter.ts
|
|
141
|
+
var native_adapter_exports = {};
|
|
142
|
+
__export(native_adapter_exports, {
|
|
143
|
+
CdvNativeAdapter: () => CdvNativeAdapter
|
|
144
|
+
});
|
|
145
|
+
function getCdv() {
|
|
146
|
+
const candidate = globalThis.CdvPurchase;
|
|
147
|
+
if (!candidate || !candidate.store) {
|
|
148
|
+
throw new IAPError({
|
|
149
|
+
code: IAPErrorCode.BILLING_NOT_AVAILABLE,
|
|
150
|
+
message: "cordova-plugin-purchase is not available. Ensure the plugin is installed and `npx cap sync` has run."
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return candidate;
|
|
154
|
+
}
|
|
155
|
+
function currentCdvPlatform() {
|
|
156
|
+
const cdv = getCdv();
|
|
157
|
+
const platform = getPlatform();
|
|
158
|
+
if (platform === "android") return cdv.Platform.GOOGLE_PLAY;
|
|
159
|
+
return cdv.Platform.APPLE_APPSTORE;
|
|
160
|
+
}
|
|
161
|
+
function mapProductType(type) {
|
|
162
|
+
const cdv = getCdv();
|
|
163
|
+
switch (type) {
|
|
164
|
+
case "subscription":
|
|
165
|
+
return cdv.ProductType.PAID_SUBSCRIPTION;
|
|
166
|
+
case "consumable":
|
|
167
|
+
return cdv.ProductType.CONSUMABLE;
|
|
168
|
+
default:
|
|
169
|
+
return cdv.ProductType.NON_CONSUMABLE;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function inferProductType(tx, configured) {
|
|
173
|
+
const id = tx.products[0]?.id;
|
|
174
|
+
if (!id) return "product";
|
|
175
|
+
const match = configured.find((p) => p.id === id);
|
|
176
|
+
return match?.type ?? "product";
|
|
177
|
+
}
|
|
178
|
+
function normalizeProduct(p, type) {
|
|
179
|
+
const offer = p.getOffer();
|
|
180
|
+
const phase = offer?.pricingPhases?.[0];
|
|
181
|
+
const priceMicros = phase?.priceMicros?.toString() ?? "0";
|
|
182
|
+
const priceString = phase?.price ?? "";
|
|
183
|
+
const currency = phase?.currency ?? "";
|
|
184
|
+
return {
|
|
185
|
+
id: p.id,
|
|
186
|
+
type,
|
|
187
|
+
title: p.title ?? p.id,
|
|
188
|
+
description: p.description ?? "",
|
|
189
|
+
priceString,
|
|
190
|
+
priceMicros,
|
|
191
|
+
currency
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function normalizeTransaction(tx, productType, token) {
|
|
195
|
+
const platform = tx.platform === "ios-appstore" ? "apple" : "google";
|
|
196
|
+
const productId = tx.products[0]?.id ?? "";
|
|
197
|
+
const result = {
|
|
198
|
+
platform,
|
|
199
|
+
productId,
|
|
200
|
+
token,
|
|
201
|
+
productType,
|
|
202
|
+
raw: tx
|
|
203
|
+
};
|
|
204
|
+
if (platform === "google") {
|
|
205
|
+
const packageName = googlePackageName(tx);
|
|
206
|
+
if (packageName) result.packageName = packageName;
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
function googlePackageName(tx) {
|
|
211
|
+
const googleTx = tx;
|
|
212
|
+
return googleTx.nativePurchase?.packageName;
|
|
213
|
+
}
|
|
214
|
+
function transactionToken(tx) {
|
|
215
|
+
if (tx.platform === "ios-appstore") {
|
|
216
|
+
return tx.transactionId || null;
|
|
217
|
+
}
|
|
218
|
+
const googleTx = tx;
|
|
219
|
+
return googleTx.nativePurchase?.purchaseToken ?? googleTx.parentReceipt?.purchaseToken ?? tx.transactionId ?? null;
|
|
220
|
+
}
|
|
221
|
+
function mapOrderError(err, productId) {
|
|
222
|
+
const cdv = getCdv();
|
|
223
|
+
const cancelled = cdv.ErrorCode?.PAYMENT_CANCELLED;
|
|
224
|
+
if (cancelled !== void 0 && err.code === cancelled) {
|
|
225
|
+
return new IAPError({
|
|
226
|
+
code: IAPErrorCode.USER_CANCELLED,
|
|
227
|
+
message: "User cancelled the native purchase sheet.",
|
|
228
|
+
cause: err
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return new IAPError({
|
|
232
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
233
|
+
message: err.message ?? `order() failed for ${productId}.`,
|
|
234
|
+
cause: err
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
var CdvNativeAdapter;
|
|
238
|
+
var init_native_adapter = __esm({
|
|
239
|
+
"src/adapters/native/cdv/native-adapter.ts"() {
|
|
240
|
+
init_errors();
|
|
241
|
+
init_platform();
|
|
242
|
+
CdvNativeAdapter = class {
|
|
243
|
+
products;
|
|
244
|
+
bootstrapped = false;
|
|
245
|
+
bootstrapping = null;
|
|
246
|
+
pendingFinish = /* @__PURE__ */ new Map();
|
|
247
|
+
/** Long-lived bootstrap-time .approved() listener — kept for dispose(). */
|
|
248
|
+
bootstrapApprovedHandler = null;
|
|
249
|
+
constructor(opts) {
|
|
250
|
+
this.products = opts.products;
|
|
251
|
+
}
|
|
252
|
+
async isAvailable() {
|
|
253
|
+
try {
|
|
254
|
+
await this.bootstrap();
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async getProducts(requests) {
|
|
261
|
+
if (requests.length === 0) return [];
|
|
262
|
+
const store = await this.ensureStore();
|
|
263
|
+
await store.update();
|
|
264
|
+
const out = [];
|
|
265
|
+
for (const req of requests) {
|
|
266
|
+
const native = store.get(req.id);
|
|
267
|
+
if (!native) continue;
|
|
268
|
+
out.push(normalizeProduct(native, req.type));
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
async purchaseProduct(opts) {
|
|
273
|
+
const store = await this.ensureStore();
|
|
274
|
+
const native = store.get(opts.productId);
|
|
275
|
+
if (!native) {
|
|
276
|
+
throw new IAPError({
|
|
277
|
+
code: IAPErrorCode.PRODUCT_NOT_FOUND,
|
|
278
|
+
message: `Product "${opts.productId}" not registered or not available from the store.`
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const offer = opts.androidPlanId ? native.getOffer(opts.androidPlanId) ?? native.getOffer() : native.getOffer();
|
|
282
|
+
if (!offer) {
|
|
283
|
+
throw new IAPError({
|
|
284
|
+
code: IAPErrorCode.PRODUCT_NOT_FOUND,
|
|
285
|
+
message: `Product "${opts.productId}" has no purchasable offer${opts.androidPlanId ? ` (planId="${opts.androidPlanId}")` : ""}.`
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
let settled = false;
|
|
290
|
+
const cleanup = () => {
|
|
291
|
+
store.off(handleApproved);
|
|
292
|
+
};
|
|
293
|
+
const handleApproved = (tx) => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
if (!tx.products.some((p) => p.id === opts.productId)) return;
|
|
296
|
+
const token = transactionToken(tx);
|
|
297
|
+
if (!token) {
|
|
298
|
+
settled = true;
|
|
299
|
+
cleanup();
|
|
300
|
+
reject(
|
|
301
|
+
new IAPError({
|
|
302
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
303
|
+
message: `Approved transaction for "${opts.productId}" has no token; cannot verify.`
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
settled = true;
|
|
309
|
+
cleanup();
|
|
310
|
+
const normalized = normalizeTransaction(tx, opts.productType, token);
|
|
311
|
+
this.pendingFinish.set(token, tx);
|
|
312
|
+
resolve(normalized);
|
|
313
|
+
};
|
|
314
|
+
store.when().approved(handleApproved);
|
|
315
|
+
const additionalData = opts.appAccountToken ? { applicationUsername: opts.appAccountToken } : void 0;
|
|
316
|
+
void Promise.resolve(offer.order(additionalData)).then((err) => {
|
|
317
|
+
if (settled) return;
|
|
318
|
+
if (!err) return;
|
|
319
|
+
settled = true;
|
|
320
|
+
cleanup();
|
|
321
|
+
reject(mapOrderError(err, opts.productId));
|
|
322
|
+
}).catch((cause) => {
|
|
323
|
+
if (settled) return;
|
|
324
|
+
settled = true;
|
|
325
|
+
cleanup();
|
|
326
|
+
reject(
|
|
327
|
+
new IAPError({
|
|
328
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
329
|
+
message: `order() rejected for ${opts.productId}.`,
|
|
330
|
+
cause
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async getOwnedTransactions() {
|
|
337
|
+
const store = await this.ensureStore();
|
|
338
|
+
await store.restorePurchases();
|
|
339
|
+
const out = [];
|
|
340
|
+
for (const tx of store.localTransactions) {
|
|
341
|
+
if (tx.state !== getCdv().TransactionState.APPROVED) continue;
|
|
342
|
+
const token = transactionToken(tx);
|
|
343
|
+
if (!token) continue;
|
|
344
|
+
const normalized = normalizeTransaction(tx, inferProductType(tx, this.products), token);
|
|
345
|
+
this.pendingFinish.set(token, tx);
|
|
346
|
+
out.push(normalized);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
async acknowledge(transaction) {
|
|
351
|
+
const cdvTx = this.pendingFinish.get(transaction.token);
|
|
352
|
+
if (!cdvTx) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
await cdvTx.finish();
|
|
357
|
+
} catch (cause) {
|
|
358
|
+
throw new IAPError({
|
|
359
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
360
|
+
message: `Failed to finish transaction for ${transaction.productId}.`,
|
|
361
|
+
cause,
|
|
362
|
+
recoverable: true
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
this.pendingFinish.delete(transaction.token);
|
|
366
|
+
}
|
|
367
|
+
async manageSubscriptions() {
|
|
368
|
+
const store = await this.ensureStore();
|
|
369
|
+
const err = await store.manageSubscriptions();
|
|
370
|
+
if (err) {
|
|
371
|
+
throw new IAPError({
|
|
372
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
373
|
+
message: err.message ?? "Failed to open subscription management."
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async dispose() {
|
|
378
|
+
if (this.bootstrapApprovedHandler) {
|
|
379
|
+
try {
|
|
380
|
+
const cdv = globalThis.CdvPurchase;
|
|
381
|
+
cdv?.store?.off(this.bootstrapApprovedHandler);
|
|
382
|
+
} catch {
|
|
383
|
+
}
|
|
384
|
+
this.bootstrapApprovedHandler = null;
|
|
385
|
+
}
|
|
386
|
+
this.pendingFinish.clear();
|
|
387
|
+
this.bootstrapped = false;
|
|
388
|
+
this.bootstrapping = null;
|
|
389
|
+
}
|
|
390
|
+
// ----- internals -----
|
|
391
|
+
async ensureStore() {
|
|
392
|
+
await this.bootstrap();
|
|
393
|
+
return getCdv().store;
|
|
394
|
+
}
|
|
395
|
+
bootstrap() {
|
|
396
|
+
if (this.bootstrapped) return Promise.resolve();
|
|
397
|
+
if (this.bootstrapping) return this.bootstrapping;
|
|
398
|
+
this.bootstrapping = (async () => {
|
|
399
|
+
const cdv = getCdv();
|
|
400
|
+
const platform = currentCdvPlatform();
|
|
401
|
+
cdv.store.register(
|
|
402
|
+
this.products.map((p) => ({
|
|
403
|
+
id: p.id,
|
|
404
|
+
type: mapProductType(p.type),
|
|
405
|
+
platform
|
|
406
|
+
}))
|
|
407
|
+
);
|
|
408
|
+
const errors = await cdv.store.initialize([platform]);
|
|
409
|
+
if (errors && errors.length > 0) {
|
|
410
|
+
const first = errors[0];
|
|
411
|
+
throw new IAPError({
|
|
412
|
+
code: IAPErrorCode.BILLING_NOT_AVAILABLE,
|
|
413
|
+
message: first?.message ?? "cordova-plugin-purchase initialize() reported errors."
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const handler = (tx) => {
|
|
417
|
+
const token = transactionToken(tx);
|
|
418
|
+
if (!token) return;
|
|
419
|
+
if (!this.pendingFinish.has(token)) {
|
|
420
|
+
this.pendingFinish.set(token, tx);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
this.bootstrapApprovedHandler = handler;
|
|
424
|
+
cdv.store.when().approved(handler);
|
|
425
|
+
await cdv.store.update();
|
|
426
|
+
this.bootstrapped = true;
|
|
427
|
+
})();
|
|
428
|
+
return this.bootstrapping;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// src/adapters/backend/index.ts
|
|
435
|
+
init_errors();
|
|
436
|
+
|
|
437
|
+
// src/adapters/backend/http-adapter.ts
|
|
438
|
+
init_errors();
|
|
439
|
+
|
|
440
|
+
// src/adapters/backend/http-client.ts
|
|
441
|
+
init_errors();
|
|
442
|
+
|
|
443
|
+
// src/lib/redact.ts
|
|
444
|
+
var VISIBLE_PREFIX_CHARS = 8;
|
|
445
|
+
var ELLIPSIS = "\u2026";
|
|
446
|
+
function maskToken(token) {
|
|
447
|
+
if (!token) return "";
|
|
448
|
+
if (token.length <= VISIBLE_PREFIX_CHARS) return token;
|
|
449
|
+
return token.slice(0, VISIBLE_PREFIX_CHARS) + ELLIPSIS;
|
|
450
|
+
}
|
|
451
|
+
function maskCredential(value) {
|
|
452
|
+
if (!value) return "";
|
|
453
|
+
if (value.length <= VISIBLE_PREFIX_CHARS) return ELLIPSIS;
|
|
454
|
+
return value.slice(0, VISIBLE_PREFIX_CHARS) + ELLIPSIS;
|
|
455
|
+
}
|
|
456
|
+
var SENSITIVE_HEADER_PATTERNS = [
|
|
457
|
+
/^authorization$/i,
|
|
458
|
+
/^cookie$/i,
|
|
459
|
+
/^x-api-key$/i,
|
|
460
|
+
/^x-auth-token$/i
|
|
461
|
+
];
|
|
462
|
+
function redactHeaders(headers) {
|
|
463
|
+
const out = {};
|
|
464
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
465
|
+
out[name] = isSensitiveHeader(name) ? redactHeaderValue(value) : value;
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
function isSensitiveHeader(name) {
|
|
470
|
+
return SENSITIVE_HEADER_PATTERNS.some((pattern) => pattern.test(name));
|
|
471
|
+
}
|
|
472
|
+
function redactHeaderValue(value) {
|
|
473
|
+
const bearerMatch = value.match(/^(Bearer|Basic|Token)\s+(.+)$/i);
|
|
474
|
+
if (bearerMatch) {
|
|
475
|
+
const [, scheme, credential] = bearerMatch;
|
|
476
|
+
return `${scheme} ${maskCredential(credential ?? "")}`;
|
|
477
|
+
}
|
|
478
|
+
return maskCredential(value);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/adapters/backend/http-client.ts
|
|
482
|
+
var RETRY_BACKOFF_MS = [1e3, 2e3, 4e3];
|
|
483
|
+
var HttpClient = class {
|
|
484
|
+
constructor(opts) {
|
|
485
|
+
this.opts = opts;
|
|
486
|
+
const provided = opts.fetch;
|
|
487
|
+
if (provided) {
|
|
488
|
+
this.fetchImpl = provided;
|
|
489
|
+
} else if (typeof globalThis.fetch === "function") {
|
|
490
|
+
this.fetchImpl = globalThis.fetch.bind(globalThis);
|
|
491
|
+
} else {
|
|
492
|
+
throw new IAPError({
|
|
493
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
494
|
+
message: "globalThis.fetch is unavailable; pass a fetch implementation via config."
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
opts;
|
|
499
|
+
fetchImpl;
|
|
500
|
+
/**
|
|
501
|
+
* Execute the request and parse the response with the given zod schema.
|
|
502
|
+
* Returns the validated, transformed result.
|
|
503
|
+
*/
|
|
504
|
+
async request(req, schema) {
|
|
505
|
+
const transformed = this.opts.requestTransform ? await this.opts.requestTransform(req) : req;
|
|
506
|
+
const base = this.opts.baseUrl.replace(/\/+$/, "");
|
|
507
|
+
const path = transformed.path.startsWith("/") ? transformed.path : `/${transformed.path}`;
|
|
508
|
+
const url = `${base}${path}`;
|
|
509
|
+
const auth = await this.opts.getAuthHeaders();
|
|
510
|
+
const headers = {
|
|
511
|
+
"content-type": "application/json",
|
|
512
|
+
accept: "application/json",
|
|
513
|
+
...auth,
|
|
514
|
+
...transformed.headers ?? {}
|
|
515
|
+
};
|
|
516
|
+
let lastError;
|
|
517
|
+
const maxAttempts = this.opts.retries + 1;
|
|
518
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
519
|
+
try {
|
|
520
|
+
return await this.singleAttempt(url, transformed, headers, schema);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (!(error instanceof IAPError) || !error.recoverable) {
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
lastError = error;
|
|
526
|
+
const remaining = maxAttempts - attempt;
|
|
527
|
+
if (remaining <= 0) break;
|
|
528
|
+
const delayMs = RETRY_BACKOFF_MS[attempt - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1] ?? 4e3;
|
|
529
|
+
this.opts.logger.debug(
|
|
530
|
+
`HTTP ${transformed.method} ${transformed.path} retry ${attempt}/${this.opts.retries} after ${delayMs}ms (${error.code})`
|
|
531
|
+
);
|
|
532
|
+
await sleep(delayMs);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
throw lastError ?? new IAPError({
|
|
536
|
+
code: IAPErrorCode.BACKEND_UNAVAILABLE,
|
|
537
|
+
message: "Backend request failed with no recorded error."
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async singleAttempt(url, req, headers, schema) {
|
|
541
|
+
this.opts.logger.debug(`HTTP ${req.method} ${req.path}`, { headers: redactHeaders(headers) });
|
|
542
|
+
let response;
|
|
543
|
+
try {
|
|
544
|
+
response = await this.fetchWithTimeout(url, {
|
|
545
|
+
method: req.method,
|
|
546
|
+
headers,
|
|
547
|
+
body: req.body !== void 0 ? JSON.stringify(req.body) : void 0
|
|
548
|
+
});
|
|
549
|
+
} catch (cause) {
|
|
550
|
+
const isAbort = cause?.name === "AbortError";
|
|
551
|
+
throw new IAPError({
|
|
552
|
+
code: isAbort ? IAPErrorCode.BACKEND_TIMEOUT : IAPErrorCode.BACKEND_UNAVAILABLE,
|
|
553
|
+
message: isAbort ? `Backend request timed out after ${this.opts.timeoutMs}ms.` : "Network error while calling backend.",
|
|
554
|
+
cause,
|
|
555
|
+
recoverable: true
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (response.status === 401 || response.status === 403) {
|
|
559
|
+
throw new IAPError({
|
|
560
|
+
code: IAPErrorCode.BACKEND_AUTH_FAILED,
|
|
561
|
+
message: `Backend auth failed (${response.status}).`
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (!response.ok) {
|
|
565
|
+
const transient = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
566
|
+
throw new IAPError({
|
|
567
|
+
// Transient failures (5xx, 408, 429) → BACKEND_UNAVAILABLE so the
|
|
568
|
+
// retry loop picks them up. Non-transient (other 4xx after auth has
|
|
569
|
+
// been ruled out) → BACKEND_BAD_RESPONSE so the orchestrator can
|
|
570
|
+
// surface "fix the request" rather than "try again later".
|
|
571
|
+
code: transient ? IAPErrorCode.BACKEND_UNAVAILABLE : IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
572
|
+
message: `Backend returned ${response.status} ${response.statusText}.`,
|
|
573
|
+
recoverable: transient
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
577
|
+
throw new IAPError({
|
|
578
|
+
code: IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
579
|
+
message: `Backend returned ${response.status} with empty body; expected JSON for ${req.path}.`
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
let raw;
|
|
583
|
+
try {
|
|
584
|
+
raw = await response.json();
|
|
585
|
+
} catch (cause) {
|
|
586
|
+
throw new IAPError({
|
|
587
|
+
code: IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
588
|
+
message: "Backend response was not valid JSON.",
|
|
589
|
+
cause
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const transformed = this.opts.responseTransform ? await this.opts.responseTransform(raw) : raw;
|
|
593
|
+
const parsed = schema.safeParse(transformed);
|
|
594
|
+
if (!parsed.success) {
|
|
595
|
+
throw new IAPError({
|
|
596
|
+
code: IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
597
|
+
message: `Backend response failed validation: ${parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`,
|
|
598
|
+
cause: parsed.error
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return parsed.data;
|
|
602
|
+
}
|
|
603
|
+
async fetchWithTimeout(url, init) {
|
|
604
|
+
const controller = new AbortController();
|
|
605
|
+
const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
|
|
606
|
+
try {
|
|
607
|
+
return await this.fetchImpl(url, { ...init, signal: controller.signal });
|
|
608
|
+
} finally {
|
|
609
|
+
clearTimeout(timer);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
function sleep(ms) {
|
|
614
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
615
|
+
}
|
|
616
|
+
var productTypeSchema = z.enum(["subscription", "product", "consumable"]);
|
|
617
|
+
var configuredProductSchema = z.object({
|
|
618
|
+
id: z.string().min(1),
|
|
619
|
+
type: productTypeSchema,
|
|
620
|
+
/**
|
|
621
|
+
* Optional. Used only by the Android native adapter to disambiguate which
|
|
622
|
+
* base plan to purchase for multi-plan subscription products (Google Play
|
|
623
|
+
* Billing). iOS ignores it. When omitted on Android, the native adapter
|
|
624
|
+
* falls back to `native.getOffer()` (the default offer) — fine for
|
|
625
|
+
* single-plan subscriptions and for non-subscription products.
|
|
626
|
+
*
|
|
627
|
+
* Recommended to set explicitly when a single subscription product has
|
|
628
|
+
* multiple base plans (e.g. monthly + yearly under one product id).
|
|
629
|
+
*/
|
|
630
|
+
androidPlanId: z.string().min(1).optional()
|
|
631
|
+
});
|
|
632
|
+
var configuredProductsArraySchema = z.array(configuredProductSchema).min(1);
|
|
633
|
+
var backendEndpointsSchema = z.object({
|
|
634
|
+
/**
|
|
635
|
+
* Optional. Set when the consumer supports iOS purchases. iOS-less
|
|
636
|
+
* (e.g. Android-only) configs may omit it; the HTTP adapter will throw
|
|
637
|
+
* `INVALID_CONFIG` at runtime if `verifyApple()` is invoked without this
|
|
638
|
+
* endpoint configured. At least one of `verifyApple` or `verifyGoogle`
|
|
639
|
+
* must be set.
|
|
640
|
+
*/
|
|
641
|
+
verifyApple: z.string().min(1).optional(),
|
|
642
|
+
/**
|
|
643
|
+
* Optional. Set when the consumer supports Android purchases.
|
|
644
|
+
* Android-less (e.g. iOS-only) configs may omit it; the HTTP adapter will
|
|
645
|
+
* throw `INVALID_CONFIG` at runtime if `verifyGoogle()` is invoked
|
|
646
|
+
* without this endpoint configured. At least one of `verifyApple` or
|
|
647
|
+
* `verifyGoogle` must be set.
|
|
648
|
+
*/
|
|
649
|
+
verifyGoogle: z.string().min(1).optional(),
|
|
650
|
+
entitlements: z.string().min(1),
|
|
651
|
+
restore: z.string().min(1),
|
|
652
|
+
/**
|
|
653
|
+
* Optional. When set, the library fetches the SKU manifest from this
|
|
654
|
+
* endpoint during `initialize()` if `products` is omitted from config.
|
|
655
|
+
* See `docs/guide/backend-contract.md` for the response shape.
|
|
656
|
+
*/
|
|
657
|
+
products: z.string().min(1).optional()
|
|
658
|
+
}).superRefine((data, ctx) => {
|
|
659
|
+
if (!data.verifyApple && !data.verifyGoogle) {
|
|
660
|
+
ctx.addIssue({
|
|
661
|
+
code: z.ZodIssueCode.custom,
|
|
662
|
+
message: "At least one of backend.endpoints.verifyApple or backend.endpoints.verifyGoogle must be set.",
|
|
663
|
+
path: []
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
var backendConfigSchema = z.object({
|
|
668
|
+
/**
|
|
669
|
+
* Custom backend transport. If provided, all HTTP-specific fields below
|
|
670
|
+
* are ignored and the library uses this object directly for backend
|
|
671
|
+
* operations. Must implement `BackendAdapter`.
|
|
672
|
+
*/
|
|
673
|
+
adapter: z.unknown().optional(),
|
|
674
|
+
// ----- HTTP-specific fields (used when `adapter` is not provided) -----
|
|
675
|
+
baseUrl: z.string().url().optional(),
|
|
676
|
+
endpoints: backendEndpointsSchema.optional(),
|
|
677
|
+
/**
|
|
678
|
+
* Returns auth headers to merge into every backend request. Called fresh
|
|
679
|
+
* per request so token refresh works automatically. Type is checked at
|
|
680
|
+
* runtime via shape guard, not zod (zod can't validate function contracts).
|
|
681
|
+
*/
|
|
682
|
+
getAuthHeaders: z.unknown().optional(),
|
|
683
|
+
/** Pre-send request transform. See {@link BackendConfig} for the typed shape. */
|
|
684
|
+
requestTransform: z.unknown().optional(),
|
|
685
|
+
/** Post-receive response transform. See {@link BackendConfig} for the typed shape. */
|
|
686
|
+
responseTransform: z.unknown().optional(),
|
|
687
|
+
entitlementSchema: z.unknown().optional(),
|
|
688
|
+
// ----- Common (apply to both HTTP and custom adapters where relevant) -----
|
|
689
|
+
timeoutMs: z.number().int().positive().default(1e4),
|
|
690
|
+
retries: z.number().int().min(0).max(5).default(2)
|
|
691
|
+
}).superRefine((data, ctx) => {
|
|
692
|
+
if (data.adapter !== void 0) return;
|
|
693
|
+
if (!data.baseUrl) {
|
|
694
|
+
ctx.addIssue({
|
|
695
|
+
code: z.ZodIssueCode.custom,
|
|
696
|
+
message: "backend.baseUrl is required when no custom adapter is provided.",
|
|
697
|
+
path: ["baseUrl"]
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
if (!data.endpoints) {
|
|
701
|
+
ctx.addIssue({
|
|
702
|
+
code: z.ZodIssueCode.custom,
|
|
703
|
+
message: "backend.endpoints is required when no custom adapter is provided.",
|
|
704
|
+
path: ["endpoints"]
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (typeof data.getAuthHeaders !== "function") {
|
|
708
|
+
ctx.addIssue({
|
|
709
|
+
code: z.ZodIssueCode.custom,
|
|
710
|
+
message: "backend.getAuthHeaders must be a function (() => Record<string, string> | Promise<Record<string, string>>) when no custom adapter is provided.",
|
|
711
|
+
path: ["getAuthHeaders"]
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
var storageConfigSchema = z.object({
|
|
716
|
+
type: z.enum(["preferences", "memory", "custom"]).default("preferences"),
|
|
717
|
+
namespace: z.string().min(1).default("nosslabs_iap"),
|
|
718
|
+
adapter: z.unknown().optional()
|
|
719
|
+
});
|
|
720
|
+
var optionsConfigSchema = z.object({
|
|
721
|
+
refreshOnResume: z.boolean().default(true),
|
|
722
|
+
entitlementCacheTtlMs: z.number().int().positive().default(60 * 60 * 1e3),
|
|
723
|
+
recoverUnfinishedTransactions: z.boolean().default(true),
|
|
724
|
+
/**
|
|
725
|
+
* Cap on how many unfinished transactions recovery inspects per launch.
|
|
726
|
+
* Defends against pathological growth if the consumer's backend has been
|
|
727
|
+
* down for an extended period and the unfinished list keeps growing.
|
|
728
|
+
* Excess entries stay in storage and are processed on subsequent launches.
|
|
729
|
+
*/
|
|
730
|
+
recoveryMaxBatch: z.number().int().positive().default(50),
|
|
731
|
+
productPriceCacheTtlMs: z.number().int().positive().default(24 * 60 * 60 * 1e3),
|
|
732
|
+
logLevel: z.enum(["silent", "error", "warn", "info", "debug"]).default("info"),
|
|
733
|
+
logger: z.unknown().optional()
|
|
734
|
+
});
|
|
735
|
+
var iapConfigSchema = z.object({
|
|
736
|
+
/**
|
|
737
|
+
* Static SKU manifest. Optional: when omitted, the library calls
|
|
738
|
+
* `backend.adapter.listProducts()` (custom adapter) or GETs
|
|
739
|
+
* `backend.endpoints.products` (HTTP) during `initialize()`. Configs
|
|
740
|
+
* without either path throw `INVALID_CONFIG` at parse time.
|
|
741
|
+
*/
|
|
742
|
+
products: z.array(configuredProductSchema).min(1).optional(),
|
|
743
|
+
backend: backendConfigSchema,
|
|
744
|
+
storage: storageConfigSchema.default({ type: "preferences", namespace: "nosslabs_iap" }),
|
|
745
|
+
options: optionsConfigSchema.default({
|
|
746
|
+
refreshOnResume: true,
|
|
747
|
+
entitlementCacheTtlMs: 60 * 60 * 1e3,
|
|
748
|
+
recoverUnfinishedTransactions: true,
|
|
749
|
+
recoveryMaxBatch: 50,
|
|
750
|
+
productPriceCacheTtlMs: 24 * 60 * 60 * 1e3,
|
|
751
|
+
logLevel: "info"
|
|
752
|
+
})
|
|
753
|
+
}).superRefine((data, ctx) => {
|
|
754
|
+
if (data.products !== void 0) return;
|
|
755
|
+
const adapter = data.backend.adapter;
|
|
756
|
+
const adapterCanList = adapter && typeof adapter.listProducts === "function";
|
|
757
|
+
const httpCanList = !data.backend.adapter && data.backend.endpoints?.products;
|
|
758
|
+
if (!adapterCanList && !httpCanList) {
|
|
759
|
+
ctx.addIssue({
|
|
760
|
+
code: z.ZodIssueCode.custom,
|
|
761
|
+
message: "products is required unless the backend can supply it: set backend.endpoints.products (HTTP) or implement listProducts() on a custom adapter.",
|
|
762
|
+
path: ["products"]
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
var entitlementBaseSchema = z.object({
|
|
767
|
+
key: z.string().min(1),
|
|
768
|
+
productId: z.string().min(1),
|
|
769
|
+
expiresAt: z.string().nullable()
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// src/adapters/backend/types.ts
|
|
773
|
+
var passthroughEntitlementSchema = entitlementBaseSchema.passthrough();
|
|
774
|
+
var verifiedTransactionSchema = z.object({
|
|
775
|
+
id: z.string(),
|
|
776
|
+
productId: z.string(),
|
|
777
|
+
/** ISO 8601 timestamp; null for non-expiring transactions. */
|
|
778
|
+
expiresAt: z.string().nullable().optional()
|
|
779
|
+
}).passthrough();
|
|
780
|
+
var verifySuccessSchema = z.object({
|
|
781
|
+
valid: z.literal(true),
|
|
782
|
+
entitlements: z.array(passthroughEntitlementSchema),
|
|
783
|
+
transaction: verifiedTransactionSchema
|
|
784
|
+
}).passthrough();
|
|
785
|
+
var verifyFailureSchema = z.object({
|
|
786
|
+
valid: z.literal(false),
|
|
787
|
+
/** Stable machine-readable code, e.g. "TRANSACTION_NOT_FOUND". */
|
|
788
|
+
error: z.string(),
|
|
789
|
+
/** Optional human-readable detail. */
|
|
790
|
+
message: z.string().optional()
|
|
791
|
+
}).passthrough();
|
|
792
|
+
var verifyResponseSchema = z.discriminatedUnion("valid", [
|
|
793
|
+
verifySuccessSchema,
|
|
794
|
+
verifyFailureSchema
|
|
795
|
+
]);
|
|
796
|
+
var restoreSuccessSchema = z.object({
|
|
797
|
+
valid: z.literal(true),
|
|
798
|
+
entitlements: z.array(passthroughEntitlementSchema)
|
|
799
|
+
}).passthrough();
|
|
800
|
+
var restoreResponseSchema = z.discriminatedUnion("valid", [
|
|
801
|
+
restoreSuccessSchema,
|
|
802
|
+
verifyFailureSchema
|
|
803
|
+
]);
|
|
804
|
+
var entitlementsResponseSchema = z.object({ entitlements: z.array(passthroughEntitlementSchema) }).passthrough();
|
|
805
|
+
var productManifestResponseSchema = z.object({ products: configuredProductsArraySchema }).passthrough();
|
|
806
|
+
function isBackendAdapter(value) {
|
|
807
|
+
if (!value || typeof value !== "object") return false;
|
|
808
|
+
const candidate = value;
|
|
809
|
+
return typeof candidate.verifyApple === "function" && typeof candidate.verifyGoogle === "function" && typeof candidate.getEntitlements === "function" && typeof candidate.restore === "function";
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/adapters/backend/http-adapter.ts
|
|
813
|
+
var HttpBackendAdapter = class {
|
|
814
|
+
http;
|
|
815
|
+
endpoints;
|
|
816
|
+
constructor(opts) {
|
|
817
|
+
this.endpoints = opts.endpoints;
|
|
818
|
+
const httpClientOpts = {
|
|
819
|
+
baseUrl: opts.baseUrl,
|
|
820
|
+
getAuthHeaders: opts.getAuthHeaders,
|
|
821
|
+
timeoutMs: opts.timeoutMs,
|
|
822
|
+
retries: opts.retries,
|
|
823
|
+
logger: opts.logger,
|
|
824
|
+
...opts.requestTransform ? { requestTransform: opts.requestTransform } : {},
|
|
825
|
+
...opts.responseTransform ? { responseTransform: opts.responseTransform } : {},
|
|
826
|
+
...opts.fetch ? { fetch: opts.fetch } : {}
|
|
827
|
+
};
|
|
828
|
+
this.http = new HttpClient(httpClientOpts);
|
|
829
|
+
}
|
|
830
|
+
async verifyApple(req) {
|
|
831
|
+
if (!this.endpoints.verifyApple) {
|
|
832
|
+
throw new IAPError({
|
|
833
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
834
|
+
message: "HttpBackendAdapter.verifyApple() requires backend.endpoints.verifyApple to be configured. Set it on iOS-supporting builds, or skip Apple purchases on this build."
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
const result = await this.http.request(
|
|
838
|
+
{ method: "POST", path: this.endpoints.verifyApple, body: req },
|
|
839
|
+
verifyResponseSchema
|
|
840
|
+
);
|
|
841
|
+
return result;
|
|
842
|
+
}
|
|
843
|
+
async verifyGoogle(req) {
|
|
844
|
+
if (!this.endpoints.verifyGoogle) {
|
|
845
|
+
throw new IAPError({
|
|
846
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
847
|
+
message: "HttpBackendAdapter.verifyGoogle() requires backend.endpoints.verifyGoogle to be configured. Set it on Android-supporting builds, or skip Google purchases on this build."
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
const result = await this.http.request(
|
|
851
|
+
{ method: "POST", path: this.endpoints.verifyGoogle, body: req },
|
|
852
|
+
verifyResponseSchema
|
|
853
|
+
);
|
|
854
|
+
return result;
|
|
855
|
+
}
|
|
856
|
+
async getEntitlements() {
|
|
857
|
+
const result = await this.http.request(
|
|
858
|
+
{ method: "GET", path: this.endpoints.entitlements },
|
|
859
|
+
entitlementsResponseSchema
|
|
860
|
+
);
|
|
861
|
+
return result.entitlements;
|
|
862
|
+
}
|
|
863
|
+
async restore(req) {
|
|
864
|
+
const result = await this.http.request(
|
|
865
|
+
{ method: "POST", path: this.endpoints.restore, body: req },
|
|
866
|
+
restoreResponseSchema
|
|
867
|
+
);
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
async listProducts() {
|
|
871
|
+
if (!this.endpoints.products) {
|
|
872
|
+
throw new IAPError({
|
|
873
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
874
|
+
message: "HttpBackendAdapter.listProducts() requires backend.endpoints.products to be configured."
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
const result = await this.http.request(
|
|
878
|
+
{ method: "GET", path: this.endpoints.products },
|
|
879
|
+
productManifestResponseSchema
|
|
880
|
+
);
|
|
881
|
+
return result.products;
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// src/adapters/backend/index.ts
|
|
886
|
+
function selectBackendAdapter(options) {
|
|
887
|
+
const { config, logger, fetch: fetchImpl } = options;
|
|
888
|
+
if (config.adapter !== void 0) {
|
|
889
|
+
if (!isBackendAdapter(config.adapter)) {
|
|
890
|
+
throw new IAPError({
|
|
891
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
892
|
+
message: "backend.adapter must implement BackendAdapter (verifyApple, verifyGoogle, getEntitlements, restore)."
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
return config.adapter;
|
|
896
|
+
}
|
|
897
|
+
if (!config.baseUrl || !config.endpoints || !config.getAuthHeaders) {
|
|
898
|
+
throw new IAPError({
|
|
899
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
900
|
+
message: "backend HTTP fields (baseUrl, endpoints, getAuthHeaders) are required when no custom adapter is provided."
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return new HttpBackendAdapter({
|
|
904
|
+
baseUrl: config.baseUrl,
|
|
905
|
+
endpoints: config.endpoints,
|
|
906
|
+
getAuthHeaders: config.getAuthHeaders,
|
|
907
|
+
requestTransform: config.requestTransform,
|
|
908
|
+
responseTransform: config.responseTransform,
|
|
909
|
+
timeoutMs: config.timeoutMs,
|
|
910
|
+
retries: config.retries,
|
|
911
|
+
logger,
|
|
912
|
+
...fetchImpl ? { fetch: fetchImpl } : {}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/adapters/native/index.ts
|
|
917
|
+
init_platform();
|
|
918
|
+
|
|
919
|
+
// src/adapters/native/web/web-stub.ts
|
|
920
|
+
init_errors();
|
|
921
|
+
var WebStubAdapter = class {
|
|
922
|
+
async isAvailable() {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
async getProducts(_requests) {
|
|
926
|
+
return [];
|
|
927
|
+
}
|
|
928
|
+
async purchaseProduct(_opts) {
|
|
929
|
+
throw new IAPError({
|
|
930
|
+
code: IAPErrorCode.PLATFORM_NOT_SUPPORTED,
|
|
931
|
+
message: "In-app purchases are not supported on the web platform."
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
async getOwnedTransactions() {
|
|
935
|
+
return [];
|
|
936
|
+
}
|
|
937
|
+
async acknowledge(_transaction) {
|
|
938
|
+
}
|
|
939
|
+
async manageSubscriptions() {
|
|
940
|
+
throw new IAPError({
|
|
941
|
+
code: IAPErrorCode.PLATFORM_NOT_SUPPORTED,
|
|
942
|
+
message: "Subscription management is not supported on the web platform."
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// src/adapters/native/index.ts
|
|
948
|
+
async function selectNativeAdapter(options) {
|
|
949
|
+
const platform = getPlatform();
|
|
950
|
+
if (platform === "ios" || platform === "android") {
|
|
951
|
+
const mod = await Promise.resolve().then(() => (init_native_adapter(), native_adapter_exports));
|
|
952
|
+
return new mod.CdvNativeAdapter({ products: options.products });
|
|
953
|
+
}
|
|
954
|
+
return new WebStubAdapter();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/adapters/storage/index.ts
|
|
958
|
+
init_errors();
|
|
959
|
+
|
|
960
|
+
// src/adapters/storage/memory-adapter.ts
|
|
961
|
+
var MemoryAdapter = class {
|
|
962
|
+
store = /* @__PURE__ */ new Map();
|
|
963
|
+
prefix;
|
|
964
|
+
constructor(namespace) {
|
|
965
|
+
this.prefix = `${namespace}.`;
|
|
966
|
+
}
|
|
967
|
+
async get(key) {
|
|
968
|
+
return this.store.get(this.prefix + key) ?? null;
|
|
969
|
+
}
|
|
970
|
+
async set(key, value) {
|
|
971
|
+
this.store.set(this.prefix + key, value);
|
|
972
|
+
}
|
|
973
|
+
async remove(key) {
|
|
974
|
+
this.store.delete(this.prefix + key);
|
|
975
|
+
}
|
|
976
|
+
async clear() {
|
|
977
|
+
for (const key of [...this.store.keys()]) {
|
|
978
|
+
if (key.startsWith(this.prefix)) this.store.delete(key);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// src/adapters/storage/preferences-adapter.ts
|
|
984
|
+
init_errors();
|
|
985
|
+
var PreferencesAdapter = class {
|
|
986
|
+
prefix;
|
|
987
|
+
knownKeys = /* @__PURE__ */ new Set();
|
|
988
|
+
constructor(namespace) {
|
|
989
|
+
this.prefix = `${namespace}.`;
|
|
990
|
+
}
|
|
991
|
+
async get(key) {
|
|
992
|
+
try {
|
|
993
|
+
const result = await Preferences.get({ key: this.prefix + key });
|
|
994
|
+
return result.value;
|
|
995
|
+
} catch (cause) {
|
|
996
|
+
throw wrap(cause, `Preferences.get failed for "${key}".`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
async set(key, value) {
|
|
1000
|
+
try {
|
|
1001
|
+
await Preferences.set({ key: this.prefix + key, value });
|
|
1002
|
+
this.knownKeys.add(key);
|
|
1003
|
+
} catch (cause) {
|
|
1004
|
+
throw wrap(cause, `Preferences.set failed for "${key}".`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async remove(key) {
|
|
1008
|
+
try {
|
|
1009
|
+
await Preferences.remove({ key: this.prefix + key });
|
|
1010
|
+
this.knownKeys.delete(key);
|
|
1011
|
+
} catch (cause) {
|
|
1012
|
+
throw wrap(cause, `Preferences.remove failed for "${key}".`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
async clear() {
|
|
1016
|
+
const keys = [...this.knownKeys];
|
|
1017
|
+
this.knownKeys.clear();
|
|
1018
|
+
await Promise.all(keys.map((k) => Preferences.remove({ key: this.prefix + k })));
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
function wrap(cause, message) {
|
|
1022
|
+
return new IAPError({
|
|
1023
|
+
code: IAPErrorCode.STORAGE_ERROR,
|
|
1024
|
+
message,
|
|
1025
|
+
cause,
|
|
1026
|
+
recoverable: true
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/adapters/storage/index.ts
|
|
1031
|
+
function selectStorageAdapter(config) {
|
|
1032
|
+
if (config.type === "memory") {
|
|
1033
|
+
return new MemoryAdapter(config.namespace);
|
|
1034
|
+
}
|
|
1035
|
+
if (config.type === "custom") {
|
|
1036
|
+
if (!isStorageAdapter(config.adapter)) {
|
|
1037
|
+
throw new IAPError({
|
|
1038
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
1039
|
+
message: 'storage.type is "custom" but storage.adapter is missing or does not implement StorageAdapter.'
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
return config.adapter;
|
|
1043
|
+
}
|
|
1044
|
+
return new PreferencesAdapter(config.namespace);
|
|
1045
|
+
}
|
|
1046
|
+
function isStorageAdapter(value) {
|
|
1047
|
+
if (!value || typeof value !== "object") return false;
|
|
1048
|
+
const candidate = value;
|
|
1049
|
+
return typeof candidate.get === "function" && typeof candidate.set === "function" && typeof candidate.remove === "function" && typeof candidate.clear === "function";
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/core/app-resume-listener.ts
|
|
1053
|
+
async function attachAppResumeListener(opts) {
|
|
1054
|
+
let mod;
|
|
1055
|
+
try {
|
|
1056
|
+
mod = await import('@capacitor/app');
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
opts.logger.warn(
|
|
1059
|
+
"refreshOnResume requested but @capacitor/app is not installed; resume listener disabled.",
|
|
1060
|
+
error
|
|
1061
|
+
);
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
let handle;
|
|
1065
|
+
try {
|
|
1066
|
+
handle = await mod.App.addListener("appStateChange", ({ isActive }) => {
|
|
1067
|
+
if (!isActive) return;
|
|
1068
|
+
void Promise.resolve(opts.onResume()).catch((error) => {
|
|
1069
|
+
opts.logger.warn("refreshOnResume handler threw.", error);
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
opts.logger.warn(
|
|
1074
|
+
"Failed to attach App appStateChange listener; resume refresh disabled.",
|
|
1075
|
+
error
|
|
1076
|
+
);
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
async remove() {
|
|
1081
|
+
try {
|
|
1082
|
+
await handle.remove();
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
opts.logger.warn("Failed to remove App appStateChange listener.", error);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/core/entitlement-cache.ts
|
|
1091
|
+
init_errors();
|
|
1092
|
+
var ENTITLEMENTS_KEY = "entitlements";
|
|
1093
|
+
var EntitlementCache = class {
|
|
1094
|
+
constructor(storage, logger) {
|
|
1095
|
+
this.storage = storage;
|
|
1096
|
+
this.logger = logger;
|
|
1097
|
+
}
|
|
1098
|
+
storage;
|
|
1099
|
+
logger;
|
|
1100
|
+
async load() {
|
|
1101
|
+
let raw;
|
|
1102
|
+
try {
|
|
1103
|
+
raw = await this.storage.get(ENTITLEMENTS_KEY);
|
|
1104
|
+
} catch (cause) {
|
|
1105
|
+
this.logger.warn("Storage read failed; treating cache as empty.", cause);
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
if (!raw) return null;
|
|
1109
|
+
let parsed;
|
|
1110
|
+
try {
|
|
1111
|
+
parsed = JSON.parse(raw);
|
|
1112
|
+
} catch (cause) {
|
|
1113
|
+
this.logger.warn("Cached entitlements payload is not valid JSON; clearing.", cause);
|
|
1114
|
+
await this.safeRemove();
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.cachedAt !== "number" || !Array.isArray(parsed.entitlements)) {
|
|
1118
|
+
this.logger.warn("Cached entitlements envelope has unexpected shape; clearing.");
|
|
1119
|
+
await this.safeRemove();
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
const validated = [];
|
|
1123
|
+
for (const item of parsed.entitlements) {
|
|
1124
|
+
const result = entitlementBaseSchema.safeParse(item);
|
|
1125
|
+
if (!result.success) {
|
|
1126
|
+
this.logger.warn("Dropping cached entitlement that fails base validation.", result.error);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
validated.push(item);
|
|
1130
|
+
}
|
|
1131
|
+
return { entitlements: validated, cachedAt: parsed.cachedAt };
|
|
1132
|
+
}
|
|
1133
|
+
/** Returns the `cachedAt` timestamp written so callers can keep their
|
|
1134
|
+
* in-memory copy of the timestamp in sync with disk. */
|
|
1135
|
+
async save(entitlements) {
|
|
1136
|
+
const cachedAt = Date.now();
|
|
1137
|
+
const envelope = {
|
|
1138
|
+
entitlements,
|
|
1139
|
+
cachedAt
|
|
1140
|
+
};
|
|
1141
|
+
try {
|
|
1142
|
+
await this.storage.set(ENTITLEMENTS_KEY, JSON.stringify(envelope));
|
|
1143
|
+
} catch (cause) {
|
|
1144
|
+
throw new IAPError({
|
|
1145
|
+
code: IAPErrorCode.STORAGE_ERROR,
|
|
1146
|
+
message: "Failed to persist entitlement cache.",
|
|
1147
|
+
cause,
|
|
1148
|
+
recoverable: true
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
return cachedAt;
|
|
1152
|
+
}
|
|
1153
|
+
async clear() {
|
|
1154
|
+
await this.safeRemove();
|
|
1155
|
+
}
|
|
1156
|
+
async safeRemove() {
|
|
1157
|
+
try {
|
|
1158
|
+
await this.storage.remove(ENTITLEMENTS_KEY);
|
|
1159
|
+
} catch (cause) {
|
|
1160
|
+
this.logger.warn("Failed to remove corrupt entitlement cache.", cause);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
function entitlementsEqual(a, b) {
|
|
1165
|
+
if (a === b) return true;
|
|
1166
|
+
if (a.length !== b.length) return false;
|
|
1167
|
+
for (let i = 0; i < a.length; i++) {
|
|
1168
|
+
const x = a[i];
|
|
1169
|
+
const y = b[i];
|
|
1170
|
+
if (!x || !y) return false;
|
|
1171
|
+
if (x.key !== y.key || x.productId !== y.productId || x.expiresAt !== y.expiresAt) {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/core/purchase-flow.ts
|
|
1179
|
+
init_errors();
|
|
1180
|
+
|
|
1181
|
+
// src/lib/uuid.ts
|
|
1182
|
+
var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1183
|
+
function isValidUuidV4(value) {
|
|
1184
|
+
return UUID_V4_REGEX.test(value);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// src/core/verify-helpers.ts
|
|
1188
|
+
init_errors();
|
|
1189
|
+
async function verifyNativeTransaction(backend, tx) {
|
|
1190
|
+
if (tx.platform === "apple") {
|
|
1191
|
+
return backend.verifyApple({
|
|
1192
|
+
productId: tx.productId,
|
|
1193
|
+
transactionId: tx.token,
|
|
1194
|
+
type: tx.productType
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
if (!tx.packageName) {
|
|
1198
|
+
throw new IAPError({
|
|
1199
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
1200
|
+
message: `Google transaction for "${tx.productId}" has no packageName; cannot verify.`
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
return backend.verifyGoogle({
|
|
1204
|
+
productId: tx.productId,
|
|
1205
|
+
purchaseToken: tx.token,
|
|
1206
|
+
packageName: tx.packageName,
|
|
1207
|
+
type: tx.productType
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/core/purchase-flow.ts
|
|
1212
|
+
var PurchaseOrchestrator = class {
|
|
1213
|
+
constructor(deps) {
|
|
1214
|
+
this.deps = deps;
|
|
1215
|
+
}
|
|
1216
|
+
deps;
|
|
1217
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
1218
|
+
async purchase(opts) {
|
|
1219
|
+
const { productId, appUserId } = opts;
|
|
1220
|
+
if (this.inFlight.has(productId)) {
|
|
1221
|
+
throw new IAPError({
|
|
1222
|
+
code: IAPErrorCode.ALREADY_IN_PROGRESS,
|
|
1223
|
+
message: `A purchase of "${productId}" is already in progress.`
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
const product = this.deps.products.find((p) => p.id === productId);
|
|
1227
|
+
if (!product) {
|
|
1228
|
+
throw new IAPError({
|
|
1229
|
+
code: IAPErrorCode.PRODUCT_NOT_FOUND,
|
|
1230
|
+
message: `Product "${productId}" is not in the configured catalog.`
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
const resolvedAppUserId = appUserId !== void 0 ? await resolveAppUserId(appUserId) : void 0;
|
|
1234
|
+
this.inFlight.add(productId);
|
|
1235
|
+
this.deps.emitter.emit("purchase-started", { productId });
|
|
1236
|
+
try {
|
|
1237
|
+
return await this.runFlow(product, resolvedAppUserId);
|
|
1238
|
+
} finally {
|
|
1239
|
+
this.inFlight.delete(productId);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
async runFlow(product, appUserId) {
|
|
1243
|
+
const { nativeAdapter, logger } = this.deps;
|
|
1244
|
+
let nativeTx;
|
|
1245
|
+
try {
|
|
1246
|
+
nativeTx = await nativeAdapter.purchaseProduct({
|
|
1247
|
+
productId: product.id,
|
|
1248
|
+
productType: product.type,
|
|
1249
|
+
...product.androidPlanId ? { androidPlanId: product.androidPlanId } : {},
|
|
1250
|
+
...appUserId ? { appAccountToken: appUserId } : {}
|
|
1251
|
+
});
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
return this.handleNativeError(product.id, error);
|
|
1254
|
+
}
|
|
1255
|
+
try {
|
|
1256
|
+
await this.deps.unfinished.add(nativeTx);
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
logger.warn(
|
|
1259
|
+
`Failed to persist unfinished entry for "${product.id}"; verification will still proceed.`,
|
|
1260
|
+
error
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
let verifyResult;
|
|
1264
|
+
try {
|
|
1265
|
+
verifyResult = await verifyNativeTransaction(this.deps.backend, nativeTx);
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
return this.handleVerifyError(product.id, error);
|
|
1268
|
+
}
|
|
1269
|
+
if (!verifyResult.valid) {
|
|
1270
|
+
return this.handleVerificationRejected(product.id, verifyResult);
|
|
1271
|
+
}
|
|
1272
|
+
return this.finalizeSuccess(product.id, nativeTx, verifyResult);
|
|
1273
|
+
}
|
|
1274
|
+
async finalizeSuccess(productId, nativeTx, response) {
|
|
1275
|
+
const { nativeAdapter, cache, unfinished, emitter, logger } = this.deps;
|
|
1276
|
+
const transaction = response.transaction;
|
|
1277
|
+
const entitlements = response.entitlements;
|
|
1278
|
+
try {
|
|
1279
|
+
await nativeAdapter.acknowledge(nativeTx);
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
logger.warn(`acknowledge() failed for "${productId}"; entitlements still updated.`, error);
|
|
1282
|
+
}
|
|
1283
|
+
const previous = this.deps.getCurrentEntitlements();
|
|
1284
|
+
try {
|
|
1285
|
+
const cachedAt = await cache.save(entitlements);
|
|
1286
|
+
this.deps.setCachePersisted(cachedAt);
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
logger.warn(
|
|
1289
|
+
`Failed to persist entitlements after purchase of "${productId}"; in-memory state still updated.`,
|
|
1290
|
+
error
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
this.deps.setEntitlements(entitlements);
|
|
1294
|
+
try {
|
|
1295
|
+
await unfinished.remove(nativeTx.token);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
logger.warn(
|
|
1298
|
+
`Failed to remove "${productId}" from unfinished list; will be skipped on next recovery.`,
|
|
1299
|
+
error
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
emitter.emit("purchase-success", { productId, transaction });
|
|
1303
|
+
emitter.emit("entitlements-changed", {
|
|
1304
|
+
entitlements: this.deps.getCurrentEntitlements(),
|
|
1305
|
+
previous
|
|
1306
|
+
});
|
|
1307
|
+
return {
|
|
1308
|
+
status: "success",
|
|
1309
|
+
productId,
|
|
1310
|
+
transaction,
|
|
1311
|
+
entitlements: this.deps.getCurrentEntitlements()
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
handleVerificationRejected(productId, response) {
|
|
1315
|
+
const detail = response.message ? `${response.message} [${response.error}]` : `Backend rejected the transaction (${response.error}).`;
|
|
1316
|
+
const error = new IAPError({
|
|
1317
|
+
code: IAPErrorCode.VERIFICATION_REJECTED,
|
|
1318
|
+
message: detail
|
|
1319
|
+
});
|
|
1320
|
+
this.deps.emitter.emit("verification-failed", { productId, error });
|
|
1321
|
+
return { status: "verification_failed", productId, error };
|
|
1322
|
+
}
|
|
1323
|
+
handleNativeError(productId, error) {
|
|
1324
|
+
const iapError = toIAPError(
|
|
1325
|
+
error,
|
|
1326
|
+
`Native purchase of "${productId}" failed.`,
|
|
1327
|
+
IAPErrorCode.STORE_ERROR
|
|
1328
|
+
);
|
|
1329
|
+
if (iapError.code === IAPErrorCode.USER_CANCELLED) {
|
|
1330
|
+
this.deps.emitter.emit("purchase-cancelled", { productId });
|
|
1331
|
+
return { status: "cancelled", productId };
|
|
1332
|
+
}
|
|
1333
|
+
if (iapError.code === IAPErrorCode.PURCHASE_PENDING) {
|
|
1334
|
+
this.deps.emitter.emit("purchase-pending", { productId });
|
|
1335
|
+
return { status: "pending", productId };
|
|
1336
|
+
}
|
|
1337
|
+
this.deps.emitter.emit("purchase-failed", { productId, error: iapError });
|
|
1338
|
+
return { status: "failed", productId, error: iapError };
|
|
1339
|
+
}
|
|
1340
|
+
handleVerifyError(productId, error) {
|
|
1341
|
+
const iapError = toIAPError(
|
|
1342
|
+
error,
|
|
1343
|
+
`Backend verification of "${productId}" failed.`,
|
|
1344
|
+
IAPErrorCode.BACKEND_UNAVAILABLE
|
|
1345
|
+
);
|
|
1346
|
+
this.deps.emitter.emit("verification-failed", { productId, error: iapError });
|
|
1347
|
+
return { status: "verification_failed", productId, error: iapError };
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
async function resolveAppUserId(supply) {
|
|
1351
|
+
let resolved;
|
|
1352
|
+
if (typeof supply === "function") {
|
|
1353
|
+
try {
|
|
1354
|
+
resolved = await supply();
|
|
1355
|
+
} catch (cause) {
|
|
1356
|
+
throw new IAPError({
|
|
1357
|
+
code: IAPErrorCode.APP_USER_ID_FETCH_FAILED,
|
|
1358
|
+
message: "The async appUserId fetcher threw or rejected.",
|
|
1359
|
+
cause
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
} else {
|
|
1363
|
+
resolved = supply;
|
|
1364
|
+
}
|
|
1365
|
+
if (typeof resolved !== "string" || !isValidUuidV4(resolved)) {
|
|
1366
|
+
throw new IAPError({
|
|
1367
|
+
code: IAPErrorCode.INVALID_APP_USER_ID,
|
|
1368
|
+
message: `appUserId must be a UUID v4; received ${typeof resolved === "string" ? `"${resolved}"` : typeof resolved}.`
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
return resolved;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/core/recovery-flow.ts
|
|
1375
|
+
var RecoveryOrchestrator = class {
|
|
1376
|
+
constructor(deps) {
|
|
1377
|
+
this.deps = deps;
|
|
1378
|
+
}
|
|
1379
|
+
deps;
|
|
1380
|
+
async recoverUnfinishedTransactions() {
|
|
1381
|
+
const { unfinished, logger, maxBatch } = this.deps;
|
|
1382
|
+
const allEntries = await unfinished.list();
|
|
1383
|
+
if (allEntries.length === 0) {
|
|
1384
|
+
return { recovered: 0, failures: 0, inspected: 0 };
|
|
1385
|
+
}
|
|
1386
|
+
const entries = allEntries.slice(0, maxBatch);
|
|
1387
|
+
if (allEntries.length > maxBatch) {
|
|
1388
|
+
logger.info(
|
|
1389
|
+
`Recovery: inspecting ${entries.length}/${allEntries.length} entries; remaining ${allEntries.length - entries.length} will be processed on subsequent launches.`
|
|
1390
|
+
);
|
|
1391
|
+
} else {
|
|
1392
|
+
logger.debug(`Recovery: inspecting ${entries.length} unfinished transaction(s).`);
|
|
1393
|
+
}
|
|
1394
|
+
const settled = await Promise.allSettled(entries.map((entry) => this.processEntry(entry)));
|
|
1395
|
+
let recovered = 0;
|
|
1396
|
+
let failures = 0;
|
|
1397
|
+
let latestEntitlements = null;
|
|
1398
|
+
for (const result of settled) {
|
|
1399
|
+
if (result.status === "rejected") {
|
|
1400
|
+
failures += 1;
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (result.value.kind === "recovered") {
|
|
1404
|
+
recovered += 1;
|
|
1405
|
+
latestEntitlements = result.value.entitlements;
|
|
1406
|
+
} else {
|
|
1407
|
+
failures += 1;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
if (latestEntitlements !== null) {
|
|
1411
|
+
await this.applyEntitlements(latestEntitlements);
|
|
1412
|
+
}
|
|
1413
|
+
logger.debug(
|
|
1414
|
+
`Recovery: ${recovered} recovered, ${failures} left in list (will retry next launch).`
|
|
1415
|
+
);
|
|
1416
|
+
return { recovered, failures, inspected: entries.length };
|
|
1417
|
+
}
|
|
1418
|
+
async processEntry(entry) {
|
|
1419
|
+
const { nativeAdapter, unfinished, logger } = this.deps;
|
|
1420
|
+
const tx = entryToNativeTransaction(entry);
|
|
1421
|
+
const tokenLabel = maskToken(entry.token);
|
|
1422
|
+
try {
|
|
1423
|
+
const response = await verifyNativeTransaction(this.deps.backend, tx);
|
|
1424
|
+
if (!response.valid) {
|
|
1425
|
+
logger.debug(
|
|
1426
|
+
`Recovery: backend rejected token=${tokenLabel} productId=${entry.productId} (${response.error}); leaving in list.`
|
|
1427
|
+
);
|
|
1428
|
+
return { kind: "failed" };
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
await nativeAdapter.acknowledge(tx);
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
logger.warn(
|
|
1434
|
+
`Recovery: acknowledge() failed for productId=${entry.productId}; entry retained for next launch.`,
|
|
1435
|
+
error
|
|
1436
|
+
);
|
|
1437
|
+
return { kind: "failed" };
|
|
1438
|
+
}
|
|
1439
|
+
try {
|
|
1440
|
+
await unfinished.remove(entry.token);
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
logger.warn(
|
|
1443
|
+
`Recovery: unfinished.remove() failed for productId=${entry.productId}; will dedupe on next launch.`,
|
|
1444
|
+
error
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
return { kind: "recovered", entitlements: response.entitlements };
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
logger.warn(
|
|
1450
|
+
`Recovery: verify failed for productId=${entry.productId}; will retry next launch.`,
|
|
1451
|
+
error
|
|
1452
|
+
);
|
|
1453
|
+
return { kind: "failed" };
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
async applyEntitlements(entitlements) {
|
|
1457
|
+
const { cache, emitter, logger } = this.deps;
|
|
1458
|
+
const previous = this.deps.getCurrentEntitlements();
|
|
1459
|
+
const unchanged = entitlementsEqual(previous, entitlements);
|
|
1460
|
+
try {
|
|
1461
|
+
const cachedAt = await cache.save(entitlements);
|
|
1462
|
+
this.deps.setCachePersisted(cachedAt);
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
logger.warn("Recovery: cache.save failed; in-memory state still updated.", error);
|
|
1465
|
+
}
|
|
1466
|
+
this.deps.setEntitlements(entitlements);
|
|
1467
|
+
if (unchanged) return;
|
|
1468
|
+
emitter.emit("entitlements-changed", {
|
|
1469
|
+
entitlements: this.deps.getCurrentEntitlements(),
|
|
1470
|
+
previous
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
function entryToNativeTransaction(entry) {
|
|
1475
|
+
const tx = {
|
|
1476
|
+
platform: entry.platform,
|
|
1477
|
+
productId: entry.productId,
|
|
1478
|
+
token: entry.token,
|
|
1479
|
+
productType: entry.productType
|
|
1480
|
+
};
|
|
1481
|
+
if (entry.packageName) tx.packageName = entry.packageName;
|
|
1482
|
+
return tx;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// src/core/restore-flow.ts
|
|
1486
|
+
init_errors();
|
|
1487
|
+
var RestoreOrchestrator = class {
|
|
1488
|
+
constructor(deps) {
|
|
1489
|
+
this.deps = deps;
|
|
1490
|
+
}
|
|
1491
|
+
deps;
|
|
1492
|
+
async restorePurchases() {
|
|
1493
|
+
const { nativeAdapter, backend, cache, unfinished, emitter, logger } = this.deps;
|
|
1494
|
+
emitter.emit("restore-started", void 0);
|
|
1495
|
+
let owned;
|
|
1496
|
+
try {
|
|
1497
|
+
owned = await nativeAdapter.getOwnedTransactions();
|
|
1498
|
+
} catch (cause) {
|
|
1499
|
+
throw toIAPError(cause, "Failed to fetch owned transactions.", IAPErrorCode.STORE_ERROR);
|
|
1500
|
+
}
|
|
1501
|
+
if (owned.length === 0) {
|
|
1502
|
+
const entitlements2 = this.deps.getCurrentEntitlements();
|
|
1503
|
+
emitter.emit("restore-completed", { restored: 0, entitlements: entitlements2 });
|
|
1504
|
+
return { restored: 0, entitlements: entitlements2 };
|
|
1505
|
+
}
|
|
1506
|
+
const request = {
|
|
1507
|
+
transactions: owned.map((tx) => this.toRestoreEntry(tx))
|
|
1508
|
+
};
|
|
1509
|
+
let response;
|
|
1510
|
+
try {
|
|
1511
|
+
response = await backend.restore(request);
|
|
1512
|
+
} catch (cause) {
|
|
1513
|
+
throw toIAPError(cause, "Backend restore call failed.", IAPErrorCode.BACKEND_UNAVAILABLE);
|
|
1514
|
+
}
|
|
1515
|
+
if (!response.valid) {
|
|
1516
|
+
const detail = response.message ? `${response.message} [${response.error}]` : `Backend rejected restore (${response.error}).`;
|
|
1517
|
+
throw new IAPError({
|
|
1518
|
+
code: IAPErrorCode.VERIFICATION_REJECTED,
|
|
1519
|
+
message: detail
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
for (const tx of owned) {
|
|
1523
|
+
try {
|
|
1524
|
+
await nativeAdapter.acknowledge(tx);
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
logger.warn(`acknowledge() failed for "${tx.productId}" during restore.`, error);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
const entitlements = response.entitlements;
|
|
1530
|
+
const previous = this.deps.getCurrentEntitlements();
|
|
1531
|
+
try {
|
|
1532
|
+
const cachedAt = await cache.save(entitlements);
|
|
1533
|
+
this.deps.setCachePersisted(cachedAt);
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
logger.warn(
|
|
1536
|
+
"Failed to persist entitlements after restore; in-memory state still updated.",
|
|
1537
|
+
error
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
this.deps.setEntitlements(entitlements);
|
|
1541
|
+
for (const tx of owned) {
|
|
1542
|
+
try {
|
|
1543
|
+
await unfinished.remove(tx.token);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
logger.warn(
|
|
1546
|
+
`Failed to remove "${tx.productId}" from unfinished list during restore.`,
|
|
1547
|
+
error
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const next = this.deps.getCurrentEntitlements();
|
|
1552
|
+
emitter.emit("restore-completed", { restored: owned.length, entitlements: next });
|
|
1553
|
+
if (!entitlementsEqual(previous, next)) {
|
|
1554
|
+
emitter.emit("entitlements-changed", { entitlements: next, previous });
|
|
1555
|
+
}
|
|
1556
|
+
return { restored: owned.length, entitlements: next };
|
|
1557
|
+
}
|
|
1558
|
+
toRestoreEntry(tx) {
|
|
1559
|
+
if (tx.platform === "apple") {
|
|
1560
|
+
return {
|
|
1561
|
+
platform: "apple",
|
|
1562
|
+
productId: tx.productId,
|
|
1563
|
+
transactionId: tx.token
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
if (!tx.packageName) {
|
|
1567
|
+
throw new IAPError({
|
|
1568
|
+
code: IAPErrorCode.STORE_ERROR,
|
|
1569
|
+
message: `Google owned transaction for "${tx.productId}" has no packageName; cannot restore.`
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
return {
|
|
1573
|
+
platform: "google",
|
|
1574
|
+
productId: tx.productId,
|
|
1575
|
+
purchaseToken: tx.token,
|
|
1576
|
+
packageName: tx.packageName
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
// src/core/unfinished-transactions.ts
|
|
1582
|
+
init_errors();
|
|
1583
|
+
var STORE_KEY = "unfinished_transactions";
|
|
1584
|
+
var unfinishedEntrySchema = z.object({
|
|
1585
|
+
platform: z.enum(["apple", "google"]),
|
|
1586
|
+
productId: z.string().min(1),
|
|
1587
|
+
token: z.string().min(1),
|
|
1588
|
+
productType: productTypeSchema,
|
|
1589
|
+
packageName: z.string().optional(),
|
|
1590
|
+
/** ISO 8601 timestamp the entry was first persisted. */
|
|
1591
|
+
recordedAt: z.string()
|
|
1592
|
+
});
|
|
1593
|
+
var envelopeSchema = z.array(unfinishedEntrySchema);
|
|
1594
|
+
var UnfinishedTransactionsStore = class {
|
|
1595
|
+
constructor(storage, logger) {
|
|
1596
|
+
this.storage = storage;
|
|
1597
|
+
this.logger = logger;
|
|
1598
|
+
}
|
|
1599
|
+
storage;
|
|
1600
|
+
logger;
|
|
1601
|
+
/**
|
|
1602
|
+
* Serializes mutating operations (`add` / `remove`) so concurrent callers
|
|
1603
|
+
* don't race the read-modify-write on the storage key. Phase 6's
|
|
1604
|
+
* parallel recovery exposed this — multiple `remove()` calls in flight
|
|
1605
|
+
* could each `list()` the same snapshot and overwrite each other's
|
|
1606
|
+
* `persist()`.
|
|
1607
|
+
*/
|
|
1608
|
+
mutationLock = Promise.resolve();
|
|
1609
|
+
/** Returns the current list, or `[]` if empty / corrupt. */
|
|
1610
|
+
async list() {
|
|
1611
|
+
let raw;
|
|
1612
|
+
try {
|
|
1613
|
+
raw = await this.storage.get(STORE_KEY);
|
|
1614
|
+
} catch (cause) {
|
|
1615
|
+
this.logger.warn("Storage read failed; treating unfinished list as empty.", cause);
|
|
1616
|
+
return [];
|
|
1617
|
+
}
|
|
1618
|
+
if (!raw) return [];
|
|
1619
|
+
let parsed;
|
|
1620
|
+
try {
|
|
1621
|
+
parsed = JSON.parse(raw);
|
|
1622
|
+
} catch (cause) {
|
|
1623
|
+
this.logger.warn("unfinished_transactions payload is not valid JSON; clearing.", cause);
|
|
1624
|
+
await this.safeClear();
|
|
1625
|
+
return [];
|
|
1626
|
+
}
|
|
1627
|
+
const result = envelopeSchema.safeParse(parsed);
|
|
1628
|
+
if (!result.success) {
|
|
1629
|
+
this.logger.warn("unfinished_transactions has unexpected shape; clearing.", result.error);
|
|
1630
|
+
await this.safeClear();
|
|
1631
|
+
return [];
|
|
1632
|
+
}
|
|
1633
|
+
return result.data;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Append a transaction to the list. Idempotent: if a same-token entry
|
|
1637
|
+
* already exists, this is a no-op (avoids dupes when restore + active
|
|
1638
|
+
* purchase race for the same StoreKit replay).
|
|
1639
|
+
*/
|
|
1640
|
+
async add(tx) {
|
|
1641
|
+
return this.runExclusive(async () => {
|
|
1642
|
+
const current = await this.list();
|
|
1643
|
+
if (current.some((e) => e.token === tx.token)) return;
|
|
1644
|
+
const entry = {
|
|
1645
|
+
platform: tx.platform,
|
|
1646
|
+
productId: tx.productId,
|
|
1647
|
+
token: tx.token,
|
|
1648
|
+
productType: tx.productType,
|
|
1649
|
+
...tx.packageName ? { packageName: tx.packageName } : {},
|
|
1650
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1651
|
+
};
|
|
1652
|
+
await this.persist([...current, entry]);
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
/** Remove the entry with the given token. No-op if not present. */
|
|
1656
|
+
async remove(token) {
|
|
1657
|
+
return this.runExclusive(async () => {
|
|
1658
|
+
const current = await this.list();
|
|
1659
|
+
const next = current.filter((e) => e.token !== token);
|
|
1660
|
+
if (next.length === current.length) return;
|
|
1661
|
+
await this.persist(next);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
/** Clear every unfinished entry. */
|
|
1665
|
+
async clear() {
|
|
1666
|
+
return this.runExclusive(() => this.safeClear());
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Run `fn` with exclusive access to the storage key. Implements a simple
|
|
1670
|
+
* promise chain — every mutation awaits the previous one's completion.
|
|
1671
|
+
* Reads (`list()`) are NOT serialized because they're tolerant of stale
|
|
1672
|
+
* snapshots (callers either compose or accept the read-once semantic).
|
|
1673
|
+
*/
|
|
1674
|
+
async runExclusive(fn) {
|
|
1675
|
+
const prev = this.mutationLock;
|
|
1676
|
+
let release;
|
|
1677
|
+
this.mutationLock = new Promise((resolve) => {
|
|
1678
|
+
release = resolve;
|
|
1679
|
+
});
|
|
1680
|
+
try {
|
|
1681
|
+
await prev;
|
|
1682
|
+
return await fn();
|
|
1683
|
+
} finally {
|
|
1684
|
+
release();
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
async persist(entries) {
|
|
1688
|
+
try {
|
|
1689
|
+
await this.storage.set(STORE_KEY, JSON.stringify(entries));
|
|
1690
|
+
} catch (cause) {
|
|
1691
|
+
throw new IAPError({
|
|
1692
|
+
code: IAPErrorCode.STORAGE_ERROR,
|
|
1693
|
+
message: "Failed to persist unfinished transactions list.",
|
|
1694
|
+
cause,
|
|
1695
|
+
recoverable: true
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
async safeClear() {
|
|
1700
|
+
try {
|
|
1701
|
+
await this.storage.remove(STORE_KEY);
|
|
1702
|
+
} catch (cause) {
|
|
1703
|
+
this.logger.warn("Failed to clear unfinished_transactions key.", cause);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
// src/events/emitter.ts
|
|
1709
|
+
var TypedEventEmitter = class {
|
|
1710
|
+
handlers = /* @__PURE__ */ new Map();
|
|
1711
|
+
on(event, handler) {
|
|
1712
|
+
const key = event;
|
|
1713
|
+
let set = this.handlers.get(key);
|
|
1714
|
+
if (!set) {
|
|
1715
|
+
set = /* @__PURE__ */ new Set();
|
|
1716
|
+
this.handlers.set(key, set);
|
|
1717
|
+
}
|
|
1718
|
+
set.add(handler);
|
|
1719
|
+
return () => {
|
|
1720
|
+
const current = this.handlers.get(key);
|
|
1721
|
+
if (current) current.delete(handler);
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
emit(event, payload) {
|
|
1725
|
+
const set = this.handlers.get(event);
|
|
1726
|
+
if (!set) return;
|
|
1727
|
+
for (const handler of [...set]) {
|
|
1728
|
+
try {
|
|
1729
|
+
handler(payload);
|
|
1730
|
+
} catch {
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
removeAll() {
|
|
1735
|
+
this.handlers.clear();
|
|
1736
|
+
}
|
|
1737
|
+
/** Number of listeners for a given event — used by tests. */
|
|
1738
|
+
listenerCount(event) {
|
|
1739
|
+
return this.handlers.get(event)?.size ?? 0;
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
// src/createIAP.ts
|
|
1744
|
+
init_errors();
|
|
1745
|
+
|
|
1746
|
+
// src/lib/logger.ts
|
|
1747
|
+
var LEVEL_PRIORITY = {
|
|
1748
|
+
silent: 0,
|
|
1749
|
+
error: 1,
|
|
1750
|
+
warn: 2,
|
|
1751
|
+
info: 3,
|
|
1752
|
+
debug: 4
|
|
1753
|
+
};
|
|
1754
|
+
var PREFIX = "[@nosslabs/iap]";
|
|
1755
|
+
function createDefaultLogger(level) {
|
|
1756
|
+
const minPriority = LEVEL_PRIORITY[level];
|
|
1757
|
+
const enabled = (l) => LEVEL_PRIORITY[l] <= minPriority;
|
|
1758
|
+
return {
|
|
1759
|
+
error(message, ...args) {
|
|
1760
|
+
if (enabled("error")) console.error(PREFIX, message, ...args);
|
|
1761
|
+
},
|
|
1762
|
+
warn(message, ...args) {
|
|
1763
|
+
if (enabled("warn")) console.warn(PREFIX, message, ...args);
|
|
1764
|
+
},
|
|
1765
|
+
info(message, ...args) {
|
|
1766
|
+
if (enabled("info")) console.info(PREFIX, message, ...args);
|
|
1767
|
+
},
|
|
1768
|
+
debug(message, ...args) {
|
|
1769
|
+
if (enabled("debug")) console.debug(PREFIX, message, ...args);
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
function isLogger(value) {
|
|
1774
|
+
if (!value || typeof value !== "object") return false;
|
|
1775
|
+
const candidate = value;
|
|
1776
|
+
return typeof candidate.error === "function" && typeof candidate.warn === "function" && typeof candidate.info === "function" && typeof candidate.debug === "function";
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// src/createIAP.ts
|
|
1780
|
+
init_platform();
|
|
1781
|
+
function createIAP(input) {
|
|
1782
|
+
const config = parseConfig(input);
|
|
1783
|
+
const logger = resolveLogger(config.options.logLevel, config.options.logger);
|
|
1784
|
+
const storage = selectStorageAdapter(config.storage);
|
|
1785
|
+
const cache = new EntitlementCache(storage, logger);
|
|
1786
|
+
const unfinished = new UnfinishedTransactionsStore(storage, logger);
|
|
1787
|
+
const backend = selectBackendAdapter({ config: config.backend, logger });
|
|
1788
|
+
const emitter = new TypedEventEmitter();
|
|
1789
|
+
if (config.products) {
|
|
1790
|
+
ensureUniqueProductIds(config.products);
|
|
1791
|
+
}
|
|
1792
|
+
const state = {
|
|
1793
|
+
config,
|
|
1794
|
+
adapter: null,
|
|
1795
|
+
backend,
|
|
1796
|
+
storage,
|
|
1797
|
+
cache,
|
|
1798
|
+
unfinished,
|
|
1799
|
+
orchestrator: null,
|
|
1800
|
+
restorer: null,
|
|
1801
|
+
recoverer: null,
|
|
1802
|
+
resumeListener: null,
|
|
1803
|
+
emitter,
|
|
1804
|
+
logger,
|
|
1805
|
+
initialized: false,
|
|
1806
|
+
destroyed: false,
|
|
1807
|
+
entitlements: [],
|
|
1808
|
+
cachedAt: null,
|
|
1809
|
+
products: Object.freeze([...config.products ?? []])
|
|
1810
|
+
};
|
|
1811
|
+
return {
|
|
1812
|
+
async initialize() {
|
|
1813
|
+
if (state.destroyed) {
|
|
1814
|
+
throw new IAPError({
|
|
1815
|
+
code: IAPErrorCode.NOT_INITIALIZED,
|
|
1816
|
+
message: "IAP instance has been destroyed; create a new one with createIAP()."
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
if (state.initialized) {
|
|
1820
|
+
state.logger.debug("initialize() called more than once; ignoring.");
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const platform = getPlatform();
|
|
1824
|
+
if (!isNative()) {
|
|
1825
|
+
state.logger.info(
|
|
1826
|
+
"Native purchases unavailable on web; entitlement queries still functional."
|
|
1827
|
+
);
|
|
1828
|
+
} else {
|
|
1829
|
+
state.logger.debug(`Initializing on platform=${platform}`);
|
|
1830
|
+
}
|
|
1831
|
+
if (!state.config.products) {
|
|
1832
|
+
if (typeof state.backend.listProducts !== "function") {
|
|
1833
|
+
throw new IAPError({
|
|
1834
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
1835
|
+
message: "config.products is omitted but backend adapter does not implement listProducts(). This is a library bug; the schema should have caught it."
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
const fetched = await state.backend.listProducts();
|
|
1839
|
+
const validated = configuredProductsArraySchema.safeParse(fetched);
|
|
1840
|
+
if (!validated.success) {
|
|
1841
|
+
throw new IAPError({
|
|
1842
|
+
code: IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
1843
|
+
message: `backend.listProducts() returned an invalid manifest: ${validated.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`,
|
|
1844
|
+
cause: validated.error
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
ensureUniqueProductIds(validated.data);
|
|
1848
|
+
state.products = Object.freeze([...validated.data]);
|
|
1849
|
+
state.logger.debug(`Resolved ${validated.data.length} product(s) from backend manifest.`);
|
|
1850
|
+
}
|
|
1851
|
+
state.adapter = await selectNativeAdapter({ products: state.products });
|
|
1852
|
+
const sharedDeps = {
|
|
1853
|
+
nativeAdapter: state.adapter,
|
|
1854
|
+
backend: state.backend,
|
|
1855
|
+
cache: state.cache,
|
|
1856
|
+
unfinished: state.unfinished,
|
|
1857
|
+
emitter: state.emitter,
|
|
1858
|
+
logger: state.logger,
|
|
1859
|
+
getCurrentEntitlements: () => state.entitlements,
|
|
1860
|
+
setEntitlements: (next) => {
|
|
1861
|
+
state.entitlements = freezeAll(next);
|
|
1862
|
+
},
|
|
1863
|
+
setCachePersisted: (cachedAt) => {
|
|
1864
|
+
state.cachedAt = cachedAt;
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
state.orchestrator = new PurchaseOrchestrator({
|
|
1868
|
+
...sharedDeps,
|
|
1869
|
+
products: state.products
|
|
1870
|
+
});
|
|
1871
|
+
state.restorer = new RestoreOrchestrator(sharedDeps);
|
|
1872
|
+
state.recoverer = new RecoveryOrchestrator({
|
|
1873
|
+
...sharedDeps,
|
|
1874
|
+
maxBatch: state.config.options.recoveryMaxBatch
|
|
1875
|
+
});
|
|
1876
|
+
try {
|
|
1877
|
+
await state.adapter.isAvailable();
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
state.logger.warn("Native adapter availability check threw; continuing.", error);
|
|
1880
|
+
}
|
|
1881
|
+
const cached = await state.cache.load();
|
|
1882
|
+
if (cached) {
|
|
1883
|
+
state.entitlements = freezeAll(cached.entitlements);
|
|
1884
|
+
state.cachedAt = cached.cachedAt;
|
|
1885
|
+
state.logger.debug(
|
|
1886
|
+
`Loaded ${cached.entitlements.length} cached entitlement(s) from ${new Date(cached.cachedAt).toISOString()}.`
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
if (state.config.options.recoverUnfinishedTransactions && isNative()) {
|
|
1890
|
+
try {
|
|
1891
|
+
const result = await state.recoverer.recoverUnfinishedTransactions();
|
|
1892
|
+
if (result.inspected > 0) {
|
|
1893
|
+
state.logger.info(
|
|
1894
|
+
`Recovery inspected ${result.inspected} unfinished transaction(s): ${result.recovered} recovered, ${result.failures} retained.`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
state.logger.warn("Recovery threw unexpectedly; continuing initialize.", error);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (state.config.options.refreshOnResume && isNative()) {
|
|
1902
|
+
state.resumeListener = await attachAppResumeListener({
|
|
1903
|
+
logger: state.logger,
|
|
1904
|
+
onResume: async () => {
|
|
1905
|
+
try {
|
|
1906
|
+
await this.refresh();
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
state.logger.warn("refreshOnResume: refresh() failed.", error);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
if (state.cachedAt !== null && Date.now() - state.cachedAt > state.config.options.entitlementCacheTtlMs) {
|
|
1914
|
+
state.logger.debug("Cache exceeds TTL; scheduling background refresh.");
|
|
1915
|
+
queueMicrotask(() => {
|
|
1916
|
+
if (!state.initialized || state.destroyed) return;
|
|
1917
|
+
this.refresh().catch((error) => {
|
|
1918
|
+
state.logger.warn("TTL background refresh failed.", error);
|
|
1919
|
+
});
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
state.initialized = true;
|
|
1923
|
+
state.emitter.emit("ready", void 0);
|
|
1924
|
+
},
|
|
1925
|
+
async refresh() {
|
|
1926
|
+
requireInitialized(state);
|
|
1927
|
+
const previous = state.entitlements;
|
|
1928
|
+
const fetched = await state.backend.getEntitlements();
|
|
1929
|
+
const next = freezeAll(fetched);
|
|
1930
|
+
try {
|
|
1931
|
+
state.cachedAt = await state.cache.save(next);
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
state.logger.warn(
|
|
1934
|
+
"Failed to persist refreshed entitlements; in-memory state still updated.",
|
|
1935
|
+
error
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
state.entitlements = next;
|
|
1939
|
+
if (!entitlementsEqual(previous, next)) {
|
|
1940
|
+
state.emitter.emit("entitlements-changed", { entitlements: next, previous });
|
|
1941
|
+
}
|
|
1942
|
+
},
|
|
1943
|
+
async destroy() {
|
|
1944
|
+
if (state.destroyed) return;
|
|
1945
|
+
state.destroyed = true;
|
|
1946
|
+
state.initialized = false;
|
|
1947
|
+
state.entitlements = [];
|
|
1948
|
+
state.cachedAt = null;
|
|
1949
|
+
state.emitter.removeAll();
|
|
1950
|
+
if (state.resumeListener) {
|
|
1951
|
+
try {
|
|
1952
|
+
await state.resumeListener.remove();
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
state.logger.warn("Resume listener remove threw; continuing teardown.", error);
|
|
1955
|
+
}
|
|
1956
|
+
state.resumeListener = null;
|
|
1957
|
+
}
|
|
1958
|
+
if (state.adapter?.dispose) {
|
|
1959
|
+
try {
|
|
1960
|
+
await state.adapter.dispose();
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
state.logger.warn("Adapter dispose threw; continuing teardown.", error);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
state.adapter = null;
|
|
1966
|
+
state.orchestrator = null;
|
|
1967
|
+
state.restorer = null;
|
|
1968
|
+
state.recoverer = null;
|
|
1969
|
+
},
|
|
1970
|
+
async purchase(opts) {
|
|
1971
|
+
requireInitialized(state);
|
|
1972
|
+
if (!state.orchestrator) {
|
|
1973
|
+
throw new IAPError({
|
|
1974
|
+
code: IAPErrorCode.NOT_INITIALIZED,
|
|
1975
|
+
message: "Purchase orchestrator not constructed; this is a library bug."
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
return state.orchestrator.purchase(opts);
|
|
1979
|
+
},
|
|
1980
|
+
async restorePurchases() {
|
|
1981
|
+
requireInitialized(state);
|
|
1982
|
+
if (!state.restorer) {
|
|
1983
|
+
throw new IAPError({
|
|
1984
|
+
code: IAPErrorCode.NOT_INITIALIZED,
|
|
1985
|
+
message: "Restore orchestrator not constructed; this is a library bug."
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
return state.restorer.restorePurchases();
|
|
1989
|
+
},
|
|
1990
|
+
async getProducts() {
|
|
1991
|
+
requireInitialized(state);
|
|
1992
|
+
if (!state.adapter) {
|
|
1993
|
+
throw new IAPError({
|
|
1994
|
+
code: IAPErrorCode.NOT_INITIALIZED,
|
|
1995
|
+
message: "Native adapter not constructed; this is a library bug."
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
return state.adapter.getProducts(state.products.map((p) => ({ id: p.id, type: p.type })));
|
|
1999
|
+
},
|
|
2000
|
+
hasEntitlement(key) {
|
|
2001
|
+
return state.entitlements.some((e) => e.key === key);
|
|
2002
|
+
},
|
|
2003
|
+
getEntitlements() {
|
|
2004
|
+
return [...state.entitlements];
|
|
2005
|
+
},
|
|
2006
|
+
getEntitlement(key) {
|
|
2007
|
+
return state.entitlements.find((e) => e.key === key) ?? null;
|
|
2008
|
+
},
|
|
2009
|
+
on(event, handler) {
|
|
2010
|
+
return state.emitter.on(event, handler);
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
function freezeAll(items) {
|
|
2015
|
+
return items.map((item) => Object.freeze({ ...item }));
|
|
2016
|
+
}
|
|
2017
|
+
function parseConfig(input) {
|
|
2018
|
+
try {
|
|
2019
|
+
const parsed = iapConfigSchema.parse(input);
|
|
2020
|
+
return parsed;
|
|
2021
|
+
} catch (error) {
|
|
2022
|
+
if (error instanceof z.ZodError) {
|
|
2023
|
+
const issues = error.issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
|
|
2024
|
+
throw new IAPError({
|
|
2025
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
2026
|
+
message: `Invalid IAP configuration:
|
|
2027
|
+
${issues}`,
|
|
2028
|
+
cause: error
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
throw error;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
function ensureUniqueProductIds(products) {
|
|
2035
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2036
|
+
for (const product of products) {
|
|
2037
|
+
if (seen.has(product.id)) {
|
|
2038
|
+
throw new IAPError({
|
|
2039
|
+
code: IAPErrorCode.INVALID_CONFIG,
|
|
2040
|
+
message: `Duplicate product id "${product.id}" in product manifest.`
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
seen.add(product.id);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
function resolveLogger(level, candidate) {
|
|
2047
|
+
if (isLogger(candidate)) return candidate;
|
|
2048
|
+
return createDefaultLogger(level);
|
|
2049
|
+
}
|
|
2050
|
+
function requireInitialized(state) {
|
|
2051
|
+
if (!state.initialized) {
|
|
2052
|
+
throw new IAPError({
|
|
2053
|
+
code: IAPErrorCode.NOT_INITIALIZED,
|
|
2054
|
+
message: "Call iap.initialize() before this method."
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/index.ts
|
|
2060
|
+
init_errors();
|
|
2061
|
+
|
|
2062
|
+
// src/version.ts
|
|
2063
|
+
var VERSION = "0.1.0";
|
|
2064
|
+
|
|
2065
|
+
export { HttpBackendAdapter, HttpClient, IAPError, IAPErrorCode, VERSION, createIAP, errorHint, isIAPError };
|
|
2066
|
+
//# sourceMappingURL=index.js.map
|
|
2067
|
+
//# sourceMappingURL=index.js.map
|