@nosslabs/iap 0.3.0 → 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,108 @@ 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
+
77
+ ## [0.3.1] — 2026-05-06
78
+
79
+ ### Added
80
+
81
+ - **`appUserId` async fetcher may now accept an optional `ctx`
82
+ parameter.** The library passes `{ authHeaders }` populated from
83
+ `backend.getAuthHeaders()` (resolved fresh per purchase), letting
84
+ consumers reuse the same auth they configured for IAP-backend
85
+ requests when their UUID-minting endpoint uses that same auth.
86
+ The parameter is optional convenience, not contract — zero-arg
87
+ fetchers from 0.2.x continue to work unchanged. Ignore the
88
+ parameter when your UUID endpoint uses different auth and close
89
+ over your own auth state instead. For consumers using a custom
90
+ `BackendAdapter` (no `getAuthHeaders` configured), `ctx.authHeaders`
91
+ is `{}`.
92
+
93
+ ```ts
94
+ // before — still valid
95
+ appUserId: async () => {
96
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders() });
97
+ return (await r.json()).uuid;
98
+ }
99
+
100
+ // after — equivalent, no helper duplication
101
+ appUserId: async ({ authHeaders }) => {
102
+ const r = await fetch('/api/iap/uuid', { method: 'POST', headers: authHeaders });
103
+ return (await r.json()).uuid;
104
+ }
105
+ ```
106
+
107
+ See `docs/guide/getting-started.md` (Pre-attaching a user identifier)
108
+ and `docs/api/types.md` (`AppUserId`) for the updated reference.
109
+
8
110
  ## [0.3.0] — 2026-05-06
9
111
 
10
112
  ### Changed (BREAKING)
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()
@@ -1232,7 +1245,11 @@ var PurchaseOrchestrator = class {
1232
1245
  message: `Product "${productId}" is not in the configured catalog.`
1233
1246
  });
1234
1247
  }
1235
- const resolvedAppUserId = appUserId !== void 0 ? await resolveAppUserId(appUserId) : void 0;
1248
+ let resolvedAppUserId;
1249
+ if (appUserId !== void 0) {
1250
+ const ctx = typeof appUserId === "function" ? { authHeaders: await this.deps.getAuthHeaders() } : { authHeaders: {} };
1251
+ resolvedAppUserId = await resolveAppUserId(appUserId, ctx);
1252
+ }
1236
1253
  this.inFlight.add(productId);
1237
1254
  this.deps.emitter.emit("purchase-started", { productId });
1238
1255
  try {
@@ -1349,11 +1366,11 @@ var PurchaseOrchestrator = class {
1349
1366
  return { status: "verification_failed", productId, error: iapError };
1350
1367
  }
1351
1368
  };
