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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/SECURITY.md +34 -0
  4. package/dist/cjs/client.js +233 -0
  5. package/dist/cjs/config.js +54 -0
  6. package/dist/cjs/errors.js +70 -0
  7. package/dist/cjs/http.js +78 -0
  8. package/dist/cjs/index.js +39 -0
  9. package/dist/cjs/invoices.js +74 -0
  10. package/dist/cjs/models/invoice.js +189 -0
  11. package/dist/cjs/models/webhook.js +97 -0
  12. package/dist/cjs/package.json +1 -0
  13. package/dist/cjs/replay.js +109 -0
  14. package/dist/cjs/status.js +46 -0
  15. package/dist/cjs/version.js +5 -0
  16. package/dist/cjs/webhooks/verifier.js +100 -0
  17. package/dist/client.d.ts +57 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +228 -0
  20. package/dist/config.d.ts +29 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +50 -0
  23. package/dist/errors.d.ts +47 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +60 -0
  26. package/dist/http.d.ts +32 -0
  27. package/dist/http.d.ts.map +1 -0
  28. package/dist/http.js +73 -0
  29. package/dist/index.d.ts +16 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +15 -0
  32. package/dist/invoices.d.ts +35 -0
  33. package/dist/invoices.d.ts.map +1 -0
  34. package/dist/invoices.js +71 -0
  35. package/dist/models/invoice.d.ts +82 -0
  36. package/dist/models/invoice.d.ts.map +1 -0
  37. package/dist/models/invoice.js +184 -0
  38. package/dist/models/webhook.d.ts +42 -0
  39. package/dist/models/webhook.d.ts.map +1 -0
  40. package/dist/models/webhook.js +93 -0
  41. package/dist/replay.d.ts +52 -0
  42. package/dist/replay.d.ts.map +1 -0
  43. package/dist/replay.js +103 -0
  44. package/dist/status.d.ts +16 -0
  45. package/dist/status.d.ts.map +1 -0
  46. package/dist/status.js +41 -0
  47. package/dist/version.d.ts +3 -0
  48. package/dist/version.d.ts.map +1 -0
  49. package/dist/version.js +2 -0
  50. package/dist/webhooks/verifier.d.ts +38 -0
  51. package/dist/webhooks/verifier.d.ts.map +1 -0
  52. package/dist/webhooks/verifier.js +96 -0
  53. package/package.json +66 -0
