@certysign/sdk 2.5.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.
- package/README.md +279 -1
- package/package.json +3 -2
- package/src/index.js +144 -0
- package/src/lib/HttpClient.js +9 -1
- package/src/lib/IdentityResource.js +11 -1
- package/src/lib/WebhookResource.js +88 -0
package/README.md
CHANGED
|
@@ -23,7 +23,11 @@ Official Node.js SDK for **CertySign Trust Services** — hash-based digital sig
|
|
|
23
23
|
- [client.certificates — X.509 Certificate Management](#clientcertificates--x509-certificate-management)
|
|
24
24
|
- [client.pki — PKI Infrastructure](#clientpki--pki-infrastructure)
|
|
25
25
|
- [client.envelopes — Envelope Management](#clientenvelopes--envelope-management)
|
|
26
|
-
- [client.legacySign — Legacy Document Signing](#clientlegacysign--legacy-document-signing)
|
|
26
|
+
- [client.legacySign — Legacy Document Signing](#clientlegacysign--legacy-document-signing)
|
|
27
|
+
- [client.webhooks — Event Notifications](#clientwebhooks--event-notifications)
|
|
28
|
+
- [client.billing — Wallet, Quotes and Pricing](#clientbilling--wallet-quotes-and-pricing)
|
|
29
|
+
- [client.signers — Sponsored Signer Certificates](#clientsigners--sponsored-signer-certificates)
|
|
30
|
+
- [client.request — Any Endpoint](#clientrequest--any-endpoint)
|
|
27
31
|
- [Complete Examples](#complete-examples)
|
|
28
32
|
- [Error Handling](#error-handling)
|
|
29
33
|
- [Rate Limiting](#rate-limiting)
|
|
@@ -1117,6 +1121,272 @@ Legacy methods: `quickSign()`, `batchSign()`, `verifyById()`, `verifyDocument()`
|
|
|
1117
1121
|
|
|
1118
1122
|
---
|
|
1119
1123
|
|
|
1124
|
+
### `client.webhooks` — Event Notifications
|
|
1125
|
+
|
|
1126
|
+
Register an endpoint and CertySign calls you when something finishes, instead of you
|
|
1127
|
+
asking repeatedly. Requires the `webhook:manage` permission on your API key.
|
|
1128
|
+
**Deliveries are not charged**, and neither are the status endpoints.
|
|
1129
|
+
|
|
1130
|
+
#### `create({ url, events, description? })` — Register an endpoint
|
|
1131
|
+
|
|
1132
|
+
```js
|
|
1133
|
+
const { data } = await client.webhooks.create({
|
|
1134
|
+
url: 'https://api.example.com/hooks/certysign',
|
|
1135
|
+
events: ['envelope.completed', 'envelope.declined'], // omit for all events
|
|
1136
|
+
description: 'Production order pipeline',
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// data.secret is returned ONCE and is never readable again. Store it now.
|
|
1140
|
+
process.env.CERTYSIGN_WEBHOOK_SECRET = data.secret;
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
Events: `envelope.sent`, `envelope.viewed`, `envelope.signed` (per signature),
|
|
1144
|
+
`envelope.completed`, `envelope.declined`, `envelope.voided`, `envelope.expired`,
|
|
1145
|
+
`signing_session.completed`, `sponsorship.created`,
|
|
1146
|
+
`sponsorship.certificate_issued`, `sponsorship.verification_rejected`,
|
|
1147
|
+
`sponsorship.failed`, `webhook.test`.
|
|
1148
|
+
|
|
1149
|
+
#### `list()`, `remove(webhookId)`, `test(webhookId)`
|
|
1150
|
+
|
|
1151
|
+
```js
|
|
1152
|
+
const { data } = await client.webhooks.list();
|
|
1153
|
+
await client.webhooks.test(data.webhooks[0].webhookId); // sends a webhook.test delivery
|
|
1154
|
+
await client.webhooks.remove(data.webhooks[0].webhookId);
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
#### `deliveries({ webhookId?, status?, limit? })`, `redeliver(deliveryId)`
|
|
1158
|
+
|
|
1159
|
+
```js
|
|
1160
|
+
// What did we send, and what did your endpoint say?
|
|
1161
|
+
const { data } = await client.webhooks.deliveries({ status: 'failed', limit: 50 });
|
|
1162
|
+
for (const d of data.deliveries) {
|
|
1163
|
+
console.log(d.event, d.responseStatus, d.error);
|
|
1164
|
+
await client.webhooks.redeliver(d.deliveryId);
|
|
1165
|
+
}
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
#### Verifying the signature
|
|
1169
|
+
|
|
1170
|
+
Use the built-in helper. It is timing-safe, enforces a freshness window, and reports
|
|
1171
|
+
the two mistakes that otherwise fail silently — signing the body alone (which leaves a
|
|
1172
|
+
captured delivery replayable forever) and verifying after `JSON.parse` (which changes
|
|
1173
|
+
the bytes the signature covers).
|
|
1174
|
+
|
|
1175
|
+
```js
|
|
1176
|
+
const { CertySignClient } = require('@certysign/sdk');
|
|
1177
|
+
|
|
1178
|
+
// express.raw, NOT express.json — the signature covers the bytes as sent.
|
|
1179
|
+
app.post('/hooks/certysign', express.raw({ type: 'application/json' }), (req, res) => {
|
|
1180
|
+
const { valid, reason, event } = CertySignClient.verifyWebhookSignature({
|
|
1181
|
+
secret: process.env.CERTYSIGN_WEBHOOK_SECRET,
|
|
1182
|
+
headers: req.headers,
|
|
1183
|
+
rawBody: req.body, // Buffer or string, never a parsed object
|
|
1184
|
+
toleranceSeconds: 300, // default
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
if (!valid) return res.status(401).send(reason);
|
|
1188
|
+
|
|
1189
|
+
if (alreadyHandled(event.eventId)) return res.sendStatus(200); // at-least-once
|
|
1190
|
+
handle(event);
|
|
1191
|
+
res.sendStatus(200); // answer fast; do the work afterwards
|
|
1192
|
+
});
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
If you would rather implement it yourself: the signature is HMAC-SHA256 over
|
|
1196
|
+
`"<timestamp>.<raw body>"` — **not the body alone** — compared timing-safely, over the
|
|
1197
|
+
raw bytes, before any JSON parsing.
|
|
1198
|
+
|
|
1199
|
+
```js
|
|
1200
|
+
const crypto = require('crypto');
|
|
1201
|
+
|
|
1202
|
+
// express.raw, NOT express.json — the signature covers the bytes as sent.
|
|
1203
|
+
app.post('/hooks/certysign', express.raw({ type: 'application/json' }), (req, res) => {
|
|
1204
|
+
const ts = req.get('X-CertySign-Timestamp');
|
|
1205
|
+
const sig = (req.get('X-CertySign-Signature') || '').replace('sha256=', '');
|
|
1206
|
+
|
|
1207
|
+
// Reject anything older than 5 minutes.
|
|
1208
|
+
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
|
|
1209
|
+
|
|
1210
|
+
const expected = crypto
|
|
1211
|
+
.createHmac('sha256', process.env.CERTYSIGN_WEBHOOK_SECRET)
|
|
1212
|
+
.update(ts + '.' + req.body.toString())
|
|
1213
|
+
.digest('hex');
|
|
1214
|
+
|
|
1215
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
|
1216
|
+
return res.sendStatus(401);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const event = JSON.parse(req.body.toString());
|
|
1220
|
+
if (alreadyHandled(event.eventId)) return res.sendStatus(200); // at-least-once
|
|
1221
|
+
|
|
1222
|
+
handle(event);
|
|
1223
|
+
res.sendStatus(200); // answer fast; do the work afterwards
|
|
1224
|
+
});
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
#### Delivery, retries and duplicates
|
|
1228
|
+
|
|
1229
|
+
- Respond **2xx** quickly. Anything else counts as a failure.
|
|
1230
|
+
- Retries on network errors, timeouts and 5xx: 10s, 1m, 5m, 30m, 2h, then stop.
|
|
1231
|
+
- A **4xx other than 429 is not retried** — your endpoint rejected the shape, and the
|
|
1232
|
+
same bytes would be rejected again.
|
|
1233
|
+
- Delivery is **at-least-once**. De-duplicate on `eventId`, which is stable across
|
|
1234
|
+
retries and redeliveries.
|
|
1235
|
+
- After 20 consecutive failures the webhook is disabled and the reason recorded.
|
|
1236
|
+
|
|
1237
|
+
**Webhooks do not replace polling.** A delivery you never received is unrecoverable
|
|
1238
|
+
unless you can ask for the current state, so the status endpoints remain — and they
|
|
1239
|
+
are free to call.
|
|
1240
|
+
|
|
1241
|
+
---
|
|
1242
|
+
|
|
1243
|
+
### `client.billing` — Wallet, Quotes and Pricing
|
|
1244
|
+
|
|
1245
|
+
Read-only, and every call here is free — checking a balance must never itself cost
|
|
1246
|
+
money.
|
|
1247
|
+
|
|
1248
|
+
#### `wallet()` — Token balance and standing
|
|
1249
|
+
|
|
1250
|
+
```js
|
|
1251
|
+
const { data } = await client.billing.wallet();
|
|
1252
|
+
console.log(`${data.availableBalance} ${data.currency} available`);
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
Spend against **`availableBalance`**, not `balance`: `balance` includes tokens already
|
|
1256
|
+
reserved by operations in flight. A `status` of `'suspended'` means chargeable calls
|
|
1257
|
+
are refused even when the balance looks healthy, so check it before concluding you can
|
|
1258
|
+
afford something.
|
|
1259
|
+
|
|
1260
|
+
#### `quote({ operationType, operationSubType?, quantity?, additionalSigners? })`
|
|
1261
|
+
|
|
1262
|
+
What an operation will cost, and whether you can currently cover it. Call it **before**
|
|
1263
|
+
a large batch: one answer up front beats `INSUFFICIENT_BALANCE` halfway through, with
|
|
1264
|
+
some documents signed and charged and the rest not.
|
|
1265
|
+
|
|
1266
|
+
```js
|
|
1267
|
+
const { data } = await client.billing.quote({
|
|
1268
|
+
operationType: 'document_signing', // or 'certificate_issuance'
|
|
1269
|
+
operationSubType: 'ADVANCED', // e.g. 'ADVANCED', 'ADES'
|
|
1270
|
+
quantity: documents.length,
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
if (!data.canAfford) {
|
|
1274
|
+
throw new Error(`Need ${data.tokensTotal}, have ${data.availableBalance}`);
|
|
1275
|
+
}
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
#### `transactions({ limit?, skip?, type?, status? })`, `plan()`, `pricing()`
|
|
1279
|
+
|
|
1280
|
+
```js
|
|
1281
|
+
const { data } = await client.billing.transactions({ limit: 100, type: 'debit' });
|
|
1282
|
+
const { data: plan } = await client.billing.plan(); // your tier and its limits
|
|
1283
|
+
const { data: rates } = await client.billing.pricing(); // the full price list
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
---
|
|
1287
|
+
|
|
1288
|
+
### `client.signers` — Sponsored Signer Certificates
|
|
1289
|
+
|
|
1290
|
+
A person cannot sign until they hold a certificate. These let you pay for one on their
|
|
1291
|
+
behalf and watch it become usable, instead of discovering at signing time that it is
|
|
1292
|
+
not there.
|
|
1293
|
+
|
|
1294
|
+
#### `signingStatus(email, { issueCompletionUrl? })` — Can this person sign yet?
|
|
1295
|
+
|
|
1296
|
+
Requires `cert:status`. Free to call, so it is safe in front of every send.
|
|
1297
|
+
|
|
1298
|
+
```js
|
|
1299
|
+
const { data } = await client.signers.signingStatus('jane@example.com');
|
|
1300
|
+
|
|
1301
|
+
if (!data.canSign) {
|
|
1302
|
+
// data.reason is NO_SIGNING_CERTIFICATE or AWAITING_IDENTITY_VERIFICATION
|
|
1303
|
+
console.log(data.reason);
|
|
1304
|
+
}
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
Worth calling **before** a batch: it turns a per-document failure — discovered halfway
|
|
1308
|
+
through, after the wallet has been charged — into one answer up front.
|
|
1309
|
+
|
|
1310
|
+
For someone stuck at `AWAITING_IDENTITY_VERIFICATION`, ask for a link you can redirect
|
|
1311
|
+
them to, so they need not hunt for the invitation email:
|
|
1312
|
+
|
|
1313
|
+
```js
|
|
1314
|
+
const { data } = await client.signers.signingStatus(email, { issueCompletionUrl: true });
|
|
1315
|
+
if (data.pendingSponsorship?.completionUrl) {
|
|
1316
|
+
return redirect(data.pendingSponsorship.completionUrl);
|
|
1317
|
+
}
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
`issueCompletionUrl` **mints a new link and the previous one stops working**, including
|
|
1321
|
+
the one already in their inbox. Ask for it when you are about to redirect someone, not
|
|
1322
|
+
on every poll. It needs `signer:sponsor`, because minting an onboarding credential is
|
|
1323
|
+
the same class of act as sponsoring one.
|
|
1324
|
+
|
|
1325
|
+
#### `sponsor({ email, name?, tier?, restrictToSponsor? })` — Pay for their certificate
|
|
1326
|
+
|
|
1327
|
+
Requires `signer:sponsor`, a paid plan and a funded wallet.
|
|
1328
|
+
|
|
1329
|
+
```js
|
|
1330
|
+
const { data } = await client.signers.sponsor({
|
|
1331
|
+
email: 'jane@example.com',
|
|
1332
|
+
name: 'Jane Mwangi', // shown on the invitation; the certificate carries the
|
|
1333
|
+
// name on their verified ID
|
|
1334
|
+
tier: 'ades', // 'ses' | 'ades' (default) | 'qes'
|
|
1335
|
+
restrictToSponsor: true, // only signs your documents (default)
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
return redirect(data.completionUrl);
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
They **cannot sign when this returns** — `status` is `awaiting_member` until they
|
|
1342
|
+
complete identity verification. Subscribe to `sponsorship.certificate_issued` rather
|
|
1343
|
+
than polling for it.
|
|
1344
|
+
|
|
1345
|
+
This also makes them a **member of your organisation** once the certificate issues:
|
|
1346
|
+
they get an account, appear in your member list, and their certificate falls under your
|
|
1347
|
+
governance.
|
|
1348
|
+
|
|
1349
|
+
`restrictToSponsor: false` gives them a certificate usable anywhere, including with
|
|
1350
|
+
your competitors. That is a deliberate choice, not a default.
|
|
1351
|
+
|
|
1352
|
+
#### `resendSetup(email)` — Send the setup link again
|
|
1353
|
+
|
|
1354
|
+
```js
|
|
1355
|
+
await client.signers.resendSetup('jane@example.com');
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
**This rotates the token**, which invalidates the link in any email already sent. That
|
|
1359
|
+
is the point — an old link that still works is an old link someone else can use — but
|
|
1360
|
+
it does mean the earlier email stops working the moment you call this.
|
|
1361
|
+
|
|
1362
|
+
---
|
|
1363
|
+
|
|
1364
|
+
### `client.request` — Any Endpoint
|
|
1365
|
+
|
|
1366
|
+
The API can gain an endpoint at any time. This calls one that has no typed method yet,
|
|
1367
|
+
so you are never blocked waiting for an SDK release.
|
|
1368
|
+
|
|
1369
|
+
```js
|
|
1370
|
+
const res = await client.request('POST', '/sdk/v1/some-new-endpoint', {
|
|
1371
|
+
data: { foo: 'bar' },
|
|
1372
|
+
params: { verbose: true }, // query string
|
|
1373
|
+
idempotencyKey: 'batch-2026-10-01',
|
|
1374
|
+
});
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
Authentication, retries, idempotency-conflict handling, the error shape and the
|
|
1378
|
+
User-Agent all behave exactly as they do for a typed resource. The only thing you give
|
|
1379
|
+
up is the named method and its documentation.
|
|
1380
|
+
|
|
1381
|
+
Prefer a typed resource where one exists — it records the shape and keeps working if a
|
|
1382
|
+
path changes. Use this when there is no resource yet.
|
|
1383
|
+
|
|
1384
|
+
> New response fields need nothing at all: responses are passed through untouched, so
|
|
1385
|
+
> a field added server-side is readable immediately on whatever version you have
|
|
1386
|
+
> installed.
|
|
1387
|
+
|
|
1388
|
+
---
|
|
1389
|
+
|
|
1120
1390
|
## Complete Examples
|
|
1121
1391
|
|
|
1122
1392
|
### PDF: PAdES Sign with signCallback (Recommended)
|
|
@@ -1387,6 +1657,14 @@ For batch workloads, use `batchHashAndSign()` or `batchSignHashes()` to sign up
|
|
|
1387
1657
|
|
|
1388
1658
|
## Migration from v1
|
|
1389
1659
|
|
|
1660
|
+
### What's New in v2.5.1
|
|
1661
|
+
|
|
1662
|
+
- **`client.webhooks`** — register an endpoint and be told when envelopes and
|
|
1663
|
+
sponsorships change state, instead of polling. See
|
|
1664
|
+
[client.webhooks](#clientwebhooks--event-notifications).
|
|
1665
|
+
- The `User-Agent` now reports the real package version. It had been pinned at
|
|
1666
|
+
`1.0.0` for the whole 2.x line, so server logs could not tell SDK versions apart.
|
|
1667
|
+
|
|
1390
1668
|
### What's New in v2.2.0
|
|
1391
1669
|
|
|
1392
1670
|
| Feature | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@certysign/sdk",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Official Node.js SDK for CertySign — hash-based digital signing, X.509 certificates, and PKI services. Documents never leave your system.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "jest --coverage",
|
|
13
13
|
"lint": "eslint src/**/*.js",
|
|
14
|
-
"build:types": "npx tsc --emitDeclarationOnly"
|
|
14
|
+
"build:types": "npx tsc --emitDeclarationOnly",
|
|
15
|
+
"test:durability": "node test/durability.test.js"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|
|
17
18
|
"certysign",
|
package/src/index.js
CHANGED
|
@@ -28,6 +28,7 @@ const { DashboardResource } = require('./lib/DashboardResource');
|
|
|
28
28
|
const { IdentityResource } = require('./lib/IdentityResource');
|
|
29
29
|
const { BillingResource } = require('./lib/BillingResource');
|
|
30
30
|
const { SignerResource } = require('./lib/SignerResource');
|
|
31
|
+
const { WebhookResource } = require('./lib/WebhookResource');
|
|
31
32
|
const { DocumentHasher } = require('./lib/DocumentHasher');
|
|
32
33
|
const { SignatureEmbedder } = require('./lib/SignatureEmbedder');
|
|
33
34
|
|
|
@@ -124,6 +125,7 @@ class CertySignClient {
|
|
|
124
125
|
this.identity = new IdentityResource(this._http); // National-ID verification (IPRS)
|
|
125
126
|
this.billing = new BillingResource(this._http); // Balance, quotes, plan (read-only)
|
|
126
127
|
this.signers = new SignerResource(this._http); // Sponsor + check the people you sign for
|
|
128
|
+
this.webhooks = new WebhookResource(this._http); // Be told, instead of polling
|
|
127
129
|
this.hasher = new DocumentHasher(); // Local document hashing
|
|
128
130
|
|
|
129
131
|
// TSA URL: explicit > environment-derived
|
|
@@ -194,6 +196,147 @@ class CertySignClient {
|
|
|
194
196
|
return { ok: false, environment: this.environment, error: err.message, code: err.code };
|
|
195
197
|
}
|
|
196
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Call any CertySign endpoint, including ones this SDK version has no method for.
|
|
202
|
+
*
|
|
203
|
+
* This exists so the backend can ship an endpoint and you can use it the same
|
|
204
|
+
* day, without waiting for an SDK release. Everything a typed resource gives you
|
|
205
|
+
* still applies: authentication, retries, idempotency-conflict handling, the
|
|
206
|
+
* error shape and the User-Agent. The only thing you give up is the named
|
|
207
|
+
* method and its JSDoc.
|
|
208
|
+
*
|
|
209
|
+
* Prefer a typed resource where one exists — it documents the shape and will keep
|
|
210
|
+
* working if a path changes. Reach for this when there is no resource yet.
|
|
211
|
+
*
|
|
212
|
+
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
|
|
213
|
+
* @param {string} path e.g. '/sdk/v1/some-new-endpoint'
|
|
214
|
+
* @param {object} [options]
|
|
215
|
+
* @param {object} [options.data] JSON body
|
|
216
|
+
* @param {object} [options.params] query string
|
|
217
|
+
* @param {object} [options.headers] extra headers
|
|
218
|
+
* @param {string} [options.idempotencyKey]
|
|
219
|
+
* @returns {Promise<any>} the parsed response body
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* // An endpoint added to the API after this SDK was published:
|
|
223
|
+
* const res = await client.request('POST', '/sdk/v1/envelopes/bulk-void', {
|
|
224
|
+
* data: { envelopeIds: ids, reason: 'duplicate batch' },
|
|
225
|
+
* });
|
|
226
|
+
*/
|
|
227
|
+
request(method, path, options = {}) {
|
|
228
|
+
if (!method) throw new Error('client.request: method is required (GET, POST, PUT, PATCH, DELETE)');
|
|
229
|
+
if (!path) throw new Error('client.request: path is required (e.g. "/sdk/v1/...")');
|
|
230
|
+
if (!String(path).startsWith('/')) {
|
|
231
|
+
throw new Error(`client.request: path must start with "/" — got "${path}"`);
|
|
232
|
+
}
|
|
233
|
+
return this._http.request(String(method).toUpperCase(), path, options);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Verify an incoming webhook delivery.
|
|
238
|
+
*
|
|
239
|
+
* Every integrator otherwise hand-writes this, and the two ways to get it wrong
|
|
240
|
+
* both fail silently rather than loudly:
|
|
241
|
+
*
|
|
242
|
+
* - signing the body alone instead of `timestamp + "." + body`, which leaves a
|
|
243
|
+
* captured delivery replayable forever; and
|
|
244
|
+
* - verifying after `JSON.parse`, because re-serialising changes the bytes the
|
|
245
|
+
* signature was computed over.
|
|
246
|
+
*
|
|
247
|
+
* So pass the RAW body: a Buffer or the original string, never a parsed object.
|
|
248
|
+
* With Express that means `express.raw({ type: 'application/json' })`, not
|
|
249
|
+
* `express.json()`.
|
|
250
|
+
*
|
|
251
|
+
* Comparison is timing-safe, and a delivery older than `toleranceSeconds` is
|
|
252
|
+
* rejected even when the signature is good.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} opts
|
|
255
|
+
* @param {string} opts.secret the whsec_... shown once at creation
|
|
256
|
+
* @param {object} opts.headers the request headers (case-insensitive)
|
|
257
|
+
* @param {Buffer|string} opts.rawBody the body exactly as received
|
|
258
|
+
* @param {number} [opts.toleranceSeconds=300] reject deliveries older than this
|
|
259
|
+
* @returns {{ valid: boolean, reason?: string, event?: object }}
|
|
260
|
+
* `event` is the parsed payload, present only when valid is true.
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => {
|
|
264
|
+
* const { valid, reason, event } = CertySignClient.verifyWebhookSignature({
|
|
265
|
+
* secret: process.env.CERTYSIGN_WEBHOOK_SECRET,
|
|
266
|
+
* headers: req.headers,
|
|
267
|
+
* rawBody: req.body,
|
|
268
|
+
* });
|
|
269
|
+
* if (!valid) return res.status(401).send(reason);
|
|
270
|
+
*
|
|
271
|
+
* if (alreadyHandled(event.eventId)) return res.sendStatus(200); // at-least-once
|
|
272
|
+
* handle(event);
|
|
273
|
+
* res.sendStatus(200);
|
|
274
|
+
* });
|
|
275
|
+
*/
|
|
276
|
+
static verifyWebhookSignature({ secret, headers, rawBody, toleranceSeconds = 300 } = {}) {
|
|
277
|
+
const crypto = require('crypto');
|
|
278
|
+
|
|
279
|
+
if (!secret) return { valid: false, reason: 'secret is required' };
|
|
280
|
+
if (!headers) return { valid: false, reason: 'headers are required' };
|
|
281
|
+
if (rawBody === undefined || rawBody === null) {
|
|
282
|
+
return { valid: false, reason: 'rawBody is required' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A parsed object means express.json() ran and the original bytes are gone.
|
|
286
|
+
// Say so plainly, because the signature would simply never match and the
|
|
287
|
+
// cause is not obvious from a bare "invalid signature".
|
|
288
|
+
if (typeof rawBody === 'object' && !Buffer.isBuffer(rawBody)) {
|
|
289
|
+
return {
|
|
290
|
+
valid: false,
|
|
291
|
+
reason: 'rawBody looks parsed, not raw. The signature covers the bytes as sent, '
|
|
292
|
+
+ "so use express.raw({ type: 'application/json' }) and pass req.body "
|
|
293
|
+
+ 'unmodified.',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Headers arrive lower-cased from Node, but a framework may not.
|
|
298
|
+
const pick = (name) => {
|
|
299
|
+
const want = name.toLowerCase();
|
|
300
|
+
if (typeof headers.get === 'function') return headers.get(name) ?? undefined;
|
|
301
|
+
const hit = Object.keys(headers).find((k) => k.toLowerCase() === want);
|
|
302
|
+
return hit ? headers[hit] : undefined;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const timestamp = pick('X-CertySign-Timestamp');
|
|
306
|
+
const signature = String(pick('X-CertySign-Signature') || '').replace(/^sha256=/, '');
|
|
307
|
+
|
|
308
|
+
if (!timestamp) return { valid: false, reason: 'missing X-CertySign-Timestamp header' };
|
|
309
|
+
if (!signature) return { valid: false, reason: 'missing X-CertySign-Signature header' };
|
|
310
|
+
|
|
311
|
+
const ts = Number(timestamp);
|
|
312
|
+
if (!Number.isFinite(ts)) return { valid: false, reason: 'X-CertySign-Timestamp is not a number' };
|
|
313
|
+
|
|
314
|
+
if (toleranceSeconds > 0) {
|
|
315
|
+
const age = Math.abs(Date.now() / 1000 - ts);
|
|
316
|
+
if (age > toleranceSeconds) {
|
|
317
|
+
return { valid: false, reason: `delivery is ${Math.round(age)}s old, outside the ${toleranceSeconds}s tolerance` };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
|
|
322
|
+
const expected = crypto
|
|
323
|
+
.createHmac('sha256', secret)
|
|
324
|
+
.update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body]))
|
|
325
|
+
.digest('hex');
|
|
326
|
+
|
|
327
|
+
// timingSafeEqual throws on a length mismatch, so compare digests of a fixed
|
|
328
|
+
// length rather than the raw hex.
|
|
329
|
+
const a = crypto.createHash('sha256').update(signature, 'utf8').digest();
|
|
330
|
+
const b = crypto.createHash('sha256').update(expected, 'utf8').digest();
|
|
331
|
+
if (!crypto.timingSafeEqual(a, b)) return { valid: false, reason: 'signature does not match' };
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
return { valid: true, event: JSON.parse(body.toString('utf8')) };
|
|
335
|
+
} catch {
|
|
336
|
+
// Signature is good, so this came from us; the caller still gets the bytes.
|
|
337
|
+
return { valid: true, event: undefined, reason: 'signature valid but body is not JSON' };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
197
340
|
}
|
|
198
341
|
|
|
199
342
|
/**
|
|
@@ -220,6 +363,7 @@ module.exports = {
|
|
|
220
363
|
// or extend them; the client wires them up as client.signers / client.billing.
|
|
221
364
|
SignerResource,
|
|
222
365
|
BillingResource,
|
|
366
|
+
WebhookResource,
|
|
223
367
|
// Legacy exports
|
|
224
368
|
SigningResource,
|
|
225
369
|
CertificateResource,
|
package/src/lib/HttpClient.js
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
'use strict';
|
|
12
12
|
|
|
13
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}`;
|
|
14
22
|
const FormData = require('form-data');
|
|
15
23
|
const crypto = require('crypto');
|
|
16
24
|
|
|
@@ -51,7 +59,7 @@ class HttpClient {
|
|
|
51
59
|
headers: {
|
|
52
60
|
'Content-Type': 'application/json',
|
|
53
61
|
'Accept': 'application/json',
|
|
54
|
-
'User-Agent':
|
|
62
|
+
'User-Agent': SDK_USER_AGENT
|
|
55
63
|
}
|
|
56
64
|
});
|
|
57
65
|
}
|
|
@@ -88,8 +88,18 @@ class IdentityResource {
|
|
|
88
88
|
|
|
89
89
|
const ctry = String(country).toUpperCase().trim();
|
|
90
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.
|
|
91
97
|
if (SUPPORTED[ctry] && !SUPPORTED[ctry].includes(type)) {
|
|
92
|
-
|
|
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
|
+
);
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
const body = {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.webhooks — be told, instead of asking.
|
|
3
|
+
*
|
|
4
|
+
* Before these existed the only way to learn that an envelope completed or a
|
|
5
|
+
* sponsored certificate was issued was to poll, and every poll was billed. Register
|
|
6
|
+
* an endpoint once and CertySign calls you.
|
|
7
|
+
*
|
|
8
|
+
* Deliveries are not charged.
|
|
9
|
+
*/
|
|
10
|
+
class WebhookResource {
|
|
11
|
+
constructor(http) {
|
|
12
|
+
this._http = http;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register an endpoint.
|
|
17
|
+
*
|
|
18
|
+
* The signing secret is returned ONCE, in this response. Store it before you do
|
|
19
|
+
* anything else — there is no way to read it back, only to replace the webhook.
|
|
20
|
+
*
|
|
21
|
+
* Requires `webhook:manage`.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} opts.url https only
|
|
25
|
+
* @param {string[]} [opts.events] omit for every event
|
|
26
|
+
* @param {string} [opts.description]
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* const { data } = await client.webhooks.create({
|
|
30
|
+
* url: 'https://api.example.com/certysign/hooks',
|
|
31
|
+
* events: ['envelope.completed', 'sponsorship.certificate_issued'],
|
|
32
|
+
* });
|
|
33
|
+
* await saveSecret(data.secret); // shown once
|
|
34
|
+
*/
|
|
35
|
+
create({ url, events, description } = {}) {
|
|
36
|
+
if (!url) throw new Error('webhooks.create: url is required');
|
|
37
|
+
return this._http.post('/sdk/v1/webhooks', {
|
|
38
|
+
data: { url, ...(events && { events }), ...(description && { description }) },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every webhook on this organisation, plus the list of supported events. */
|
|
43
|
+
list() {
|
|
44
|
+
return this._http.get('/sdk/v1/webhooks');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stop sending to this endpoint. */
|
|
48
|
+
remove(webhookId) {
|
|
49
|
+
if (!webhookId) throw new Error('webhooks.remove: webhookId is required');
|
|
50
|
+
return this._http.delete(`/sdk/v1/webhooks/${encodeURIComponent(webhookId)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Send a real, signed `webhook.test` event.
|
|
55
|
+
*
|
|
56
|
+
* Worth doing before anything real depends on it: it is the only way to confirm
|
|
57
|
+
* your signature check works while a failure still costs nothing.
|
|
58
|
+
*/
|
|
59
|
+
test(webhookId) {
|
|
60
|
+
if (!webhookId) throw new Error('webhooks.test: webhookId is required');
|
|
61
|
+
return this._http.post(`/sdk/v1/webhooks/${encodeURIComponent(webhookId)}/test`, { data: {} });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What was sent, when, and what your endpoint said.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* @param {string} [opts.webhookId]
|
|
69
|
+
* @param {'pending'|'delivered'|'failed'|'exhausted'} [opts.status]
|
|
70
|
+
* @param {number} [opts.limit]
|
|
71
|
+
*/
|
|
72
|
+
deliveries({ webhookId, status, limit } = {}) {
|
|
73
|
+
const q = new URLSearchParams();
|
|
74
|
+
if (webhookId) q.set('webhookId', webhookId);
|
|
75
|
+
if (status) q.set('status', status);
|
|
76
|
+
if (limit) q.set('limit', String(limit));
|
|
77
|
+
const qs = q.toString();
|
|
78
|
+
return this._http.get(`/sdk/v1/webhooks/deliveries${qs ? `?${qs}` : ''}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Send one again, with the same eventId so you can de-duplicate. */
|
|
82
|
+
redeliver(deliveryId) {
|
|
83
|
+
if (!deliveryId) throw new Error('webhooks.redeliver: deliveryId is required');
|
|
84
|
+
return this._http.post(`/sdk/v1/webhooks/deliveries/${encodeURIComponent(deliveryId)}/redeliver`, { data: {} });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { WebhookResource };
|