zkp-sdk-node 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/SECURITY.md +34 -0
- package/dist/cjs/client.js +233 -0
- package/dist/cjs/config.js +54 -0
- package/dist/cjs/errors.js +70 -0
- package/dist/cjs/http.js +78 -0
- package/dist/cjs/index.js +39 -0
- package/dist/cjs/invoices.js +74 -0
- package/dist/cjs/models/invoice.js +189 -0
- package/dist/cjs/models/webhook.js +97 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/replay.js +109 -0
- package/dist/cjs/status.js +46 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/webhooks/verifier.js +100 -0
- package/dist/client.d.ts +57 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +228 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +60 -0
- package/dist/http.d.ts +32 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +73 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/invoices.d.ts +35 -0
- package/dist/invoices.d.ts.map +1 -0
- package/dist/invoices.js +71 -0
- package/dist/models/invoice.d.ts +82 -0
- package/dist/models/invoice.d.ts.map +1 -0
- package/dist/models/invoice.js +184 -0
- package/dist/models/webhook.d.ts +42 -0
- package/dist/models/webhook.d.ts.map +1 -0
- package/dist/models/webhook.js +93 -0
- package/dist/replay.d.ts +52 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +103 -0
- package/dist/status.d.ts +16 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +41 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/webhooks/verifier.d.ts +38 -0
- package/dist/webhooks/verifier.d.ts.map +1 -0
- package/dist/webhooks/verifier.js +96 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZeroKYC Payments Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# zkp-sdk-node
|
|
2
|
+
|
|
3
|
+
Official Node.js/TypeScript SDK for the [ZeroKYC Pay](https://zerokyc-payments.com)
|
|
4
|
+
crypto payment gateway. Framework-agnostic, zero runtime dependencies (built-in
|
|
5
|
+
`fetch`, `AbortController`, `node:crypto`), Node.js **20+** (22+ recommended),
|
|
6
|
+
TypeScript `strict`. **Server-side only** — API keys and webhook verification
|
|
7
|
+
must never run in a browser.
|
|
8
|
+
|
|
9
|
+
The same public contract and security guarantees as
|
|
10
|
+
[zkp-sdk-php](https://github.com/zerokyc-payments/zkp-sdk-php) and
|
|
11
|
+
[zkp-sdk-python](https://github.com/zerokyc-payments/zkp-sdk-python).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install zkp-sdk-node
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
(If the package is not on npm yet, install from source:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install github:zerokyc-payments/zkp-sdk-node
|
|
23
|
+
```
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
## Quickstart (sandbox invoice in 5 minutes)
|
|
27
|
+
|
|
28
|
+
1. Create an account at [console.zerokyc-payments.com](https://console.zerokyc-payments.com)
|
|
29
|
+
and copy a **sandbox** API key (`pk_test_...`) from *API keys*.
|
|
30
|
+
2. Create an invoice and send the buyer to the hosted checkout:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { idempotencyKey, ZeroKYC } from "zkp-sdk-node";
|
|
34
|
+
|
|
35
|
+
const zkp = new ZeroKYC({
|
|
36
|
+
apiKey: process.env.ZEROKYC_API_KEY!, // pk_test_... / pk_live_...
|
|
37
|
+
environment: "sandbox",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const response = await zkp.createInvoice({
|
|
41
|
+
amount: "19.90",
|
|
42
|
+
currency: "USD",
|
|
43
|
+
orderId: "INV-1042",
|
|
44
|
+
description: "VPS plan: starter",
|
|
45
|
+
idempotencyKey: idempotencyKey("myshop", "order", 1042), // stable per order
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
res.redirect(response.invoice.checkoutUrl);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
3. Get paid: ZeroKYC detects the on-chain payment and POSTs a signed webhook.
|
|
52
|
+
**A verified webhook (or a server-side `getInvoice()`) is the only proof of
|
|
53
|
+
payment — never a browser success URL.**
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { WebhookVerificationError } from "zkp-sdk-node";
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const event = zkp.verifyWebhook(rawBody, req.header("X-ZKP-Signature") ?? "");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof WebhookVerificationError) {
|
|
62
|
+
res.status(400).end();
|
|
63
|
+
} else throw error;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Full production flow with duplicate protection and payment matching:
|
|
68
|
+
[examples/safe-webhook-handler-express.ts](examples/safe-webhook-handler-express.ts)
|
|
69
|
+
/ [examples/safe-webhook-handler-nextjs.ts](examples/safe-webhook-handler-nextjs.ts).
|
|
70
|
+
|
|
71
|
+
## API surface
|
|
72
|
+
|
|
73
|
+
| Method | Purpose |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `createInvoice({ amount, currency, ... })` | create an invoice (idempotent with `idempotencyKey`) |
|
|
76
|
+
| `getInvoice(invoiceId)` | reconciliation / polling / lost-webhook recovery |
|
|
77
|
+
| `cancelInvoice(invoiceId)` | cancel an unpaid invoice |
|
|
78
|
+
| `ping()` | liveness / configuration probe |
|
|
79
|
+
| `verifyWebhook(rawBody, signatureHeader)` | HMAC verification of a delivery |
|
|
80
|
+
|
|
81
|
+
Every method accepts `{ signal }` (standard `AbortSignal`) as the last option;
|
|
82
|
+
a per-request timeout (`timeoutMs`, default 15 s) is enforced via `AbortController`.
|
|
83
|
+
|
|
84
|
+
## Raw bodies for webhook verification (important)
|
|
85
|
+
|
|
86
|
+
The signature covers the **exact raw bytes**. Never verify re-serialized JSON:
|
|
87
|
+
|
|
88
|
+
- **Express**: enable the raw-body capture before the JSON parser:
|
|
89
|
+
`app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }))`
|
|
90
|
+
- **Next.js App Router**: `await request.text()` in a `route.ts` with
|
|
91
|
+
`export const runtime = "nodejs"` — that *is* the raw body.
|
|
92
|
+
- **Fastify**: `addContentTypeParser("application/json", { parseAs: "buffer" }, ...)`.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
`new ZeroKYC({ apiKey, environment?, webhookSecret?, timeoutMs?, maxRetries?, baseUrl? })`
|
|
97
|
+
|
|
98
|
+
- `apiKey`: `pk_test_...` (sandbox) / `pk_live_...` (production).
|
|
99
|
+
- `environment`: `"sandbox" | "production"`; inferred from the key; a mismatch throws.
|
|
100
|
+
- `webhookSecret`: `whsec_...` from console → Webhooks (used by `verifyWebhook`).
|
|
101
|
+
- `baseUrl`: tests/local development only — the URL is defined centrally.
|
|
102
|
+
|
|
103
|
+
## Status normalization
|
|
104
|
+
|
|
105
|
+
Raw API statuses never leak into your billing logic:
|
|
106
|
+
|
|
107
|
+
| API (raw) | `InvoiceStatus` |
|
|
108
|
+
|--------------------------|-----------------|
|
|
109
|
+
| `created`, `pending` | `"PENDING"` |
|
|
110
|
+
| `detecting` | `"CONFIRMING"` |
|
|
111
|
+
| `confirmed` | `"PAID"` |
|
|
112
|
+
| `underpaid` | `"UNDERPAID"` |
|
|
113
|
+
| `expired` | `"EXPIRED"` |
|
|
114
|
+
| `canceled` | `"CANCELLED"` |
|
|
115
|
+
| unknown | `"FAILED"` (alert) |
|
|
116
|
+
|
|
117
|
+
`invoice.isPaid` / `invoice.isTerminal` answer the common questions. Monetary
|
|
118
|
+
amounts are **decimal strings everywhere** — never JavaScript numbers.
|
|
119
|
+
|
|
120
|
+
## Idempotency
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { idempotencyKey } from "zkp-sdk-node";
|
|
124
|
+
idempotencyKey("whmcs", "invoice", 1042); // zerokyc:whmcs:invoice:1042 (<=120 chars)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A timeout + retry then returns **the same** invoice
|
|
128
|
+
(`response.idempotentReplay === true`) instead of a duplicate.
|
|
129
|
+
|
|
130
|
+
## Error handling & retries
|
|
131
|
+
|
|
132
|
+
| Error | HTTP | Retried automatically? |
|
|
133
|
+
|--------------------------|---------------|------------------------|
|
|
134
|
+
| `AuthenticationError` | 401 / 403 | never |
|
|
135
|
+
| `ValidationError` | 400 / 422 | never |
|
|
136
|
+
| `RateLimitError` | 429 | yes (Retry-After up to 5 s, max twice) |
|
|
137
|
+
| `APIError` | 5xx, 402, 404 | only GET / idempotent POST |
|
|
138
|
+
| `NetworkError` | transport/abort | only GET / idempotent POST (never a caller abort) |
|
|
139
|
+
| `WebhookVerificationError` | n/a | n/a (machine-readable `.reason`) |
|
|
140
|
+
|
|
141
|
+
`RateLimitError.retryAfter` is the parsed Retry-After in seconds or `null`
|
|
142
|
+
(malformed, negative, fractional and HTTP-date values map to `null` safely).
|
|
143
|
+
Backoff is bounded exponential (300 ms → 600 ms → 1200 ms, capped by `maxRetries`).
|
|
144
|
+
|
|
145
|
+
## Webhook security checklist
|
|
146
|
+
|
|
147
|
+
- verify against the **exact raw body** (never re-serialized JSON);
|
|
148
|
+
- strict `t=`/`v1=` header format, lowercase hex, `timingSafeEqual` (built in);
|
|
149
|
+
- default ±300 s window (`new WebhookVerifier(secret, 600)` to widen);
|
|
150
|
+
- at-least-once delivery: `ReplayGuard.isDuplicate()` is a **pure** check —
|
|
151
|
+
call `markProcessed()` only after the local order update succeeded;
|
|
152
|
+
- match invoice id / amount / asset before crediting (`matchesOrder()`);
|
|
153
|
+
- secrets never appear in exceptions or logs.
|
|
154
|
+
|
|
155
|
+
Self-test against the documented vector:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { WebhookVerifier } from "zkp-sdk-node";
|
|
159
|
+
|
|
160
|
+
const event = new WebhookVerifier("whsec_zkp_test_vector_2026").verify(
|
|
161
|
+
'{"id":"evt_test_001","type":"payment.confirmed","invoice_id":"inv_test_001"}',
|
|
162
|
+
"t=1788788073,v1=ade537fa13aec79a6d1648bd7f197872066c161676c389243ab5c6b13fea7f52",
|
|
163
|
+
{ now: 1788788073 },
|
|
164
|
+
);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Assets
|
|
168
|
+
|
|
169
|
+
USDT (TRC-20), USDC/USDT (Polygon, Arbitrum), BTC, XMR, TON, USDT-TON. Pin one
|
|
170
|
+
via `paymentCurrency: "USDT_TRON"`, or let the buyer choose with `"any"` (default).
|
|
171
|
+
|
|
172
|
+
## Node support & SemVer
|
|
173
|
+
|
|
174
|
+
Node 20 / 22 / 24 (CI-tested; 22+ recommended). The SDK itself runs on plain Node 20;
|
|
175
|
+
developing it (running the test suite) needs Node 20.12+ because of vitest. ESM and CommonJS entry points
|
|
176
|
+
with full type declarations. SemVer: 0.x collects integration feedback,
|
|
177
|
+
breaking changes before 1.0 land in minor bumps and are listed in the
|
|
178
|
+
[CHANGELOG](CHANGELOG.md).
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npm ci
|
|
184
|
+
npm run lint && npm run typecheck
|
|
185
|
+
npm test # vitest
|
|
186
|
+
npm run build && npm pack --dry-run
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT — see [LICENSE](LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
Please **do not** report security vulnerabilities through public GitHub issues.
|
|
6
|
+
|
|
7
|
+
Report privately via GitHub's *Report a vulnerability* flow on this repository
|
|
8
|
+
(Report a vulnerability -> Security tab), or email **security@zerokyc-payments.com**.
|
|
9
|
+
|
|
10
|
+
Include a description, reproduction steps and affected versions. We aim to
|
|
11
|
+
respond within 72 hours and will credit reporters in release notes unless you
|
|
12
|
+
prefer to stay anonymous.
|
|
13
|
+
|
|
14
|
+
## Scope
|
|
15
|
+
|
|
16
|
+
- This SDK repository (client, webhook verification, examples).
|
|
17
|
+
- For platform/product vulnerabilities (API, checkout, console) use the same
|
|
18
|
+
channel - we route internally.
|
|
19
|
+
|
|
20
|
+
## Out of scope
|
|
21
|
+
|
|
22
|
+
- Attacks requiring a compromised merchant API key or webhook secret.
|
|
23
|
+
- Rate limiting/resource exhaustion of our public endpoints without a proof
|
|
24
|
+
of impact.
|
|
25
|
+
|
|
26
|
+
## Verification notes for auditors
|
|
27
|
+
|
|
28
|
+
- Webhook signature comparison uses `crypto.timingSafeEqual` for constant-time
|
|
29
|
+
comparison (length-checked before the call).
|
|
30
|
+
- Timestamp tolerance defaults to +-300 seconds and is enforced before the
|
|
31
|
+
HMAC comparison.
|
|
32
|
+
- Secrets are never included in exceptions or logs by the SDK itself.
|
|
33
|
+
- The SDK is server-side only; API keys and webhook secrets must never reach
|
|
34
|
+
a browser bundle.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP client with typed error mapping and a bounded, idempotency-aware
|
|
4
|
+
* retry policy. Adapters never talk to the API directly.
|
|
5
|
+
*
|
|
6
|
+
* Retry rules (identical to the PHP/Python SDKs):
|
|
7
|
+
* - GET: retry NetworkError and selected retryable statuses (429/408/5xx);
|
|
8
|
+
* - POST invoices: retry ONLY when an idempotency key is present;
|
|
9
|
+
* - 429: honors Retry-After up to a 5s cap (plain non-negative integers
|
|
10
|
+
* only; malformed, negative, fractional and HTTP-date values safely map
|
|
11
|
+
* to retryAfter: null and never break error construction);
|
|
12
|
+
* - 400/401/403/422: never retried;
|
|
13
|
+
* - bounded exponential backoff (300ms -> 600ms -> 1200ms), no infinite loops.
|
|
14
|
+
*
|
|
15
|
+
* Requests accept an AbortSignal; a per-request timeout is enforced via
|
|
16
|
+
* AbortController. Secrets never appear in exceptions.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.ZeroKYC = void 0;
|
|
20
|
+
exports.idempotencyKey = idempotencyKey;
|
|
21
|
+
const config_js_1 = require("./config.js");
|
|
22
|
+
const errors_js_1 = require("./errors.js");
|
|
23
|
+
const http_js_1 = require("./http.js");
|
|
24
|
+
const invoices_js_1 = require("./invoices.js");
|
|
25
|
+
const invoice_js_1 = require("./models/invoice.js");
|
|
26
|
+
const verifier_js_1 = require("./webhooks/verifier.js");
|
|
27
|
+
const RETRYABLE_STATUSES = new Set([429, 408]);
|
|
28
|
+
const RETRY_AFTER_CAP_SECONDS = 5;
|
|
29
|
+
/** Stable idempotency key: zerokyc:{platform}:{entity}:{id} (max 120 chars). */
|
|
30
|
+
function idempotencyKey(...parts) {
|
|
31
|
+
const key = `zerokyc:${parts.join(":")}`;
|
|
32
|
+
if (!key) {
|
|
33
|
+
throw new errors_js_1.ValidationError("idempotency key must not be empty");
|
|
34
|
+
}
|
|
35
|
+
if (key.length > 120) {
|
|
36
|
+
throw new errors_js_1.ValidationError("idempotency key must be at most 120 characters");
|
|
37
|
+
}
|
|
38
|
+
return key;
|
|
39
|
+
}
|
|
40
|
+
const defaultSleeper = (ms) => new Promise((resolve) => {
|
|
41
|
+
setTimeout(resolve, ms);
|
|
42
|
+
});
|
|
43
|
+
class ZeroKYC {
|
|
44
|
+
config;
|
|
45
|
+
fetchLike;
|
|
46
|
+
sleeper;
|
|
47
|
+
constructor(options = { apiKey: "" }) {
|
|
48
|
+
this.config = new config_js_1.Config(options);
|
|
49
|
+
this.fetchLike = options.fetchImpl ?? http_js_1.fetchTransport;
|
|
50
|
+
this.sleeper = options.sleeper ?? defaultSleeper;
|
|
51
|
+
}
|
|
52
|
+
/** Create an invoice; with an idempotency key a timeout+retry returns the
|
|
53
|
+
* same invoice instead of creating a duplicate. */
|
|
54
|
+
async createInvoice(options) {
|
|
55
|
+
const { signal, ...create } = options;
|
|
56
|
+
const payload = (0, invoices_js_1.buildCreatePayload)(create);
|
|
57
|
+
const headers = { "Content-Type": "application/json" };
|
|
58
|
+
if (create.idempotencyKey !== undefined) {
|
|
59
|
+
headers["Idempotency-Key"] = create.idempotencyKey;
|
|
60
|
+
}
|
|
61
|
+
const response = await this.request("POST", "/v1/invoices", {
|
|
62
|
+
headers,
|
|
63
|
+
body: JSON.stringify(payload),
|
|
64
|
+
mayRetry: create.idempotencyKey !== undefined,
|
|
65
|
+
expectedStatus: 201,
|
|
66
|
+
signal,
|
|
67
|
+
});
|
|
68
|
+
const replay = (response.header("Idempotent-Replay") ?? "").toLowerCase() === "true";
|
|
69
|
+
return new invoice_js_1.CreateInvoiceResponse(invoice_js_1.Invoice.fromJson(parseJson(response)), replay);
|
|
70
|
+
}
|
|
71
|
+
/** Reconciliation/recovery: poll a status server-to-server. */
|
|
72
|
+
async getInvoice(invoiceId, options = {}) {
|
|
73
|
+
const response = await this.request("GET", `/v1/invoices/${encodeURIComponent(invoiceId)}`, {
|
|
74
|
+
mayRetry: true,
|
|
75
|
+
expectedStatus: 200,
|
|
76
|
+
signal: options.signal,
|
|
77
|
+
});
|
|
78
|
+
return invoice_js_1.Invoice.fromJson(parseJson(response));
|
|
79
|
+
}
|
|
80
|
+
async cancelInvoice(invoiceId, options = {}) {
|
|
81
|
+
const response = await this.request("POST", `/v1/invoices/${encodeURIComponent(invoiceId)}/cancel`, {
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: "{}",
|
|
84
|
+
mayRetry: false,
|
|
85
|
+
expectedStatus: 200,
|
|
86
|
+
signal: options.signal,
|
|
87
|
+
});
|
|
88
|
+
return invoice_js_1.Invoice.fromJson(parseJson(response));
|
|
89
|
+
}
|
|
90
|
+
/** Liveness/configuration probe; returns the decoded /v1/ping body. */
|
|
91
|
+
async ping(options = {}) {
|
|
92
|
+
const response = await this.request("GET", "/v1/ping", {
|
|
93
|
+
mayRetry: true,
|
|
94
|
+
expectedStatus: 200,
|
|
95
|
+
signal: options.signal,
|
|
96
|
+
});
|
|
97
|
+
return parseJson(response);
|
|
98
|
+
}
|
|
99
|
+
/** Verify a delivery; throws WebhookVerificationError on any failure. */
|
|
100
|
+
verifyWebhook(rawBody, signatureHeader, options = {}) {
|
|
101
|
+
return this.verifier(options.secret).verify(rawBody, signatureHeader);
|
|
102
|
+
}
|
|
103
|
+
verifier(secret) {
|
|
104
|
+
return new verifier_js_1.WebhookVerifier(secret ?? this.config.webhookSecret);
|
|
105
|
+
}
|
|
106
|
+
/** Await the sleeper; a caller abort during the wait stops the retry loop. */
|
|
107
|
+
async wait(ms, signal) {
|
|
108
|
+
const sleep = Promise.resolve(this.sleeper(ms));
|
|
109
|
+
if (!signal) {
|
|
110
|
+
await sleep;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (signal.aborted) {
|
|
114
|
+
throw new errors_js_1.NetworkError("aborted while waiting to retry");
|
|
115
|
+
}
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
const onAbort = () => reject(new errors_js_1.NetworkError("aborted while waiting to retry"));
|
|
118
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
119
|
+
void sleep.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async request(method, path, opts) {
|
|
123
|
+
let attempt = 0;
|
|
124
|
+
for (;;) {
|
|
125
|
+
let response;
|
|
126
|
+
try {
|
|
127
|
+
response = await this.fetchLike(this.config.baseUrl + path, {
|
|
128
|
+
method,
|
|
129
|
+
headers: {
|
|
130
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
131
|
+
Accept: "application/json",
|
|
132
|
+
"User-Agent": (0, http_js_1.sdkUserAgent)(),
|
|
133
|
+
...opts.headers,
|
|
134
|
+
},
|
|
135
|
+
body: opts.body,
|
|
136
|
+
signal: opts.signal,
|
|
137
|
+
redirect: "manual",
|
|
138
|
+
timeoutMs: this.config.timeoutMs,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (!(e instanceof errors_js_1.NetworkError)) {
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
// the caller cancelled: retrying their own abort is pointless
|
|
146
|
+
if (opts.signal?.aborted) {
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
if (!opts.mayRetry || attempt >= this.config.maxRetries) {
|
|
150
|
+
throw e;
|
|
151
|
+
}
|
|
152
|
+
await this.wait(backoffMs(attempt), opts.signal);
|
|
153
|
+
attempt += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (response.status === opts.expectedStatus) {
|
|
157
|
+
return response;
|
|
158
|
+
}
|
|
159
|
+
if ((RETRYABLE_STATUSES.has(response.status) || response.status >= 500) &&
|
|
160
|
+
opts.mayRetry &&
|
|
161
|
+
attempt < this.config.maxRetries) {
|
|
162
|
+
const delayMs = response.status === 429
|
|
163
|
+
? retryAfterMs(response.header("Retry-After"))
|
|
164
|
+
: backoffMs(attempt);
|
|
165
|
+
if (delayMs !== null) {
|
|
166
|
+
await this.wait(delayMs, opts.signal);
|
|
167
|
+
attempt += 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// Retry-After beyond the cap: fall through and surface the 429
|
|
171
|
+
}
|
|
172
|
+
throw mapError(response);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
exports.ZeroKYC = ZeroKYC;
|
|
177
|
+
function parseJson(response) {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(response.body);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new errors_js_1.APIError("response was not valid JSON", response.status);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function mapError(response) {
|
|
186
|
+
let error = {};
|
|
187
|
+
try {
|
|
188
|
+
const decoded = JSON.parse(response.body);
|
|
189
|
+
if (decoded && typeof decoded === "object" && typeof decoded.error === "object") {
|
|
190
|
+
error = decoded.error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// non-JSON error body
|
|
195
|
+
}
|
|
196
|
+
const message = typeof error.message === "string" && error.message
|
|
197
|
+
? error.message
|
|
198
|
+
: `unexpected HTTP ${response.status}`;
|
|
199
|
+
if (response.status === 401 || response.status === 403) {
|
|
200
|
+
return new errors_js_1.AuthenticationError(message);
|
|
201
|
+
}
|
|
202
|
+
if (response.status === 429) {
|
|
203
|
+
return new errors_js_1.RateLimitError(message, parseRetryAfter(response.header("Retry-After")));
|
|
204
|
+
}
|
|
205
|
+
if (response.status === 400 || response.status === 422) {
|
|
206
|
+
return new errors_js_1.ValidationError(message);
|
|
207
|
+
}
|
|
208
|
+
return new errors_js_1.APIError(message, response.status, typeof error.code === "string" ? error.code : undefined, typeof error.doc_url === "string" ? error.doc_url : undefined);
|
|
209
|
+
}
|
|
210
|
+
/** Seconds from Retry-After: plain non-negative integers only, else null. */
|
|
211
|
+
function parseRetryAfter(header) {
|
|
212
|
+
if (header === null) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const value = header.trim();
|
|
216
|
+
return /^\d+$/.test(value) ? Number(value) : null;
|
|
217
|
+
}
|
|
218
|
+
/** Delay for a 429 retry; null means "surface the error immediately". */
|
|
219
|
+
function retryAfterMs(header) {
|
|
220
|
+
if (header === null) {
|
|
221
|
+
return backoffMs(0);
|
|
222
|
+
}
|
|
223
|
+
const seconds = parseRetryAfter(header);
|
|
224
|
+
if (seconds === null) {
|
|
225
|
+
// malformed or HTTP-date: unsupported -> retryAfter reported as null,
|
|
226
|
+
// still retry on the default bounded backoff
|
|
227
|
+
return backoffMs(0);
|
|
228
|
+
}
|
|
229
|
+
return seconds <= RETRY_AFTER_CAP_SECONDS ? seconds * 1000 : null;
|
|
230
|
+
}
|
|
231
|
+
function backoffMs(attempt) {
|
|
232
|
+
return 300 * 2 ** attempt;
|
|
233
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SDK configuration: sandbox/production, base URL, timeouts, retries.
|
|
4
|
+
*
|
|
5
|
+
* The base URL is defined centrally here; the override exists for tests and
|
|
6
|
+
* local development only - adapters must never hard-code URLs.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.Config = exports.PRODUCTION = exports.SANDBOX = exports.DEFAULT_BASE_URL = void 0;
|
|
10
|
+
exports.DEFAULT_BASE_URL = "https://api.zerokyc-payments.com";
|
|
11
|
+
exports.SANDBOX = "sandbox";
|
|
12
|
+
exports.PRODUCTION = "production";
|
|
13
|
+
class Config {
|
|
14
|
+
apiKey;
|
|
15
|
+
environment;
|
|
16
|
+
webhookSecret;
|
|
17
|
+
timeoutMs;
|
|
18
|
+
maxRetries;
|
|
19
|
+
baseUrl;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
const { apiKey } = options;
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
throw new Error("apiKey is required");
|
|
24
|
+
}
|
|
25
|
+
const environment = options.environment ?? (apiKey.startsWith("pk_test_") ? exports.SANDBOX : exports.PRODUCTION);
|
|
26
|
+
if (environment !== exports.SANDBOX && environment !== exports.PRODUCTION) {
|
|
27
|
+
throw new Error(`environment must be 'sandbox' or 'production', got '${environment}'`);
|
|
28
|
+
}
|
|
29
|
+
// Mixing up sandbox and live keys is the classic production incident:
|
|
30
|
+
// refuse the mismatch outright instead of hoping for the best.
|
|
31
|
+
const isTestKey = apiKey.startsWith("pk_test_");
|
|
32
|
+
if (environment === exports.PRODUCTION && isTestKey) {
|
|
33
|
+
throw new Error("environment is production but the apiKey is a sandbox key (pk_test_...); " +
|
|
34
|
+
"use a pk_live_... key or set environment: 'sandbox'");
|
|
35
|
+
}
|
|
36
|
+
if (environment === exports.SANDBOX && !isTestKey) {
|
|
37
|
+
throw new Error("environment is sandbox but the apiKey is not a sandbox key; expected a pk_test_... key");
|
|
38
|
+
}
|
|
39
|
+
const maxRetries = options.maxRetries ?? 2;
|
|
40
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
|
|
41
|
+
throw new Error("maxRetries must be an integer between 0 and 5");
|
|
42
|
+
}
|
|
43
|
+
this.apiKey = apiKey;
|
|
44
|
+
this.environment = environment;
|
|
45
|
+
this.webhookSecret = options.webhookSecret ?? "";
|
|
46
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
47
|
+
this.maxRetries = maxRetries;
|
|
48
|
+
this.baseUrl = (options.baseUrl ?? exports.DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
49
|
+
}
|
|
50
|
+
get isSandbox() {
|
|
51
|
+
return this.environment === exports.SANDBOX;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.Config = Config;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Typed error hierarchy.
|
|
4
|
+
*
|
|
5
|
+
* Mapping: 401/403 -> AuthenticationError, 400/422 -> ValidationError,
|
|
6
|
+
* 429 -> RateLimitError (carries retryAfter seconds when sent), 5xx/402/404
|
|
7
|
+
* -> APIError, transport failures (timeout/DNS/connect/abort) ->
|
|
8
|
+
* NetworkError, webhook verification -> WebhookVerificationError
|
|
9
|
+
* (machine-readable `reason`).
|
|
10
|
+
*
|
|
11
|
+
* Secrets never appear in messages: they carry API-provided text only.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.WebhookVerificationError = exports.NetworkError = exports.APIError = exports.RateLimitError = exports.ValidationError = exports.AuthenticationError = exports.ZeroKYCError = void 0;
|
|
15
|
+
class ZeroKYCError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = new.target.name;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
exports.ZeroKYCError = ZeroKYCError;
|
|
22
|
+
/** 401/403 from the API: missing/invalid key or forbidden scope. Never retried. */
|
|
23
|
+
class AuthenticationError extends ZeroKYCError {
|
|
24
|
+
}
|
|
25
|
+
exports.AuthenticationError = AuthenticationError;
|
|
26
|
+
/** 400/422 from the API (or local request validation). Never retried. */
|
|
27
|
+
class ValidationError extends ZeroKYCError {
|
|
28
|
+
}
|
|
29
|
+
exports.ValidationError = ValidationError;
|
|
30
|
+
/** 429 from the API; `retryAfter` carries Retry-After seconds when parseable. */
|
|
31
|
+
class RateLimitError extends ZeroKYCError {
|
|
32
|
+
retryAfter;
|
|
33
|
+
constructor(message, retryAfter = null) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.retryAfter = retryAfter;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.RateLimitError = RateLimitError;
|
|
39
|
+
/** Unexpected API error (5xx, 402 cutoff, 404, malformed envelope). */
|
|
40
|
+
class APIError extends ZeroKYCError {
|
|
41
|
+
status;
|
|
42
|
+
errorCode;
|
|
43
|
+
docUrl;
|
|
44
|
+
constructor(message, status = 0, errorCode, docUrl) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.status = status;
|
|
47
|
+
this.errorCode = errorCode;
|
|
48
|
+
this.docUrl = docUrl;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
exports.APIError = APIError;
|
|
52
|
+
/** Transport failure: DNS, connect, TLS, timeout or abort. */
|
|
53
|
+
class NetworkError extends ZeroKYCError {
|
|
54
|
+
}
|
|
55
|
+
exports.NetworkError = NetworkError;
|
|
56
|
+
/** Webhook verification failed; `reason` is machine-readable. */
|
|
57
|
+
class WebhookVerificationError extends ZeroKYCError {
|
|
58
|
+
reason;
|
|
59
|
+
static MISSING_HEADER = "missing_header";
|
|
60
|
+
static MALFORMED_HEADER = "malformed_header";
|
|
61
|
+
static STALE_TIMESTAMP = "stale_timestamp";
|
|
62
|
+
static FUTURE_TIMESTAMP = "future_timestamp";
|
|
63
|
+
static SIGNATURE_MISMATCH = "signature_mismatch";
|
|
64
|
+
static MALFORMED_PAYLOAD = "malformed_payload";
|
|
65
|
+
constructor(message, reason) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.reason = reason;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|
package/dist/cjs/http.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP transport: built-in fetch with per-request timeout via
|
|
4
|
+
* AbortController, an injectable fetch for tests, and redirect: "manual"
|
|
5
|
+
* (a 3xx must surface - the same rule as the PHP/Python SDKs).
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.httpResponse = httpResponse;
|
|
9
|
+
exports.fetchTransport = fetchTransport;
|
|
10
|
+
exports.sdkUserAgent = sdkUserAgent;
|
|
11
|
+
const errors_js_1 = require("./errors.js");
|
|
12
|
+
const version_js_1 = require("./version.js");
|
|
13
|
+
function httpResponse(status, body, headers = {}) {
|
|
14
|
+
const normalized = {};
|
|
15
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
16
|
+
normalized[k.toLowerCase()] = v;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
status,
|
|
20
|
+
body,
|
|
21
|
+
headers: normalized,
|
|
22
|
+
header(name) {
|
|
23
|
+
return normalized[name.toLowerCase()] ?? null;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Built-in fetch transport: per-request timeout via AbortController, caller
|
|
29
|
+
* aborts forwarded into the same controller (original reason preserved),
|
|
30
|
+
* redirect: "manual" - a 3xx must surface (same rule as the PHP/Python SDKs).
|
|
31
|
+
* Listener and timer lifecycles are leak-free: everything is cleaned in
|
|
32
|
+
* finally, so a reused caller signal never accumulates handlers.
|
|
33
|
+
*/
|
|
34
|
+
async function fetchTransport(url, init) {
|
|
35
|
+
// ONE request controller: caller aborts are forwarded into it, and the
|
|
36
|
+
// forwarding listener plus the timeout timer are always removed in finally
|
|
37
|
+
// (a reused caller signal must never accumulate listeners).
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
controller.abort(new Error("request timeout"));
|
|
41
|
+
}, init.timeoutMs ?? 15_000);
|
|
42
|
+
const caller = init.signal;
|
|
43
|
+
const onCallerAbort = () => {
|
|
44
|
+
// surface the caller's own abort reason, not a generic one
|
|
45
|
+
controller.abort(caller?.reason);
|
|
46
|
+
};
|
|
47
|
+
if (caller?.aborted) {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
throw new errors_js_1.NetworkError(`transport failure: ${String(caller.reason ?? "aborted")}`);
|
|
50
|
+
}
|
|
51
|
+
caller?.addEventListener("abort", onCallerAbort, { once: true });
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(url, {
|
|
54
|
+
method: init.method,
|
|
55
|
+
headers: init.headers,
|
|
56
|
+
body: init.body,
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
redirect: "manual",
|
|
59
|
+
});
|
|
60
|
+
const headers = {};
|
|
61
|
+
response.headers.forEach((value, key) => {
|
|
62
|
+
headers[key] = value;
|
|
63
|
+
});
|
|
64
|
+
return httpResponse(response.status, await response.text(), headers);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
// fetch rejects on network/timeout/abort; DNS/connect arrive as TypeError
|
|
68
|
+
throw new errors_js_1.NetworkError(`transport failure: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Identifiable SDK user agent (CDNs commonly block library default UAs). */
|
|
76
|
+
function sdkUserAgent() {
|
|
77
|
+
return `zerokyc-node/${version_js_1.VERSION}`;
|
|
78
|
+
}
|