@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 +71 -2
- package/README.md +1 -1
- package/dist/index.cjs +58 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -7
- package/dist/index.d.ts +75 -7
- package/dist/index.js +58 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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;
|