1352
- async function resolveAppUserId(supply) {
1369
+ async function resolveAppUserId(supply, ctx) {
1353
1370
  let resolved;
1354
1371
  if (typeof supply === "function") {
1355
1372
  try {
1356
- resolved = await supply();
1373
+ resolved = await supply(ctx);
1357
1374
  } catch (cause) {
1358
1375
  throw new exports.IAPError({
1359
1376
  code: exports.IAPErrorCode.APP_USER_ID_FETCH_FAILED,
@@ -1374,6 +1391,10 @@ async function resolveAppUserId(supply) {
1374
1391
  }
1375
1392
 
1376
1393
  // src/core/recovery-flow.ts
1394
+ var DEFAULT_PERMANENT_ERROR_CODES = [
1395
+ "TRANSACTION_NOT_FOUND",
1396
+ "PRODUCT_MISMATCH"
1397
+ ];
1377
1398
  var RecoveryOrchestrator = class {
1378
1399
  constructor(deps) {
1379
1400
  this.deps = deps;
@@ -1383,7 +1404,7 @@ var RecoveryOrchestrator = class {
1383
1404
  const { unfinished, logger, maxBatch } = this.deps;
1384
1405
  const allEntries = await unfinished.list();
1385
1406
  if (allEntries.length === 0) {
1386
- return { recovered: 0, failures: 0, inspected: 0 };
1407
+ return { recovered: 0, failures: 0, droppedPermanent: 0, inspected: 0 };
1387
1408
  }
1388
1409
  const entries = allEntries.slice(0, maxBatch);
1389
1410
  if (allEntries.length > maxBatch) {
@@ -1396,6 +1417,7 @@ var RecoveryOrchestrator = class {
1396
1417
  const settled = await Promise.allSettled(entries.map((entry) => this.processEntry(entry)));
1397
1418
  let recovered = 0;
1398
1419
  let failures = 0;
1420
+ let droppedPermanent = 0;
1399
1421
  let latestEntitlements = null;
1400
1422
  for (const result of settled) {
1401
1423
  if (result.status === "rejected") {
@@ -1405,6 +1427,8 @@ var RecoveryOrchestrator = class {
1405
1427
  if (result.value.kind === "recovered") {
1406
1428
  recovered += 1;
1407
1429
  latestEntitlements = result.value.entitlements;
1430
+ } else if (result.value.kind === "dropped-permanent") {
1431
+ droppedPermanent += 1;
1408
1432
  } else {
1409
1433
  failures += 1;
1410
1434
  }
@@ -1413,19 +1437,47 @@ var RecoveryOrchestrator = class {
1413
1437
  await this.applyEntitlements(latestEntitlements);
1414
1438
  }
1415
1439
  logger.debug(
1416
- `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).`
1417
1441
  );
1418
- return { recovered, failures, inspected: entries.length };
1442
+ return { recovered, failures, droppedPermanent, inspected: entries.length };
1419
1443
  }
1420
1444
  async processEntry(entry) {
1421
- const { nativeAdapter, unfinished, logger } = this.deps;
1445
+ const { nativeAdapter, unfinished, logger, emitter, permanentErrorCodes } = this.deps;
1422
1446
  const tx = entryToNativeTransaction(entry);
1423
1447
  const tokenLabel = maskToken(entry.token);
1424
1448
  try {
1425
1449
  const response = await verifyNativeTransaction(this.deps.backend, tx);
1426
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
+ }
1427
1479
  logger.debug(
1428
- `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.`
1429
1481
  );
1430
1482
  return { kind: "failed" };
1431
1483
  }
@@ -1851,6 +1903,8 @@ function createIAP(input) {
1851
1903
  state.logger.debug(`Resolved ${validated.data.length} product(s) from backend manifest.`);
1852
1904
  }
1853
1905
  state.adapter = await selectNativeAdapter({ products: state.products });
1906
+ const configGetAuthHeaders = state.config.backend.getAuthHeaders;
1907
+ const getAuthHeaders = configGetAuthHeaders ? async () => configGetAuthHeaders() : async () => ({});
1854
1908
  const sharedDeps = {
1855
1909
  nativeAdapter: state.adapter,
1856
1910
  backend: state.backend,
@@ -1864,7 +1918,8 @@ function createIAP(input) {
1864
1918
  },
1865
1919
  setCachePersisted: (cachedAt) => {
1866
1920
  state.cachedAt = cachedAt;
1867
- }
1921
+ },
1922
+ getAuthHeaders
1868
1923
  };
1869
1924
  state.orchestrator = new PurchaseOrchestrator({
1870
1925
  ...sharedDeps,
@@ -1873,7 +1928,10 @@ function createIAP(input) {
1873
1928
  state.restorer = new RestoreOrchestrator(sharedDeps);
1874
1929
  state.recoverer = new RecoveryOrchestrator({
1875
1930
  ...sharedDeps,
1876
- 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
+ )
1877
1935
  });
1878
1936
  try {
1879
1937
  await state.adapter.isAvailable();
@@ -2064,6 +2122,7 @@ init_errors();
2064
2122
  // src/version.ts
2065
2123
  var VERSION = "0.1.0";
2066
2124
 
2125
+ exports.DEFAULT_PERMANENT_ERROR_CODES = DEFAULT_PERMANENT_ERROR_CODES;
2067
2126
  exports.HttpBackendAdapter = HttpBackendAdapter;
2068
2127
  exports.HttpClient = HttpClient;
2069
2128
  exports.VERSION = VERSION;