@nosslabs/iap 0.3.1 → 0.4.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,75 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] — 2026-05-08
9
+
10
+ ### Added
11
+
12
+ - **`options.permanentErrorCodes` config** — list of backend
13
+ `valid:false` error codes that recovery should treat as permanent.
14
+ Entries with a matching error are removed from
15
+ `unfinished_transactions` storage instead of being retried on every
16
+ app launch. Defaults to `['TRANSACTION_NOT_FOUND', 'PRODUCT_MISMATCH']`
17
+ per the documented recipe contract — the two codes that mean "the
18
+ backend looked and the answer is permanently no, this transaction is
19
+ not valid." When provided, the option REPLACES the default (no magic
20
+ merge); pass `[...DEFAULT_PERMANENT_ERROR_CODES, 'YOUR_CODE']` to
21
+ extend, or `[]` to disable the feature entirely (revert to
22
+ retry-forever behavior).
23
+
24
+ ```ts
25
+ import { createIAP, DEFAULT_PERMANENT_ERROR_CODES } from '@nosslabs/iap';
26
+
27
+ // Default: TRANSACTION_NOT_FOUND and PRODUCT_MISMATCH are dropped.
28
+ createIAP({ /* ... */ });
29
+
30
+ // Extend with your backend's custom permanent codes.
31
+ createIAP({
32
+ options: {
33
+ permanentErrorCodes: [...DEFAULT_PERMANENT_ERROR_CODES, 'MY_CUSTOM_CODE'],
34
+ },
35
+ });
36
+
37
+ // Opt out entirely — every valid:false retains the entry for retry.
38
+ createIAP({
39
+ options: { permanentErrorCodes: [] },
40
+ });
41
+ ```
42
+
43
+ - **`'recovery-dropped-permanent'` event** — fires once per entry
44
+ removed by the new classifier. Payload:
45
+ `{ productId, token, error, message? }`. Useful for ops
46
+ observability when a stuck-loop self-heals.
47
+
48
+ - **`DEFAULT_PERMANENT_ERROR_CODES` exported** from the package root
49
+ for the spread-then-extend pattern above.
50
+
51
+ - **`RecoveryResult.droppedPermanent`** — new field on the recovery
52
+ result alongside `recovered` and `failures`. Counts how many
53
+ entries were removed during the current sweep.
54
+
55
+ ### Changed
56
+
57
+ - **Recovery no longer retries `valid:false` responses with
58
+ permanently-invalid error codes** (default:
59
+ `TRANSACTION_NOT_FOUND`, `PRODUCT_MISMATCH`). Previous behavior is
60
+ preserved for any code not in the permanent set, and is
61
+ configurable via `options.permanentErrorCodes`. Strict improvement
62
+ for the cases it affects (stuck loops self-heal); other paths
63
+ unchanged. **Bumped to a minor version** because the behavior is
64
+ observable — consumers asserting on `RecoveryResult` shape or
65
+ intentionally depending on retry-forever semantics for a specific
66
+ code should opt out via `permanentErrorCodes: []`.
67
+
68
+ > **Backend assumption.** The default set assumes your backend
69
+ > queries Apple App Store Server API / Google Play Developer API
70
+ > with eventually-consistent reads (typical for Attesto's recipe
71
+ > pattern). If your backend reads from a replicated database with
72
+ > replication lag exceeding app-launch cadence, a `TRANSACTION_NOT_FOUND`
73
+ > response could be transient — in that case configure
74
+ > `permanentErrorCodes: []` (or a custom set) until you've reconciled
75
+ > the lag.
76
+
8
77
  ## [0.3.1] — 2026-05-06
9
78
 
10
79
  ### Added
@@ -24,13 +93,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
24
93
  ```ts
25
94
  // before — still valid
26
95
  appUserId: async () => {
27
- const r = await fetch('/api/iap/uuid', { headers: authHeaders() });
96
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders() });
28
97
  return (await r.json()).uuid;
29
98
  }
30
99
 
31
100
  // after — equivalent, no helper duplication
32
101
  appUserId: async ({ authHeaders }) => {
33
- const r = await fetch('/api/iap/uuid', { headers: authHeaders });
102
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders });
34
103
  return (await r.json()).uuid;
35
104
  }
36
105
  ```
package/README.md CHANGED
@@ -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
  });
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;