package/dist/replay.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * At-least-once delivery protection and payment matching (P0).
3
+ *
4
+ * `isDuplicate()` is a PURE lookup: it never mutates the store. An event is
5
+ * marked processed only via `markProcessed()` AFTER the local order update
6
+ * succeeded - marking earlier would turn any crash in between into a
7
+ * permanently unpaid order (the retry would be skipped as a duplicate).
8
+ *
9
+ * Concurrent deliveries of the same event can both pass `isDuplicate()`;
10
+ * apps needing strict single-processing should implement an atomic claim
11
+ * (INSERT ... ON CONFLICT / unique constraint) in their EventStore and use
12
+ * it as the source of truth.
13
+ *
14
+ * Monetary validation is fail-closed: an unparseable amount never compares
15
+ * as "enough" - `matchesOrder()` returns false and `compareDecimals()`
16
+ * throws instead of silently comparing garbage.
17
+ */
18
+ /** Plain non-negative decimal only: no sign, exponent, spaces, extra dots or
19
+ * NaN/Infinity spellings. Zero is a syntactically valid amount. */
20
+ const DECIMAL_RE = /^[0-9]+(?:\.[0-9]+)?$/;
21
+ /** True only for plain non-negative decimal strings (no float arithmetic). */
22
+ export function isValidDecimal(value) {
23
+ return typeof value === "string" && DECIMAL_RE.test(value);
24
+ }
25
+ /**
26
+ * Compare two plain non-negative decimal strings WITHOUT floats.
27
+ * Throws TypeError on anything that is not a plain decimal - garbage must
28
+ * never compare silently (validate first; matchesOrder does).
29
+ */
30
+ export function compareDecimals(a, b) {
31
+ for (const value of [a, b]) {
32
+ if (!DECIMAL_RE.test(value)) {
33
+ throw new TypeError(`not a plain non-negative decimal string: ${JSON.stringify(value)}`);
34
+ }
35
+ }
36
+ const [ai = "0", af = ""] = a.split(".");
37
+ const [bi = "0", bf = ""] = b.split(".");
38
+ // normalize BEFORE comparing: leading zeros must not affect length/value
39
+ // ("0002" is 2, not a 4-digit number greater than "10")
40
+ const norm = (s) => s.replace(/^0+(?=\d)/, "");
41
+ const nAi = norm(ai);
42
+ const nBi = norm(bi);
43
+ const intCmp = nAi.length !== nBi.length ? nAi.length - nBi.length : nAi.localeCompare(nBi);
44
+ const fracCmp = af.padEnd(bf.length, "0").localeCompare(bf.padEnd(af.length, "0"));
45
+ return intCmp || fracCmp; // never -0 (0 stays 0 via ||)
46
+ }
47
+ export class ReplayGuard {
48
+ store;
49
+ constructor(store) {
50
+ this.store = store;
51
+ }
52
+ /** Pure check: true when the event was already processed. No side effects. */
53
+ async isDuplicate(eventId) {
54
+ return await this.store.has(eventId);
55
+ }
56
+ /** Call only after the local order update has succeeded. */
57
+ async markProcessed(eventId) {
58
+ await this.store.markProcessed(eventId);
59
+ }
60
+ /**
61
+ * Signature validity alone is NOT enough to credit an order: match the
62
+ * invoice id, the paid crypto amount (>= minAmount) and the asset.
63
+ * Fail-closed: invalid amount data means "not enough", never "paid".
64
+ */
65
+ matchesOrder(event, expectedInvoiceId, options = {}) {
66
+ if (event.invoiceId !== expectedInvoiceId) {
67
+ return false;
68
+ }
69
+ if (options.asset !== undefined && !assetMatches(event, options.asset)) {
70
+ return false;
71
+ }
72
+ if (options.minAmount !== undefined) {
73
+ const paid = event.paidAmount;
74
+ if (paid === null || !isValidDecimal(paid) || !isValidDecimal(options.minAmount)) {
75
+ return false;
76
+ }
77
+ if (compareDecimals(paid, options.minAmount) < 0) {
78
+ return false;
79
+ }
80
+ }
81
+ return true;
82
+ }
83
+ }
84
+ function assetMatches(event, expected) {
85
+ const paidRaw = (event.paidAsset ?? "").toLowerCase();
86
+ if (!paidRaw) {
87
+ return false;
88
+ }
89
+ const [expTicker, expNetwork] = expected.toLowerCase().split("_");
90
+ const [paidTicker] = paidRaw.split("_");
91
+ if (paidTicker !== expTicker) {
92
+ return false;
93
+ }
94
+ if (expNetwork !== undefined && expNetwork !== "") {
95
+ // asset id expected: the webhook MUST carry the network explicitly;
96
+ // missing or different network is a mismatch (fail-closed)
97
+ const paidNetwork = (event.paidNetwork ?? "").toLowerCase();
98
+ if (paidNetwork !== expNetwork) {
99
+ return false;
100
+ }
101
+ }
102
+ return true;
103
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Normalized invoice status + raw-API status mapping.
3
+ *
4
+ * Raw statuses (created/pending/detecting/confirmed/underpaid/expired/canceled)
5
+ * never leak into billing logic: platforms consume InvoiceStatus
6
+ * (mirrors the PHP/Python SDKs).
7
+ */
8
+ export type InvoiceStatus = "PENDING" | "CONFIRMING" | "PAID" | "UNDERPAID" | "EXPIRED" | "CANCELLED" | "OVERPAID" | "FAILED";
9
+ export declare class StatusMapper {
10
+ static normalize(raw: string): InvoiceStatus | null;
11
+ static normalizeOrFail(raw: string): InvoiceStatus;
12
+ static isTerminal(raw: string): boolean;
13
+ static isPaid(raw: string): boolean;
14
+ }
15
+ export declare function isTerminalStatus(status: InvoiceStatus): boolean;
16
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,YAAY,GACZ,MAAM,GACN,WAAW,GACX,SAAS,GACT,WAAW,GACX,UAAU,GACV,QAAQ,CAAC;AAiBb,qBAAa,YAAY;IACvB,MAAM,CAAC,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI;IAInD,MAAM,CAAC,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa;IAQlD,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIvC,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;CAGpC;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAE/D"}
package/dist/status.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Normalized invoice status + raw-API status mapping.
3
+ *
4
+ * Raw statuses (created/pending/detecting/confirmed/underpaid/expired/canceled)
5
+ * never leak into billing logic: platforms consume InvoiceStatus
6
+ * (mirrors the PHP/Python SDKs).
7
+ */
8
+ const MAP = {
9
+ created: "PENDING",
10
+ pending: "PENDING",
11
+ detecting: "CONFIRMING",
12
+ confirmed: "PAID",
13
+ underpaid: "UNDERPAID",
14
+ expired: "EXPIRED",
15
+ canceled: "CANCELLED",
16
+ // overpayment is credited and confirmed server-side; kept for forward
17
+ // compatibility and local bookkeeping
18
+ overpaid: "OVERPAID",
19
+ };
20
+ const RAW_TERMINAL = new Set(["confirmed", "underpaid", "expired", "canceled"]);
21
+ export class StatusMapper {
22
+ static normalize(raw) {
23
+ return MAP[raw] ?? null;
24
+ }
25
+ static normalizeOrFail(raw) {
26
+ const status = MAP[raw];
27
+ if (!status) {
28
+ throw new Error(`unknown invoice status '${raw}'`);
29
+ }
30
+ return status;
31
+ }
32
+ static isTerminal(raw) {
33
+ return RAW_TERMINAL.has(raw);
34
+ }
35
+ static isPaid(raw) {
36
+ return raw === "confirmed";
37
+ }
38
+ }
39
+ export function isTerminalStatus(status) {
40
+ return status !== "PENDING" && status !== "CONFIRMING";
41
+ }
@@ -0,0 +1,3 @@
1
+ /** Single source of truth for the SDK version. */
2
+ export declare const VERSION = "0.1.0";
3
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,eAAO,MAAM,OAAO,UAAU,CAAC"}
@@ -0,0 +1,2 @@
1
+ /** Single source of truth for the SDK version. */
2
+ export const VERSION = "0.1.0";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Webhook signature verification - security critical (P0).
3
+ *
4
+ * Every delivery carries one header:
5
+ *
6
+ * X-ZKP-Signature: t=<unix seconds>,v1=<64-char lowercase hex>
7
+ *
8
+ * v1 = HMAC-SHA256(webhookSecret, "{t}.{rawBody}") where rawBody is the exact
9
+ * request body as received - never re-serialized JSON. Verification is strict
10
+ * (header format, ±tolerance window) and constant-time (timingSafeEqual).
11
+ */
12
+ import { WebhookEvent, type VerificationResult } from "../models/webhook.js";
13
+ export declare const DEFAULT_TOLERANCE_SECONDS = 300;
14
+ export interface VerifyOptions {
15
+ /** Override `now` (unix seconds) for tests. */
16
+ now?: number;
17
+ /** Signature secret override (config webhookSecret used when omitted). */
18
+ secret?: string;
19
+ /** Tolerance window in seconds, default 300. */
20
+ toleranceSeconds?: number;
21
+ }
22
+ export declare class WebhookVerifier {
23
+ private readonly secret;
24
+ readonly toleranceSeconds: number;
25
+ constructor(webhookSecret: string, toleranceSeconds?: number);
26
+ /** Verify and decode; throws WebhookVerificationError on any failure. */
27
+ verify(rawBody: string | Uint8Array, signatureHeader: string, options?: {
28
+ now?: number;
29
+ }): WebhookEvent;
30
+ /** Non-throwing variant returning a VerificationResult. */
31
+ check(rawBody: string | Uint8Array, signatureHeader: string, options?: {
32
+ now?: number;
33
+ }): VerificationResult;
34
+ /** Build a signature header for a body (tests and local replay tooling). */
35
+ sign(rawBody: string | Uint8Array, timestamp?: number): string;
36
+ private signPayload;
37
+ }
38
+ //# sourceMappingURL=verifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verifier.d.ts","sourceRoot":"","sources":["../../src/webhooks/verifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,EAAE,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAI7E,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAY7C,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;gBAEtB,aAAa,EAAE,MAAM,EAAE,gBAAgB,GAAE,MAAkC;IAWvF,yEAAyE;IACzE,MAAM,CACJ,OAAO,EAAE,MAAM,GAAG,UAAU,EAC5B,eAAe,EAAE,MAAM,EACvB,OAAO,GAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAO,GAC7B,YAAY;IAQf,2DAA2D;IAC3D,KAAK,CACH,OAAO,EAAE,MAAM,GAAG,UAAU,EAC5B,eAAe,EAAE,MAAM,EACvB,OAAO,GAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAO,GAC7B,kBAAkB;IA6CrB,4EAA4E;IAC5E,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM;IAK9D,OAAO,CAAC,WAAW;CAIpB"}
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Webhook signature verification - security critical (P0).
3
+ *
4
+ * Every delivery carries one header:
5
+ *
6
+ * X-ZKP-Signature: t=<unix seconds>,v1=<64-char lowercase hex>
7
+ *
8
+ * v1 = HMAC-SHA256(webhookSecret, "{t}.{rawBody}") where rawBody is the exact
9
+ * request body as received - never re-serialized JSON. Verification is strict
10
+ * (header format, ±tolerance window) and constant-time (timingSafeEqual).
11
+ */
12
+ import { createHmac, timingSafeEqual } from "node:crypto";
13
+ import { WebhookVerificationError as WVE } from "../errors.js";
14
+ import { WebhookEvent } from "../models/webhook.js";
15
+ const HEADER_RE = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/;
16
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
17
+ const MESSAGES = {
18
+ [WVE.MISSING_HEADER]: "X-ZKP-Signature header is missing",
19
+ [WVE.MALFORMED_HEADER]: "signature header is malformed (expected t=<int>,v1=<64 lowercase hex>)",
20
+ [WVE.STALE_TIMESTAMP]: "signature timestamp is outside the tolerance window (stale)",
21
+ [WVE.FUTURE_TIMESTAMP]: "signature timestamp is outside the tolerance window (future)",
22
+ [WVE.SIGNATURE_MISMATCH]: "signature does not match the raw body",
23
+ [WVE.MALFORMED_PAYLOAD]: "verified body is not a JSON object",
24
+ };
25
+ export class WebhookVerifier {
26
+ secret;
27
+ toleranceSeconds;
28
+ constructor(webhookSecret, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS) {
29
+ if (!webhookSecret) {
30
+ throw new Error("webhook secret must not be empty");
31
+ }
32
+ if (!Number.isInteger(toleranceSeconds) || toleranceSeconds < 1) {
33
+ throw new Error("tolerance must be at least 1 second");
34
+ }
35
+ this.secret = webhookSecret;
36
+ this.toleranceSeconds = toleranceSeconds;
37
+ }
38
+ /** Verify and decode; throws WebhookVerificationError on any failure. */
39
+ verify(rawBody, signatureHeader, options = {}) {
40
+ const result = this.check(rawBody, signatureHeader, options);
41
+ if (!result.event) {
42
+ throw new WVE(MESSAGES[result.reason ?? ""] ?? "verification failed", result.reason ?? "invalid");
43
+ }
44
+ return result.event;
45
+ }
46
+ /** Non-throwing variant returning a VerificationResult. */
47
+ check(rawBody, signatureHeader, options = {}) {
48
+ if (!signatureHeader) {
49
+ return { valid: false, reason: WVE.MISSING_HEADER, event: null };
50
+ }
51
+ const match = HEADER_RE.exec(signatureHeader);
52
+ if (!match) {
53
+ return { valid: false, reason: WVE.MALFORMED_HEADER, event: null };
54
+ }
55
+ const [, timestamp, signature] = match;
56
+ const now = options.now ?? Math.floor(Date.now() / 1000);
57
+ const skew = now - Number(timestamp);
58
+ if (skew > this.toleranceSeconds) {
59
+ return { valid: false, reason: WVE.STALE_TIMESTAMP, event: null };
60
+ }
61
+ if (-skew > this.toleranceSeconds) {
62
+ return { valid: false, reason: WVE.FUTURE_TIMESTAMP, event: null };
63
+ }
64
+ const expected = this.signPayload(timestamp, rawBody);
65
+ if (expected.length !== signature.length ||
66
+ !timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(signature, "utf8"))) {
67
+ return { valid: false, reason: WVE.SIGNATURE_MISMATCH, event: null };
68
+ }
69
+ let payload;
70
+ try {
71
+ const text = typeof rawBody === "string" ? rawBody : Buffer.from(rawBody).toString("utf8");
72
+ payload = JSON.parse(text);
73
+ }
74
+ catch {
75
+ return { valid: false, reason: WVE.MALFORMED_PAYLOAD, event: null };
76
+ }
77
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
78
+ return { valid: false, reason: WVE.MALFORMED_PAYLOAD, event: null };
79
+ }
80
+ try {
81
+ return { valid: true, reason: null, event: WebhookEvent.fromJson(payload) };
82
+ }
83
+ catch {
84
+ return { valid: false, reason: WVE.MALFORMED_PAYLOAD, event: null };
85
+ }
86
+ }
87
+ /** Build a signature header for a body (tests and local replay tooling). */
88
+ sign(rawBody, timestamp) {
89
+ const t = timestamp ?? Math.floor(Date.now() / 1000);
90
+ return `t=${t},v1=${this.signPayload(String(t), rawBody)}`;
91
+ }
92
+ signPayload(timestamp, rawBody) {
93
+ const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : Buffer.from(rawBody);
94
+ return createHmac("sha256", this.secret).update(timestamp + ".").update(body).digest("hex");
95
+ }
96
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "zkp-sdk-node",
3
+ "version": "0.1.0",
4
+ "description": "Official Node.js/TypeScript SDK for the ZeroKYC Pay crypto payment gateway (no-KYC checkout for digital services).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "keywords": [
9
+ "zerokyc",
10
+ "crypto",
11
+ "payments",
12
+ "gateway",
13
+ "usdt",
14
+ "bitcoin",
15
+ "monero",
16
+ "ton",
17
+ "webhooks",
18
+ "sdk"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "require": "./dist/cjs/index.js"
28
+ }
29
+ },
30
+ "main": "./dist/cjs/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "SECURITY.md"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
39
+ "typecheck": "tsc --noEmit",
40
+ "typecheck:examples": "tsc -p tsconfig.examples.json",
41
+ "lint": "eslint src tests examples",
42
+ "test": "vitest run",
43
+ "test:coverage": "vitest run --coverage --coverage.include='src/webhooks/**' --coverage.include='src/replay.ts' --coverage.thresholds.lines=90 --coverage.thresholds.functions=90 --coverage.thresholds.branches=85",
44
+ "prepack": "npm run build"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/zerokyc-payments/zkp-sdk-node.git"
49
+ },
50
+ "homepage": "https://zerokyc-payments.com/",
51
+ "bugs": {
52
+ "url": "https://github.com/zerokyc-payments/zkp-sdk-node/issues"
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/js": "^9.39.5",
56
+ "@types/express": "^5.0.6",
57
+ "@types/node": "^20.14.0",
58
+ "@vitest/coverage-v8": "^4.1.11",
59
+ "eslint": "^9.10.0",
60
+ "express": "^5.2.1",
61
+ "next": "^16.3.4",
62
+ "typescript": "^5.6.0",
63
+ "typescript-eslint": "^8.6.0",
64
+ "vitest": "^4.1.11"
65
+ }
66
+ }