@certysign/sdk 1.0.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/README.md +398 -0
- package/examples/certificate-flow.js +198 -0
- package/examples/dha-integration.js +208 -0
- package/examples/nhif-batch-sign.js +216 -0
- package/package.json +45 -0
- package/src/index.js +161 -0
- package/src/lib/CertificateResource.js +146 -0
- package/src/lib/EnvelopeResource.js +249 -0
- package/src/lib/HttpClient.js +198 -0
- package/src/lib/PkiResource.js +170 -0
- package/src/lib/SigningResource.js +192 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CertificateResource — X.509 certificate operations
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - issue() POST /sdk/v1/certificates/issue
|
|
6
|
+
* - verify() GET /sdk/v1/certificates/:sn/verify
|
|
7
|
+
* - status() GET /sdk/v1/certificates/:sn/status
|
|
8
|
+
*
|
|
9
|
+
* Per-document X.509 certificates are issued by CertySign Intermediate CA
|
|
10
|
+
* and include CRL Distribution Points and OCSP AIA extensions for
|
|
11
|
+
* programmatic revocation checking by relying parties (e.g. HIEs).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
class CertificateResource {
|
|
17
|
+
/** @param {import('../lib/HttpClient').HttpClient} http */
|
|
18
|
+
constructor(http) {
|
|
19
|
+
this._http = http;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Issue ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Issue a per-document X.509 certificate signed by CertySign Intermediate CA.
|
|
26
|
+
*
|
|
27
|
+
* The certificate is bound to the subscriber's organisation and the specific
|
|
28
|
+
* document. It includes:
|
|
29
|
+
* - CRL Distribution Point → https://pki.certysign.com/crl/intermediate.crl
|
|
30
|
+
* - OCSP AIA → https://pki.certysign.com/ocsp
|
|
31
|
+
* - CA Issuers AIA → https://pki.certysign.com/certs/intermediate.crt
|
|
32
|
+
*
|
|
33
|
+
* The private key is returned ONCE in the response (PEM). CertySign does not
|
|
34
|
+
* retain the private key after issuance.
|
|
35
|
+
*
|
|
36
|
+
* @param {IssueCertificateOptions} options
|
|
37
|
+
* @returns {Promise<IssueCertificateResult>}
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const cert = await client.certificates.issue({
|
|
41
|
+
* commonName: 'DHA Kenya Claims System',
|
|
42
|
+
* organisation: 'Department of Health Affairs',
|
|
43
|
+
* country: 'KE',
|
|
44
|
+
* locality: 'Nairobi',
|
|
45
|
+
* email: 'pki@dha.go.ke',
|
|
46
|
+
* validityDays: 365,
|
|
47
|
+
* metadata: { systemId: 'DHA-CLAIMS-PROD', claimId: 'CLM-2026-003421' }
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // cert.data.serialNumber — store this for future status checks
|
|
51
|
+
* // cert.data.pemCertificate — PEM cert to embed in signed PDF
|
|
52
|
+
* // cert.data.privateKey — PEM private key (shown ONCE)
|
|
53
|
+
* // cert.data.validUntil — ISO 8601 expiry
|
|
54
|
+
*/
|
|
55
|
+
issue(options) {
|
|
56
|
+
const {
|
|
57
|
+
commonName,
|
|
58
|
+
organisation,
|
|
59
|
+
country = 'KE',
|
|
60
|
+
state = 'Nairobi',
|
|
61
|
+
locality = 'Nairobi',
|
|
62
|
+
email,
|
|
63
|
+
validityDays = 365,
|
|
64
|
+
keySize = 2048,
|
|
65
|
+
metadata = {}
|
|
66
|
+
} = options || {};
|
|
67
|
+
|
|
68
|
+
if (!commonName) throw new Error('certificates.issue: commonName is required');
|
|
69
|
+
if (!organisation) throw new Error('certificates.issue: organisation is required');
|
|
70
|
+
|
|
71
|
+
return this._http.post('/sdk/v1/certificates/issue', {
|
|
72
|
+
data: { commonName, organisation, country, state, locality, email, validityDays, keySize, metadata }
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Verify ─────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verify a certificate's validity at a specific point in time.
|
|
80
|
+
*
|
|
81
|
+
* Checks:
|
|
82
|
+
* - Certificate chain back to CertySign Root CA
|
|
83
|
+
* - Revocation status (CRL + OCSP)
|
|
84
|
+
* - Validity period
|
|
85
|
+
* - Key usage constraints
|
|
86
|
+
*
|
|
87
|
+
* @param {string} serialNumber - Certificate serial number (hex)
|
|
88
|
+
* @param {Date|string} [atTime] - Point-in-time check (default: now)
|
|
89
|
+
* @returns {Promise<VerifyCertResult>}
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* // Verify if a cert was valid at the time the document was signed
|
|
93
|
+
* const result = await client.certificates.verify(
|
|
94
|
+
* 'A1B2C3D4E5F6...',
|
|
95
|
+
* new Date('2026-01-15T10:30:00Z')
|
|
96
|
+
* );
|
|
97
|
+
* if (result.data.valid) console.log('Certificate was valid at signing time');
|
|
98
|
+
*/
|
|
99
|
+
verify(serialNumber, atTime) {
|
|
100
|
+
if (!serialNumber) throw new Error('certificates.verify: serialNumber is required');
|
|
101
|
+
const params = atTime
|
|
102
|
+
? { atTime: (atTime instanceof Date ? atTime.toISOString() : atTime) }
|
|
103
|
+
: undefined;
|
|
104
|
+
return this._http.get(`/sdk/v1/certificates/${encodeURIComponent(serialNumber)}/verify`, { params });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Status ─────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get the current status of a certificate (good / revoked / expired / unknown).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} serialNumber
|
|
113
|
+
* @returns {Promise<CertStatusResult>}
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const { data } = await client.certificates.status('A1B2C3...');
|
|
117
|
+
* // data.status → 'good' | 'revoked' | 'expired' | 'unknown'
|
|
118
|
+
* // data.revocationDate → ISO 8601 if revoked
|
|
119
|
+
* // data.revocationReason → RFC 5280 reason string if revoked
|
|
120
|
+
*/
|
|
121
|
+
status(serialNumber) {
|
|
122
|
+
if (!serialNumber) throw new Error('certificates.status: serialNumber is required');
|
|
123
|
+
return this._http.get(`/sdk/v1/certificates/${encodeURIComponent(serialNumber)}/status`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @typedef {Object} IssueCertificateOptions
|
|
129
|
+
* @property {string} commonName - Subject CN (e.g. 'DHA Kenya Claims System')
|
|
130
|
+
* @property {string} organisation - Subject O (e.g. 'Department of Health Affairs')
|
|
131
|
+
* @property {string} [country] - ISO 3166 alpha-2 (default: 'KE')
|
|
132
|
+
* @property {string} [state] - State/province (default: 'Nairobi')
|
|
133
|
+
* @property {string} [locality] - City (default: 'Nairobi')
|
|
134
|
+
* @property {string} [email] - Subscriber email address
|
|
135
|
+
* @property {number} [validityDays] - Cert validity in days (default: 365)
|
|
136
|
+
* @property {number} [keySize] - RSA key size in bits (default: 2048)
|
|
137
|
+
* @property {Object} [metadata] - Custom metadata stored with the cert record
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {Object} IssueCertificateResult
|
|
142
|
+
* @property {boolean} success
|
|
143
|
+
* @property {{ serialNumber, fingerprint, validFrom, validUntil, pemCertificate, pemChain, privateKey }} data
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
module.exports = { CertificateResource };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview EnvelopeResource — full envelope lifecycle management
|
|
3
|
+
*
|
|
4
|
+
* Covers the complete envelope CRUD plus document management:
|
|
5
|
+
* - create() POST /sdk/v1/envelopes
|
|
6
|
+
* - get() GET /sdk/v1/envelopes/:id
|
|
7
|
+
* - list() GET /sdk/v1/envelopes
|
|
8
|
+
* - uploadDocuments() POST /sdk/v1/envelopes/:id/documents
|
|
9
|
+
* - send() POST /sdk/v1/envelopes/:id/send
|
|
10
|
+
* - sign() POST /sdk/v1/envelopes/:id/sign
|
|
11
|
+
* - getDocument() GET /sdk/v1/envelopes/:id/documents/:docId
|
|
12
|
+
* - getAuditTrail() GET /sdk/v1/envelopes/:id/audit
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const FormData = require('form-data');
|
|
18
|
+
|
|
19
|
+
class EnvelopeResource {
|
|
20
|
+
/** @param {import('../lib/HttpClient').HttpClient} http */
|
|
21
|
+
constructor(http) {
|
|
22
|
+
this._http = http;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Create ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a new signing envelope.
|
|
29
|
+
*
|
|
30
|
+
* Envelopes are containers for documents and signature requests.
|
|
31
|
+
* After creation, upload documents (uploadDocuments), then send
|
|
32
|
+
* the envelope (send) to make it ready for signing.
|
|
33
|
+
*
|
|
34
|
+
* @param {CreateEnvelopeOptions} options
|
|
35
|
+
* @returns {Promise<EnvelopeResult>}
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const { data } = await client.envelopes.create({
|
|
39
|
+
* title: 'NHIF Reimbursement Claim Q1-2026',
|
|
40
|
+
* signers: [{
|
|
41
|
+
* name: 'Dr. Grace Mwangi',
|
|
42
|
+
* email: 'g.mwangi@nhif.or.ke',
|
|
43
|
+
* role: 'primary'
|
|
44
|
+
* }],
|
|
45
|
+
* metadata: { claimPeriod: 'Q1-2026', department: 'Reimbursements' }
|
|
46
|
+
* });
|
|
47
|
+
* const envelopeId = data.envelope._id;
|
|
48
|
+
*/
|
|
49
|
+
create(options = {}) {
|
|
50
|
+
const { title, signers = [], message, metadata = {} } = options;
|
|
51
|
+
if (!title) throw new Error('envelopes.create: title is required');
|
|
52
|
+
return this._http.post('/sdk/v1/envelopes', {
|
|
53
|
+
data: { title, signers, message, metadata }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Get ────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get a single envelope by ID with full status, signers, and document list.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} envelopeId
|
|
63
|
+
* @returns {Promise<EnvelopeResult>}
|
|
64
|
+
*/
|
|
65
|
+
get(envelopeId) {
|
|
66
|
+
if (!envelopeId) throw new Error('envelopes.get: envelopeId is required');
|
|
67
|
+
return this._http.get(`/sdk/v1/envelopes/${encodeURIComponent(envelopeId)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── List ───────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* List envelopes for the authenticated tenant.
|
|
74
|
+
*
|
|
75
|
+
* @param {ListEnvelopesOptions} [options]
|
|
76
|
+
* @returns {Promise<ListEnvelopesResult>}
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* const { data } = await client.envelopes.list({ status: 'completed', limit: 20 });
|
|
80
|
+
* data.envelopes.forEach(env => console.log(env._id, env.status));
|
|
81
|
+
*/
|
|
82
|
+
list(options = {}) {
|
|
83
|
+
const { status, page = 1, limit = 20, search } = options;
|
|
84
|
+
return this._http.get('/sdk/v1/envelopes', {
|
|
85
|
+
params: { status, page, limit, search }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Upload Documents ───────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upload one or more PDF documents to an existing envelope.
|
|
93
|
+
*
|
|
94
|
+
* Must be called after create() and before send().
|
|
95
|
+
*
|
|
96
|
+
* @param {string} envelopeId
|
|
97
|
+
* @param {DocumentUpload[]} documents
|
|
98
|
+
* @returns {Promise<UploadResult>}
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* await client.envelopes.uploadDocuments('env_abc123', [
|
|
102
|
+
* { data: fs.readFileSync('./report.pdf'), filename: 'Q1-report.pdf' }
|
|
103
|
+
* ]);
|
|
104
|
+
*/
|
|
105
|
+
uploadDocuments(envelopeId, documents) {
|
|
106
|
+
if (!envelopeId) throw new Error('envelopes.uploadDocuments: envelopeId is required');
|
|
107
|
+
if (!documents?.length) throw new Error('envelopes.uploadDocuments: documents array is required');
|
|
108
|
+
|
|
109
|
+
const form = new FormData();
|
|
110
|
+
for (const doc of documents) {
|
|
111
|
+
const buf = doc.data;
|
|
112
|
+
const name = doc.filename || 'document.pdf';
|
|
113
|
+
if (Buffer.isBuffer(buf)) {
|
|
114
|
+
form.append('documents', buf, { filename: name, contentType: 'application/pdf' });
|
|
115
|
+
} else if (buf && typeof buf.pipe === 'function') {
|
|
116
|
+
form.append('documents', buf, { filename: name, contentType: 'application/pdf' });
|
|
117
|
+
} else {
|
|
118
|
+
throw new Error(`Document "${name}" must be a Buffer or ReadStream`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return this._http.post(`/sdk/v1/envelopes/${encodeURIComponent(envelopeId)}/documents`, {
|
|
123
|
+
formData: form
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Send ───────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Transition an envelope to 'sent' status, making it ready for signing.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} envelopeId
|
|
133
|
+
* @returns {Promise<EnvelopeResult>}
|
|
134
|
+
*/
|
|
135
|
+
send(envelopeId) {
|
|
136
|
+
if (!envelopeId) throw new Error('envelopes.send: envelopeId is required');
|
|
137
|
+
return this._http.post(`/sdk/v1/envelopes/${encodeURIComponent(envelopeId)}/send`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Sign ───────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply a cryptographic signature to all documents in the envelope.
|
|
144
|
+
*
|
|
145
|
+
* This is the core signing step. For SDK requests, interactive 2FA is
|
|
146
|
+
* bypassed — the API key alone authorises the signature.
|
|
147
|
+
*
|
|
148
|
+
* The resulting signatures are PAdES-LTV compliant (PDF Advanced Electronic
|
|
149
|
+
* Signatures with Long-Term Validation).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} envelopeId
|
|
152
|
+
* @param {SignEnvelopeOptions} [options]
|
|
153
|
+
* @returns {Promise<SignResult>}
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const result = await client.envelopes.sign('env_abc123', {
|
|
157
|
+
* reason: 'Claims approval — NHIF Kenya',
|
|
158
|
+
* location: 'Nairobi, Kenya'
|
|
159
|
+
* });
|
|
160
|
+
* console.log(result.data.signatures[0].certificate.serialNumber);
|
|
161
|
+
*/
|
|
162
|
+
sign(envelopeId, options = {}) {
|
|
163
|
+
if (!envelopeId) throw new Error('envelopes.sign: envelopeId is required');
|
|
164
|
+
const { reason = 'Digital signature', location = 'Nairobi, Kenya' } = options;
|
|
165
|
+
return this._http.post(`/sdk/v1/envelopes/${encodeURIComponent(envelopeId)}/sign`, {
|
|
166
|
+
data: { reason, location }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Download Document ──────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Download a signed document as a Buffer.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} envelopeId
|
|
176
|
+
* @param {string} documentId
|
|
177
|
+
* @returns {Promise<Buffer>} Raw PDF bytes
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const pdfBuffer = await client.envelopes.getDocument('env_abc123', 'doc_xyz789');
|
|
181
|
+
* fs.writeFileSync('./signed-claim.pdf', pdfBuffer);
|
|
182
|
+
*/
|
|
183
|
+
async getDocument(envelopeId, documentId) {
|
|
184
|
+
if (!envelopeId) throw new Error('envelopes.getDocument: envelopeId is required');
|
|
185
|
+
if (!documentId) throw new Error('envelopes.getDocument: documentId is required');
|
|
186
|
+
|
|
187
|
+
const response = await this._http.get(
|
|
188
|
+
`/sdk/v1/envelopes/${encodeURIComponent(envelopeId)}/documents/${encodeURIComponent(documentId)}`,
|
|
189
|
+
{ responseType: 'arraybuffer' }
|
|
190
|
+
);
|
|
191
|
+
return Buffer.isBuffer(response) ? response : Buffer.from(response);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Audit Trail ────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Retrieve the cryptographically verifiable audit trail for an envelope.
|
|
198
|
+
*
|
|
199
|
+
* The audit chain is a Merkle-linked log of all envelope events:
|
|
200
|
+
* - created, document_uploaded, sent, signed, certificate_issued, completed
|
|
201
|
+
*
|
|
202
|
+
* Each entry includes a SHA-256 event hash and chain hash linking it to
|
|
203
|
+
* the previous entry, making the log tamper-evident.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} envelopeId
|
|
206
|
+
* @returns {Promise<AuditTrailResult>}
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* const { data } = await client.envelopes.getAuditTrail('env_abc123');
|
|
210
|
+
* data.auditTrail.forEach(event => {
|
|
211
|
+
* console.log(event.timestamp, event.action, event.eventHash);
|
|
212
|
+
* });
|
|
213
|
+
* if (data.chainIntegrity.valid) console.log('Audit chain intact');
|
|
214
|
+
*/
|
|
215
|
+
getAuditTrail(envelopeId) {
|
|
216
|
+
if (!envelopeId) throw new Error('envelopes.getAuditTrail: envelopeId is required');
|
|
217
|
+
return this._http.get(`/sdk/v1/envelopes/${encodeURIComponent(envelopeId)}/audit`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @typedef {Object} CreateEnvelopeOptions
|
|
223
|
+
* @property {string} title - Envelope title
|
|
224
|
+
* @property {{ name: string, email?: string, role?: string }[]} [signers]
|
|
225
|
+
* @property {string} [message] - Message to signers
|
|
226
|
+
* @property {Object} [metadata]
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {Object} DocumentUpload
|
|
231
|
+
* @property {Buffer|import('fs').ReadStream} data
|
|
232
|
+
* @property {string} [filename]
|
|
233
|
+
*/
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @typedef {Object} SignEnvelopeOptions
|
|
237
|
+
* @property {string} [reason]
|
|
238
|
+
* @property {string} [location]
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @typedef {Object} ListEnvelopesOptions
|
|
243
|
+
* @property {'draft'|'sent'|'in_progress'|'completed'|'cancelled'} [status]
|
|
244
|
+
* @property {number} [page]
|
|
245
|
+
* @property {number} [limit]
|
|
246
|
+
* @property {string} [search]
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
module.exports = { EnvelopeResource };
|
|
@@ -0,0 +1,198 @@
|
|
|
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://api.certysign.com';
|
|
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 };
|