@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 +102 -0
- package/README.md +1 -1
- package/dist/index.cjs +69 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -11
- package/dist/index.d.ts +102 -11
- package/dist/index.js +69 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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;
|