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