@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.
@@ -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 };