@nosslabs/iap 0.3.1 → 5.0.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 CHANGED
@@ -5,6 +5,92 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [5.0.0] — 2026-05-14
9
+
10
+ ### Changed
11
+
12
+ - **Renumbered the Capacitor 5 line from `0.x` to `5.x`** so the
13
+ library's major version tracks the Capacitor major it targets
14
+ (matching the convention `@capgo/native-purchases`, Ionic plugins,
15
+ and most platform-aligned ecosystems use). **No code changes vs
16
+ `0.4.0`** — the published tarball is functionally identical.
17
+ Consumers: bump your dependency `^0.4.0` → `^5.0.0`. Nothing else
18
+ needs to change.
19
+
20
+ The Capacitor 7+ line is published on the `@next` dist-tag as `7.x`
21
+ (currently `7.0.0-next.0` prerelease). See [Migration](https://iap.nossdev.com/migration/)
22
+ when you're ready to move to Capacitor 7. The `5.x` line on this
23
+ branch (`5.x`) continues to receive Capacitor-5 patches.
24
+
25
+ ## [0.4.0] — 2026-05-08
26
+
27
+ ### Added
28
+
29
+ - **`options.permanentErrorCodes` config** — list of backend
30
+ `valid:false` error codes that recovery should treat as permanent.
31
+ Entries with a matching error are removed from
32
+ `unfinished_transactions` storage instead of being retried on every
33
+ app launch. Defaults to `['TRANSACTION_NOT_FOUND', 'PRODUCT_MISMATCH']`
34
+ per the documented recipe contract — the two codes that mean "the
35
+ backend looked and the answer is permanently no, this transaction is
36
+ not valid." When provided, the option REPLACES the default (no magic
37
+ merge); pass `[...DEFAULT_PERMANENT_ERROR_CODES, 'YOUR_CODE']` to
38
+ extend, or `[]` to disable the feature entirely (revert to
39
+ retry-forever behavior).
40
+
41
+ ```ts
42
+ import { createIAP, DEFAULT_PERMANENT_ERROR_CODES } from '@nosslabs/iap';
43
+
44
+ // Default: TRANSACTION_NOT_FOUND and PRODUCT_MISMATCH are dropped.
45
+ createIAP({ /* ... */ });
46
+
47
+ // Extend with your backend's custom permanent codes.
48
+ createIAP({
49
+ options: {
50
+ permanentErrorCodes: [...DEFAULT_PERMANENT_ERROR_CODES, 'MY_CUSTOM_CODE'],
51
+ },
52
+ });
53
+
54
+ // Opt out entirely — every valid:false retains the entry for retry.
55
+ createIAP({
56
+ options: { permanentErrorCodes: [] },
57
+ });
58
+ ```
59
+
60
+ - **`'recovery-dropped-permanent'` event** — fires once per entry
61
+ removed by the new classifier. Payload:
62
+ `{ productId, token, error, message? }`. Useful for ops
63
+ observability when a stuck-loop self-heals.
64
+
65
+ - **`DEFAULT_PERMANENT_ERROR_CODES` exported** from the package root
66
+ for the spread-then-extend pattern above.
67
+
68
+ - **`RecoveryResult.droppedPermanent`** — new field on the recovery
69
+ result alongside `recovered` and `failures`. Counts how many
70
+ entries were removed during the current sweep.
71
+
72
+ ### Changed
73
+
74
+ - **Recovery no longer retries `valid:false` responses with
75
+ permanently-invalid error codes** (default:
76
+ `TRANSACTION_NOT_FOUND`, `PRODUCT_MISMATCH`). Previous behavior is
77
+ preserved for any code not in the permanent set, and is
78
+ configurable via `options.permanentErrorCodes`. Strict improvement
79
+ for the cases it affects (stuck loops self-heal); other paths
80
+ unchanged. **Bumped to a minor version** because the behavior is
81
+ observable — consumers asserting on `RecoveryResult` shape or
82
+ intentionally depending on retry-forever semantics for a specific
83
+ code should opt out via `permanentErrorCodes: []`.
84
+
85
+ > **Backend assumption.** The default set assumes your backend
86
+ > queries Apple App Store Server API / Google Play Developer API
87
+ > with eventually-consistent reads (typical for Attesto's recipe
88
+ > pattern). If your backend reads from a replicated database with
89
+ > replication lag exceeding app-launch cadence, a `TRANSACTION_NOT_FOUND`
90
+ > response could be transient — in that case configure
91
+ > `permanentErrorCodes: []` (or a custom set) until you've reconciled
92
+ > the lag.
93
+
8
94
  ## [0.3.1] — 2026-05-06
9
95
 
10
96
  ### Added
