@certysign/sdk 2.4.0 → 2.5.1

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.
@@ -105,13 +105,20 @@ class HashSigningResource {
105
105
  location = 'Nairobi, Kenya',
106
106
  signerName,
107
107
  signerEmail,
108
- metadata = {}
108
+ metadata = {},
109
+ // Second factor. Single-document signing challenges the named signer: the
110
+ // first call returns 2FA_REQUIRED with a verificationNonce and sends them a
111
+ // code; re-send the same request carrying both. Batch signing is exempt.
112
+ twoFactorCode,
113
+ twoFactorMethod,
114
+ verificationNonce
109
115
  } = options;
110
116
 
111
117
  if (!documentHash) throw new Error('signHash: documentHash is required');
112
118
 
113
119
  return this._http.post('/sdk/v1/sign/hash', {
114
- data: { documentHash, hashAlgorithm, fileName, reason, location, signerName, signerEmail, metadata }
120
+ data: { documentHash, hashAlgorithm, fileName, reason, location, signerName, signerEmail, metadata,
121
+ twoFactorCode, twoFactorMethod, verificationNonce }
115
122
  });
116
123
  }
117
124
 
@@ -1,198 +1,219 @@
1
- /**
2
- * @fileoverview CertySign SDK — HTTP client layer
3
- *
4
- * Wraps axios with:
5
- * - Automatic API key header injection
6
- * - Idempotency key support
7
- * - Retry with exponential back-off (network errors, 429, 503)
8
- * - Consistent error normalisation
9
- */
10
-
11
- 'use strict';
12
-
13
- const axios = require('axios');
14
- const FormData = require('form-data');
15
- const crypto = require('crypto');
16
-
17
- /** Default base URL — override per environment */
18
- const DEFAULT_BASE_URL = 'https://core.certysign.io';
19
-
20
- /** Statuses that should trigger a retry */
21
- const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
22
-
23
- /**
24
- * @typedef {Object} HttpClientOptions
25
- * @property {string} publicKey - API public key (cs_pk_...)
26
- * @property {string} secretKey - API secret key (cs_sk_...)
27
- * @property {string} [baseUrl] - Override default gateway URL
28
- * @property {number} [timeout] - Request timeout ms (default 30000)
29
- * @property {number} [retries] - Max retries on transient errors (default 3)
30
- * @property {boolean} [debug] - Log requests/responses
31
- */
32
-
33
- class HttpClient {
34
- /**
35
- * @param {HttpClientOptions} options
36
- */
37
- constructor(options) {
38
- if (!options.publicKey) throw new Error('CertySign SDK: publicKey is required');
39
- if (!options.secretKey) throw new Error('CertySign SDK: secretKey is required');
40
-
41
- this.publicKey = options.publicKey;
42
- this.secretKey = options.secretKey;
43
- this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
44
- this.timeout = options.timeout ?? 30_000;
45
- this.retries = options.retries ?? 3;
46
- this.debug = options.debug ?? false;
47
-
48
- this._client = axios.create({
49
- baseURL: this.baseUrl,
50
- timeout: this.timeout,
51
- headers: {
52
- 'Content-Type': 'application/json',
53
- 'Accept': 'application/json',
54
- 'User-Agent': `CertySign-SDK-Node/1.0.0 Node/${process.version}`
55
- }
56
- });
57
- }
58
-
59
- // ── Core request execution ───────────────────────────────────────────────
60
-
61
- /**
62
- * Execute an HTTP request with retry logic.
63
- *
64
- * @param {string} method - HTTP verb (GET, POST, PUT, DELETE, PATCH)
65
- * @param {string} path - Path relative to baseUrl
66
- * @param {Object} [options]
67
- * @param {Object} [options.data] - JSON body
68
- * @param {Object} [options.params] - Query params
69
- * @param {Object} [options.headers] - Extra headers
70
- * @param {FormData} [options.formData] - Multipart form (takes precedence over data)
71
- * @param {string} [options.idempotencyKey] - Idempotency key (auto-generated if omitted)
72
- * @param {string} [options.accept] - Override Accept header
73
- * @param {'json'|'arraybuffer'|'text'} [options.responseType] - Expected response type
74
- * @returns {Promise<any>}
75
- */
76
- async request(method, path, options = {}) {
77
- const {
78
- data,
79
- params,
80
- headers: extraHeaders = {},
81
- formData,
82
- idempotencyKey,
83
- accept,
84
- responseType = 'json'
85
- } = options;
86
-
87
- const idempKey = idempotencyKey || `sdk_${crypto.randomBytes(16).toString('hex')}`;
88
-
89
- const headers = {
90
- 'X-API-Key-Id': this.publicKey,
91
- 'X-API-Key-Secret': this.secretKey,
92
- 'X-Idempotency-Key': idempKey,
93
- ...extraHeaders
94
- };
95
-
96
- if (accept) headers['Accept'] = accept;
97
-
98
- const config = {
99
- method,
100
- url: path,
101
- headers,
102
- params,
103
- responseType
104
- };
105
-
106
- if (formData) {
107
- config.data = formData;
108
- config.headers = { ...config.headers, ...formData.getHeaders() };
109
- delete config.headers['Content-Type']; // Let axios set multipart boundary
110
- } else if (data !== undefined) {
111
- config.data = data;
112
- }
113
-
114
- let attempt = 0;
115
-
116
- while (true) { // eslint-disable-line no-constant-condition
117
- attempt++;
118
-
119
- if (this.debug) {
120
- console.debug(`[CertySign SDK] ${method.toUpperCase()} ${path} (attempt ${attempt})`);
121
- }
122
-
123
- try {
124
- const response = await this._client.request(config);
125
- return response.data;
126
-
127
- } catch (err) {
128
- const status = err.response?.status;
129
- const canRetry = RETRYABLE_STATUS.has(status) || !status; // network error
130
-
131
- if (canRetry && attempt <= this.retries) {
132
- const delay = Math.min(200 * 2 ** (attempt - 1), 4000);
133
- if (this.debug) console.debug(`[CertySign SDK] Retrying in ${delay}ms (status=${status})`);
134
- await _sleep(delay);
135
- continue;
136
- }
137
-
138
- throw _normaliseError(err);
139
- }
140
- }
141
- }
142
-
143
- get(path, options) { return this.request('GET', path, options); }
144
- post(path, options) { return this.request('POST', path, options); }
145
- put(path, options) { return this.request('PUT', path, options); }
146
- patch(path, options) { return this.request('PATCH', path, options); }
147
- delete(path, options) { return this.request('DELETE', path, options); }
148
- }
149
-
150
- // ── Helpers ──────────────────────────────────────────────────────────────────
151
-
152
- function _sleep(ms) {
153
- return new Promise(resolve => setTimeout(resolve, ms));
154
- }
155
-
156
- /**
157
- * Normalise an axios error into a plain CertySignError.
158
- * @param {Error} err
159
- * @returns {CertySignError}
160
- */
161
- function _normaliseError(err) {
162
- if (err.response) {
163
- const body = err.response.data;
164
- const e = new CertySignError(
165
- body?.message || `HTTP ${err.response.status}`,
166
- err.response.status,
167
- body?.code
168
- );
169
- e.details = body;
170
- e.headers = err.response.headers;
171
- return e;
172
- }
173
-
174
- if (err.request) {
175
- return new CertySignError(`No response received: ${err.message}`, 0, 'NETWORK_ERROR');
176
- }
177
-
178
- return new CertySignError(err.message, 0, 'SDK_INTERNAL_ERROR');
179
- }
180
-
181
- /**
182
- * Error class thrown by all SDK methods on failure.
183
- *
184
- * @property {number} statusCode - HTTP status code (0 for network errors)
185
- * @property {string} code - Machine-readable error code (e.g. 'INVALID_API_KEY')
186
- * @property {Object} details - Full error body from the API
187
- */
188
- class CertySignError extends Error {
189
- constructor(message, statusCode, code) {
190
- super(message);
191
- this.name = 'CertySignError';
192
- this.statusCode = statusCode;
193
- this.code = code;
194
- this.details = null;
195
- }
196
- }
197
-
198
- module.exports = { HttpClient, CertySignError };
1
+ /**
2
+ * @fileoverview CertySign SDK — HTTP client layer
3
+ *
4
+ * Wraps axios with:
5
+ * - Automatic API key header injection
6
+ * - Idempotency key support
7
+ * - Retry with exponential back-off (network errors, 429, 503)
8
+ * - Consistent error normalisation
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const axios = require('axios');
14
+
15
+ // Reported on every request, so a log can tell which SDK version a caller is on.
16
+ // Read from package.json because a hardcoded literal drifts: this said 1.0.0 for
17
+ // the whole 2.x line. Guarded because a bundler may not ship package.json.
18
+ const SDK_VERSION = (() => {
19
+ try { return require('../../package.json').version; } catch { return 'unknown'; }
20
+ })();
21
+ const SDK_USER_AGENT = `CertySign-SDK-Node/${SDK_VERSION} Node/${process.version}`;
22
+ const FormData = require('form-data');
23
+ const crypto = require('crypto');
24
+
25
+ /** Default base URL — override per environment */
26
+ const DEFAULT_BASE_URL = 'https://core.certysign.io';
27
+
28
+ /** Statuses that should trigger a retry */
29
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
30
+
31
+ /**
32
+ * @typedef {Object} HttpClientOptions
33
+ * @property {string} publicKey - API public key (cs_pk_...)
34
+ * @property {string} secretKey - API secret key (cs_sk_...)
35
+ * @property {string} [baseUrl] - Override default gateway URL
36
+ * @property {number} [timeout] - Request timeout ms (default 30000)
37
+ * @property {number} [retries] - Max retries on transient errors (default 3)
38
+ * @property {boolean} [debug] - Log requests/responses
39
+ */
40
+
41
+ class HttpClient {
42
+ /**
43
+ * @param {HttpClientOptions} options
44
+ */
45
+ constructor(options) {
46
+ if (!options.publicKey) throw new Error('CertySign SDK: publicKey is required');
47
+ if (!options.secretKey) throw new Error('CertySign SDK: secretKey is required');
48
+
49
+ this.publicKey = options.publicKey;
50
+ this.secretKey = options.secretKey;
51
+ this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
52
+ this.timeout = options.timeout ?? 30_000;
53
+ this.retries = options.retries ?? 3;
54
+ this.debug = options.debug ?? false;
55
+
56
+ this._client = axios.create({
57
+ baseURL: this.baseUrl,
58
+ timeout: this.timeout,
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'Accept': 'application/json',
62
+ 'User-Agent': SDK_USER_AGENT
63
+ }
64
+ });
65
+ }
66
+
67
+ // ── Core request execution ───────────────────────────────────────────────
68
+
69
+ /**
70
+ * Execute an HTTP request with retry logic.
71
+ *
72
+ * @param {string} method - HTTP verb (GET, POST, PUT, DELETE, PATCH)
73
+ * @param {string} path - Path relative to baseUrl
74
+ * @param {Object} [options]
75
+ * @param {Object} [options.data] - JSON body
76
+ * @param {Object} [options.params] - Query params
77
+ * @param {Object} [options.headers] - Extra headers
78
+ * @param {FormData} [options.formData] - Multipart form (takes precedence over data)
79
+ * @param {string} [options.idempotencyKey] - Idempotency key (auto-generated if omitted)
80
+ * @param {string} [options.accept] - Override Accept header
81
+ * @param {'json'|'arraybuffer'|'text'} [options.responseType] - Expected response type
82
+ * @returns {Promise<any>}
83
+ */
84
+ async request(method, path, options = {}) {
85
+ const {
86
+ data,
87
+ params,
88
+ headers: extraHeaders = {},
89
+ formData,
90
+ idempotencyKey,
91
+ accept,
92
+ responseType = 'json'
93
+ } = options;
94
+
95
+ const idempKey = idempotencyKey || `sdk_${crypto.randomBytes(16).toString('hex')}`;
96
+
97
+ const headers = {
98
+ 'X-API-Key-Id': this.publicKey,
99
+ 'X-API-Key-Secret': this.secretKey,
100
+ 'X-Idempotency-Key': idempKey,
101
+ ...extraHeaders
102
+ };
103
+
104
+ if (accept) headers['Accept'] = accept;
105
+
106
+ const config = {
107
+ method,
108
+ url: path,
109
+ headers,
110
+ params,
111
+ responseType
112
+ };
113
+
114
+ if (formData) {
115
+ config.data = formData;
116
+ config.headers = { ...config.headers, ...formData.getHeaders() };
117
+ delete config.headers['Content-Type']; // Let axios set multipart boundary
118
+ } else if (data !== undefined) {
119
+ config.data = data;
120
+ }
121
+
122
+ let attempt = 0;
123
+
124
+ while (true) { // eslint-disable-line no-constant-condition
125
+ attempt++;
126
+
127
+ if (this.debug) {
128
+ console.debug(`[CertySign SDK] ${method.toUpperCase()} ${path} (attempt ${attempt})`);
129
+ }
130
+
131
+ try {
132
+ const response = await this._client.request(config);
133
+ return response.data;
134
+
135
+ } catch (err) {
136
+ const status = err.response?.status;
137
+ const code = err.response?.data?.code;
138
+
139
+ // A 409 usually means "your request conflicts with reality" and retrying is
140
+ // pointless. IDEMPOTENCY_IN_PROGRESS is the exception: an identical request
141
+ // with this same key is mid-flight, and the right move is to wait for it
142
+ // rather than fail. Retrying is safe precisely BECAUSE the key is the same —
143
+ // the server will hand back that request's result, not run a second one.
144
+ const waitingOnTwin = status === 409 && code === 'IDEMPOTENCY_IN_PROGRESS';
145
+
146
+ const canRetry = RETRYABLE_STATUS.has(status) || waitingOnTwin || !status;
147
+
148
+ if (canRetry && attempt <= this.retries) {
149
+ // Honour Retry-After when the server states one, rather than guessing.
150
+ const retryAfter = Number(err.response?.headers?.['retry-after']);
151
+ const delay = Number.isFinite(retryAfter) && retryAfter > 0
152
+ ? Math.min(retryAfter * 1000, 10000)
153
+ : Math.min(200 * 2 ** (attempt - 1), 4000);
154
+ if (this.debug) console.debug(`[CertySign SDK] Retrying in ${delay}ms (status=${status})`);
155
+ await _sleep(delay);
156
+ continue;
157
+ }
158
+
159
+ throw _normaliseError(err);
160
+ }
161
+ }
162
+ }
163
+
164
+ get(path, options) { return this.request('GET', path, options); }
165
+ post(path, options) { return this.request('POST', path, options); }
166
+ put(path, options) { return this.request('PUT', path, options); }
167
+ patch(path, options) { return this.request('PATCH', path, options); }
168
+ delete(path, options) { return this.request('DELETE', path, options); }
169
+ }
170
+
171
+ // ── Helpers ──────────────────────────────────────────────────────────────────
172
+
173
+ function _sleep(ms) {
174
+ return new Promise(resolve => setTimeout(resolve, ms));
175
+ }
176
+
177
+ /**
178
+ * Normalise an axios error into a plain CertySignError.
179
+ * @param {Error} err
180
+ * @returns {CertySignError}
181
+ */
182
+ function _normaliseError(err) {
183
+ if (err.response) {
184
+ const body = err.response.data;
185
+ const e = new CertySignError(
186
+ body?.message || `HTTP ${err.response.status}`,
187
+ err.response.status,
188
+ body?.code
189
+ );
190
+ e.details = body;
191
+ e.headers = err.response.headers;
192
+ return e;
193
+ }
194
+
195
+ if (err.request) {
196
+ return new CertySignError(`No response received: ${err.message}`, 0, 'NETWORK_ERROR');
197
+ }
198
+
199
+ return new CertySignError(err.message, 0, 'SDK_INTERNAL_ERROR');
200
+ }
201
+
202
+ /**
203
+ * Error class thrown by all SDK methods on failure.
204
+ *
205
+ * @property {number} statusCode - HTTP status code (0 for network errors)
206
+ * @property {string} code - Machine-readable error code (e.g. 'INVALID_API_KEY')
207
+ * @property {Object} details - Full error body from the API
208
+ */
209
+ class CertySignError extends Error {
210
+ constructor(message, statusCode, code) {
211
+ super(message);
212
+ this.name = 'CertySignError';
213
+ this.statusCode = statusCode;
214
+ this.code = code;
215
+ this.details = null;
216
+ }
217
+ }
218
+
219
+ module.exports = { HttpClient, CertySignError };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @fileoverview IdentityResource — Identity-as-a-Service
3
+ *
4
+ * Verify a national ID against the issuing authority (IPRS for Kenya) and get
5
+ * the biographic record back — the same pipeline CertySign uses to onboard its
6
+ * own signers.
7
+ *
8
+ * Registry-first: an ID already verified anywhere on the CertySign network is
9
+ * served from our registry (`source: 'registry'`) with no authority call.
10
+ *
11
+ * Requires the `identity:verify` scope on your API key. That scope is NOT granted
12
+ * by default — enable it under Settings → Security → SDK API Keys → Permissions.
13
+ *
14
+ * PRIVACY: this endpoint processes another person's personal data. You must
15
+ * attest that the data subject consented; the attestation is recorded in your
16
+ * tenant's audit trail on every call. The full ID number is never echoed back —
17
+ * store the returned `identityReference` instead of the raw number.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ /** Countries / ID types the service can reach today. */
23
+ const SUPPORTED = Object.freeze({ KE: Object.freeze(['NATIONAL_ID']) });
24
+
25
+ class IdentityResource {
26
+ /** @param {import('./HttpClient').HttpClient} http */
27
+ constructor(http) {
28
+ this._http = http;
29
+ }
30
+
31
+ /**
32
+ * Verify a national ID against the issuing authority.
33
+ *
34
+ * @param {Object} params
35
+ * @param {string} params.country - ISO-3166 alpha-2, e.g. 'KE'
36
+ * @param {string} params.idNumber - The subject's national ID number
37
+ * @param {string} [params.idType] - Defaults to 'NATIONAL_ID'
38
+ * @param {Object} params.consent - Lawful-basis attestation (REQUIRED)
39
+ * @param {boolean} params.consent.obtained - Must be true
40
+ * @param {string} [params.consent.reference] - Your own record id for the consent
41
+ * @param {string} [params.consent.purpose] - Why you're verifying, e.g. 'KYC onboarding'
42
+ * @param {string} [params.firstName] - Optional cross-check hint
43
+ * @param {string} [params.lastName] - Optional cross-check hint
44
+ * @param {string} [params.dob] - Optional cross-check hint (YYYY-MM-DD)
45
+ * @param {boolean} [params.forceRefresh] - Skip the registry/negative cache and
46
+ * go straight to the authority
47
+ * @param {string} [params.idempotencyKey] - Sent as X-Idempotency-Key
48
+ *
49
+ * @returns {Promise<Object>} `{ success, data }` where data contains
50
+ * `verified`, `decision`, `identityReference`, `idLast4`, `person`,
51
+ * `source` ('registry' | 'authority'), `cached`, `resultCode`, `checkedAt`.
52
+ *
53
+ * @throws {CertySignError} 403 CONSENT_REQUIRED, 402 INSUFFICIENT_BALANCE,
54
+ * 422 COUNTRY_NOT_SUPPORTED / ID_TYPE_NOT_SUPPORTED, 502 VERIFICATION_UNAVAILABLE
55
+ *
56
+ * @example
57
+ * const { data } = await client.identity.verify({
58
+ * country: 'KE',
59
+ * idNumber: '12345678',
60
+ * consent: { obtained: true, reference: 'loan-8821', purpose: 'KYC onboarding' }
61
+ * });
62
+ *
63
+ * if (data.verified) {
64
+ * console.log('Verified:', data.person.fullName, data.person.dob);
65
+ * // Store this instead of the raw ID number:
66
+ * await db.users.update(userId, { identityRef: data.identityReference });
67
+ * } else {
68
+ * console.log('Not verified:', data.decision, data.resultText);
69
+ * }
70
+ */
71
+ verify(params = {}) {
72
+ const {
73
+ country, idNumber, idType = 'NATIONAL_ID', consent,
74
+ firstName, lastName, middleName, dob, phoneNumber,
75
+ forceRefresh, idempotencyKey,
76
+ } = params;
77
+
78
+ // Fail fast on the client so an obviously-malformed call never costs a
79
+ // round-trip (or a charge).
80
+ if (!country) throw new Error('identity.verify: country is required (e.g. "KE")');
81
+ if (!idNumber) throw new Error('identity.verify: idNumber is required');
82
+ if (!consent || consent.obtained !== true) {
83
+ throw new Error(
84
+ 'identity.verify: consent.obtained must be true — you must attest that the data ' +
85
+ 'subject consented to this verification. Pass consent: { obtained: true, reference?, purpose? }'
86
+ );
87
+ }
88
+
89
+ const ctry = String(country).toUpperCase().trim();
90
+ const type = String(idType).toUpperCase().trim();
91
+ // Warn, do not throw. Which ID types exist for a country is the SERVER's
92
+ // vocabulary, and it grows. Refusing an unknown type here means that the day the
93
+ // API starts accepting one, every installed copy of this SDK rejects it before
94
+ // the request leaves the process — and the only fix is a republish. A warning
95
+ // still catches a typo during development while leaving the server as the
96
+ // authority on what it accepts.
97
+ if (SUPPORTED[ctry] && !SUPPORTED[ctry].includes(type)) {
98
+ console.warn(
99
+ `[certysign] identity.verify: idType '${type}' was not known for ${ctry} when this ` +
100
+ `SDK version was published (known: ${SUPPORTED[ctry].join(', ')}). Sending it anyway — ` +
101
+ `the API decides. Upgrade the SDK to silence this.`
102
+ );
103
+ }
104
+
105
+ const body = {
106
+ country: ctry,
107
+ idType: type,
108
+ idNumber: String(idNumber).trim(),
109
+ consent: {
110
+ obtained: true,
111
+ ...(consent.reference ? { reference: consent.reference } : {}),
112
+ ...(consent.purpose ? { purpose: consent.purpose } : {}),
113
+ },
114
+ };
115
+ if (firstName) body.firstName = firstName;
116
+ if (lastName) body.lastName = lastName;
117
+ if (middleName) body.middleName = middleName;
118
+ if (dob) body.dob = dob;
119
+ if (phoneNumber) body.phoneNumber = phoneNumber;
120
+ if (forceRefresh === true) body.forceRefresh = true;
121
+
122
+ return this._http.post('/sdk/v1/identity/verify', {
123
+ data: body,
124
+ ...(idempotencyKey ? { idempotencyKey } : {}),
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Convenience wrapper: resolve to `true` / `false` instead of the full record.
130
+ * Any API error still throws — a thrown error means "we could not check",
131
+ * which is NOT the same as "not verified", so don't collapse the two.
132
+ *
133
+ * @param {Object} params - Same shape as {@link IdentityResource#verify}
134
+ * @returns {Promise<boolean>}
135
+ *
136
+ * @example
137
+ * if (await client.identity.isVerified({
138
+ * country: 'KE', idNumber: '12345678',
139
+ * consent: { obtained: true, purpose: 'KYC onboarding' }
140
+ * })) { … }
141
+ */
142
+ async isVerified(params = {}) {
143
+ const res = await this.verify(params);
144
+ return res?.data?.verified === true;
145
+ }
146
+
147
+ /** Countries and ID types this SDK build knows the service supports. */
148
+ static get supported() {
149
+ return SUPPORTED;
150
+ }
151
+ }
152
+
153
+ module.exports = { IdentityResource };