@@ -24,13 +110,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
24
110
  ```ts
25
111
  // before — still valid
26
112
  appUserId: async () => {
27
- const r = await fetch('/api/iap/uuid', { headers: authHeaders() });
113
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders() });
28
114
  return (await r.json()).uuid;
29
115
  }
30
116
 
31
117
  // after — equivalent, no helper duplication
32
118
  appUserId: async ({ authHeaders }) => {
33
- const r = await fetch('/api/iap/uuid', { headers: authHeaders });
119
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders });
34
120
  return (await r.json()).uuid;
35
121
  }
36
122
  ```
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Thin Capacitor IAP orchestrator. Server-side validation via [Attesto](https://attesto.nossdev.com).
4
4
 
5
- **Status: 0.2.0published.** API may have breaking changes through the 0.x line as it's exercised in production apps. Pin the minor version (`^0.2.0`) and watch the [CHANGELOG](./CHANGELOG.md).
5
+ **Status: `5.x`the Capacitor 5 maintenance line on the `@latest` dist-tag.** Renumbered from `0.4.x` to track the Capacitor major (same code, new number). The Capacitor 7+ line is `7.x` on the `@next` dist-tag (still prerelease — see [Migration](https://iap.nossdev.com/migration/)). Pin the minor (`^5.0.0`) and watch the [CHANGELOG](./CHANGELOG.md).
6
6
 
7
7
  ```bash
8
8
  npm install @nosslabs/iap cordova-plugin-purchase
@@ -46,7 +46,7 @@ if (result.status === 'success') {
46
46
  await iap.purchase({
47
47
  productId: 'premium_monthly',
48
48
  appUserId: async () => {
49
- const r = await fetch('/api/iap/uuid', { headers: authHeaders() });
49
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders() });
50
50
  return (await r.json()).uuid;
51
51
  },
52
52
  });
@@ -75,10 +75,10 @@ It does **not**: talk to Attesto directly, define entitlement business logic, ma
75
75
 
76
76
  ## Capacitor support matrix
77
77
 
78
- | `@nosslabs/iap` | Capacitor | Plugin | Status |
79
- |---|---|---|---|
80
- | 0.x | 5.x | `cordova-plugin-purchase ^13.x` | **Current** |
81
- | 1.x | 7.x | TBD (Capacitor-native plugin) | Roadmap |
78
+ | `@nosslabs/iap` | Capacitor | Native plugin | dist-tag | Status |
79
+ |---|---|---|---|---|
80
+ | 5.x | 5.x | `cordova-plugin-purchase ^13.x` | `@latest` | **Current (this branch)** |
81
+ | 7.x | 7.x (also runs on 8.x) | `@capgo/native-purchases` | `@next` | Prerelease |
82
82
 
83
83
  ## Optional peer dependency
84
84
 
package/dist/index.cjs CHANGED
@@ -730,6 +730,19 @@ var optionsConfigSchema = zod.z.object({
730
730
  * Excess entries stay in storage and are processed on subsequent launches.
731
731
  */
732
732
  recoveryMaxBatch: zod.z.number().int().positive().default(50),
733
+ /**
734
+ * List of backend `valid:false` error codes that recovery should treat as
735
+ * permanent — entries with a matching error are removed from storage
736
+ * instead of retried on every launch. When omitted (the default), iap
737
+ * uses `DEFAULT_PERMANENT_ERROR_CODES` (`['TRANSACTION_NOT_FOUND',
738
+ * 'PRODUCT_MISMATCH']`).
739
+ *
740
+ * REPLACES the default when provided — pass `[...DEFAULT_PERMANENT_ERROR_CODES,
741
+ * 'YOUR_CODE']` to extend, or `[]` to disable the feature entirely
742
+ * (revert to retry-forever behavior). Export the constant from
743
+ * `@nosslabs/iap` for the spread form.
744
+ */
745
+ permanentErrorCodes: zod.z.array(zod.z.string()).optional(),
733
746
  productPriceCacheTtlMs: zod.z.number().int().positive().default(24 * 60 * 60 * 1e3),
734
747
  logLevel: zod.z.enum(["silent", "error", "warn", "info", "debug"]).default("info"),
735
748
  logger: zod.z.unknown().optional()
@@ -1378,6 +1391,10 @@ async function resolveAppUserId(supply, ctx) {
1378
1391
  }
1379
1392
 
1380
1393
  // src/core/recovery-flow.ts
1394
+ var DEFAULT_PERMANENT_ERROR_CODES = [
1395
+ "TRANSACTION_NOT_FOUND",
1396
+ "PRODUCT_MISMATCH"
1397
+ ];
1381
1398
  var RecoveryOrchestrator = class {
1382
1399
  constructor(deps) {
1383
1400
  this.deps = deps;
@@ -1387,7 +1404,7 @@ var RecoveryOrchestrator = class {
1387
1404
  const { unfinished, logger, maxBatch } = this.deps;
1388
1405
  const allEntries = await unfinished.list();
1389
1406
  if (allEntries.length === 0) {
1390
- return { recovered: 0, failures: 0, inspected: 0 };
1407
+ return { recovered: 0, failures: 0, droppedPermanent: 0, inspected: 0 };
1391
1408
  }
1392
1409
  const entries = allEntries.slice(0, maxBatch);
1393
1410
  if (allEntries.length > maxBatch) {
@@ -1400,6 +1417,7 @@ var RecoveryOrchestrator = class {
1400
1417
  const settled = await Promise.allSettled(entries.map((entry) => this.processEntry(entry)));
1401
1418
  let recovered = 0;
1402
1419
  let failures = 0;
1420
+ let droppedPermanent = 0;
1403
1421
  let latestEntitlements = null;
1404
1422
  for (const result of settled) {
1405
1423
  if (result.status === "rejected") {
@@ -1409,6 +1427,8 @@ var RecoveryOrchestrator = class {
1409
1427
  if (result.value.kind === "recovered") {
1410
1428
  recovered += 1;
1411
1429
  latestEntitlements = result.value.entitlements;
1430
+ } else if (result.value.kind === "dropped-permanent") {
1431
+ droppedPermanent += 1;
1412
1432
  } else {
1413
1433
  failures += 1;
1414
1434
  }
@@ -1417,19 +1437,47 @@ var RecoveryOrchestrator = class {
1417
1437
  await this.applyEntitlements(latestEntitlements);
1418
1438
  }
1419
1439
  logger.debug(
1420
- `Recovery: ${recovered} recovered, ${failures} left in list (will retry next launch).`
1440
+ `Recovery: ${recovered} recovered, ${droppedPermanent} dropped (permanent), ${failures} left in list (will retry next launch).`
1421
1441
  );
1422
- return { recovered, failures, inspected: entries.length };
1442
+ return { recovered, failures, droppedPermanent, inspected: entries.length };
1423
1443
  }
1424
1444
  async processEntry(entry) {
1425
- const { nativeAdapter, unfinished, logger } = this.deps;
1445
+ const { nativeAdapter, unfinished, logger, emitter, permanentErrorCodes } = this.deps;
1426
1446
  const tx = entryToNativeTransaction(entry);
1427
1447
  const tokenLabel = maskToken(entry.token);
1428
1448
  try {
1429
1449
  const response = await verifyNativeTransaction(this.deps.backend, tx);
1430
1450
  if (!response.valid) {
1451
+ if (permanentErrorCodes.has(response.error)) {
1452
+ logger.info(
1453
+ `Recovery: dropping permanently-invalid token=${tokenLabel} productId=${entry.productId} (${response.error}).`
1454
+ );
1455
+ try {
1456
+ await nativeAdapter.acknowledge(tx);
1457
+ } catch (error) {
1458
+ logger.warn(
1459
+ `Recovery: best-effort acknowledge() failed for productId=${entry.productId}; proceeding with removal anyway.`,
1460
+ error
1461
+ );
1462
+ }
1463
+ try {
1464
+ await unfinished.remove(entry.token);
1465
+ } catch (error) {
1466
+ logger.warn(
1467
+ `Recovery: unfinished.remove() failed for productId=${entry.productId} after permanent classification; will dedupe on next launch.`,
1468
+ error
1469
+ );
1470
+ }
1471
+ emitter.emit("recovery-dropped-permanent", {
1472
+ productId: entry.productId,
1473
+ token: entry.token,
1474
+ error: response.error,
1475
+ ...response.message !== void 0 ? { message: response.message } : {}
1476
+ });
1477
+ return { kind: "dropped-permanent" };
1478
+ }
1431
1479
  logger.debug(
1432
- `Recovery: backend rejected token=${tokenLabel} productId=${entry.productId} (${response.error}); leaving in list.`
1480
+ `Recovery: backend rejected token=${tokenLabel} productId=${entry.productId} (${response.error}); leaving in list for retry.`
1433
1481
  );
1434
1482
  return { kind: "failed" };
1435
1483
  }
@@ -1880,7 +1928,10 @@ function createIAP(input) {
1880
1928
  state.restorer = new RestoreOrchestrator(sharedDeps);
1881
1929
  state.recoverer = new RecoveryOrchestrator({
1882
1930
  ...sharedDeps,
1883
- maxBatch: state.config.options.recoveryMaxBatch
1931
+ maxBatch: state.config.options.recoveryMaxBatch,
1932
+ permanentErrorCodes: new Set(
1933
+ state.config.options.permanentErrorCodes ?? DEFAULT_PERMANENT_ERROR_CODES
1934
+ )
1884
1935
  });
1885
1936
  try {
1886
1937
  await state.adapter.isAvailable();
@@ -2071,6 +2122,7 @@ init_errors();
2071
2122
  // src/version.ts
2072
2123
  var VERSION = "0.1.0";
2073
2124
 
2125
+ exports.DEFAULT_PERMANENT_ERROR_CODES = DEFAULT_PERMANENT_ERROR_CODES;
2074
2126
  exports.HttpBackendAdapter = HttpBackendAdapter;
2075
2127
  exports.HttpClient = HttpClient;
2076
2128
  exports.VERSION = VERSION;