feat: initial node content (v0.1.0)
- ESM-Package für Node.js 18+ - Client mit Sync, Async (mit Polling), Webhook, Verify, Download - Typisierte Error-Klassen - Webhook-Signatur-Verifikation (timingSafeEqual) - 13 Tests mit Node native test runner
This commit is contained in:
parent
4c1e8a235b
commit
1f2d02b916
7 changed files with 750 additions and 77 deletions
20
LICENSE
20
LICENSE
|
|
@ -1,9 +1,21 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 hightrusted
|
Copyright (c) 2026 hightrusted GmbH
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
|
||||||
177
README.md
177
README.md
|
|
@ -5,19 +5,7 @@
|
||||||
Offizielles Node.js-SDK für die [hightrusted CAPTURE API](https://capture.hightrusted.net) —
|
Offizielles Node.js-SDK für die [hightrusted CAPTURE API](https://capture.hightrusted.net) —
|
||||||
forensische Web-Captures mit qualifiziertem Zeitstempel nach RFC 3161 / eIDAS Art. 41.
|
forensische Web-Captures mit qualifiziertem Zeitstempel nach RFC 3161 / eIDAS Art. 41.
|
||||||
|
|
||||||
**Made in Germany.** Server in Deutschland. DSGVO-nativ. Kein US-Cloud-Anbieter
|
**Made in Germany.** Server in Deutschland. DSGVO-nativ. Quelloffen unter MIT.
|
||||||
in der Verarbeitungskette. Quelloffen unter MIT-Lizenz.
|
|
||||||
|
|
||||||
## Was die CAPTURE API tut
|
|
||||||
|
|
||||||
Sie nimmt eine URL entgegen, rendert die Seite vollständig (inkl. JavaScript),
|
|
||||||
liefert sie als PDF/A zurück und versieht das Ergebnis mit einem qualifizierten
|
|
||||||
Zeitstempel. Das Ergebnis ist gerichtsverwertbar und Jahre später noch
|
|
||||||
verifizierbar — auch nachdem die Original-Seite längst offline ist.
|
|
||||||
|
|
||||||
Anwendungsfälle: Markenrechtsverletzungen dokumentieren, Auftragsbedingungen
|
|
||||||
zum Buchungszeitpunkt sichern, Compliance-Nachweise für Behörden, Beweissicherung
|
|
||||||
durch Anwälte und Sachverständige.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -36,65 +24,120 @@ const client = new Client({ apiKey: 'ht_live_...' });
|
||||||
const capture = await client.capture({ url: 'https://example.com' });
|
const capture = await client.capture({ url: 'https://example.com' });
|
||||||
|
|
||||||
console.log(capture.id);
|
console.log(capture.id);
|
||||||
console.log(capture.verifyUrl);
|
console.log(capture.verify_url);
|
||||||
console.log(capture.timestamp.issuedAt);
|
console.log(capture.timestamp.issued_at);
|
||||||
|
|
||||||
// PDF herunterladen
|
// PDF herunterladen
|
||||||
await capture.download('./beweis.pdf');
|
await client.downloadPdf(capture.id, './beweis.pdf');
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentifizierung
|
## Drei Aufruf-Modi
|
||||||
|
|
||||||
Bearer-Token mit API-Key. Key-Erzeugung im Dashboard:
|
### Synchron (Default)
|
||||||
|
```javascript
|
||||||
|
const capture = await client.capture({ url: 'https://example.com' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asynchron mit Polling
|
||||||
|
```javascript
|
||||||
|
const capture = await client.captureAsync({
|
||||||
|
url: 'https://example.com',
|
||||||
|
reference: 'case-001',
|
||||||
|
waitForReady: true, // Default — pollt bis ready
|
||||||
|
maxWaitMs: 60_000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook
|
||||||
|
```javascript
|
||||||
|
const job = await client.captureWebhook({
|
||||||
|
url: 'https://example.com',
|
||||||
|
webhookUrl: 'https://your-app.tld/webhooks/capture',
|
||||||
|
reference: 'case-001',
|
||||||
|
});
|
||||||
|
// Server liefert das fertige Capture per HTTP-POST aus
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifikation (kostenlos, ohne Quota)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Per Capture-ID
|
||||||
|
const result = await client.verify({ source: 'cap_...' });
|
||||||
|
|
||||||
|
// Per Verify-URL
|
||||||
|
const result = await client.verify({ source: 'https://verify.hightrusted.net/c/...' });
|
||||||
|
|
||||||
|
// Per PDF-Upload (Buffer oder Blob)
|
||||||
|
const pdf = await readFile('./beweis.pdf');
|
||||||
|
const result = await client.verify({ pdf });
|
||||||
|
|
||||||
|
console.log(result.valid); // true
|
||||||
|
console.log(result.audit_log.chain_valid); // true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhook-Signatur prüfen
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import express from 'express';
|
||||||
|
import { verifyWebhookSignature } from '@hightrusted/capture';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/webhooks/capture',
|
||||||
|
express.raw({ type: 'application/json' }), // RAW body — wichtig!
|
||||||
|
(req, res) => {
|
||||||
|
const sig = req.header('X-Hightrusted-Signature');
|
||||||
|
if (!verifyWebhookSignature(req.body, sig, 'wh_secret_...')) {
|
||||||
|
return res.status(401).json({ error: 'invalid_signature' });
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(req.body);
|
||||||
|
// ...
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fehler-Behandlung
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import {
|
||||||
|
Client,
|
||||||
|
InvalidApiKeyError,
|
||||||
|
QuotaExceededError,
|
||||||
|
RateLimitedError,
|
||||||
|
UnreachableUrlError,
|
||||||
|
} from '@hightrusted/capture';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const capture = await client.capture({ url: 'https://example.com' });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidApiKeyError) console.error('API-Key ungültig');
|
||||||
|
else if (err instanceof QuotaExceededError) console.error('Quota erschöpft');
|
||||||
|
else if (err instanceof RateLimitedError)
|
||||||
|
console.error(`Rate-limited, retry in ${err.retryAfterSeconds}s`);
|
||||||
|
else throw err;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Key
|
||||||
|
|
||||||
|
Bearer-Token, Erzeugung unter
|
||||||
https://capture.hightrusted.net/dashboard/api-keys
|
https://capture.hightrusted.net/dashboard/api-keys
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// API-Key direkt
|
|
||||||
const client = new Client({ apiKey: 'ht_live_...' });
|
const client = new Client({ apiKey: 'ht_live_...' });
|
||||||
|
// alternativ via Umgebungsvariable HIGHTRUSTED_API_KEY
|
||||||
// oder über Umgebungsvariable HIGHTRUSTED_API_KEY
|
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Asynchrone Captures + Webhooks
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const job = await client.captureAsync({
|
|
||||||
url: 'https://example.com',
|
|
||||||
webhookUrl: 'https://your-app.tld/webhooks/capture',
|
|
||||||
});
|
|
||||||
// job.status === 'queued'
|
|
||||||
|
|
||||||
// später, sobald der Webhook capture.ready geliefert hat:
|
|
||||||
const capture = await client.get(job.id);
|
|
||||||
await capture.download('./beweis.pdf');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const result = await client.verify('cap_...');
|
|
||||||
console.log(result.valid); // true
|
|
||||||
console.log(result.timestamp); // 2026-04-25T11:29:40Z
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate Limits
|
|
||||||
|
|
||||||
Limits werden pro API-Key gemessen. Bei Überschreitung: HTTP 429 mit
|
|
||||||
`Retry-After`-Header. Das SDK respektiert den Header automatisch und retried.
|
|
||||||
|
|
||||||
| Plan | req/min | Calls/Monat |
|
|
||||||
|-----------|---------|-------------|
|
|
||||||
| Developer | 5 | 100 |
|
|
||||||
| Starter | 30 | 300 |
|
|
||||||
| Growth | 120 | 2.000 |
|
|
||||||
| Scale | 600 | 10.000 |
|
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Node.js 18 oder höher
|
- Node.js 18 oder höher (`fetch`, `AbortSignal.timeout`, `FormData` werden vorausgesetzt)
|
||||||
- ESM und CommonJS werden beide unterstützt
|
- ESM only — `import`-Syntax. Wenn du CommonJS brauchst, dynamic import nutzen:
|
||||||
- TypeScript-Typdefinitionen sind enthalten
|
```javascript
|
||||||
|
const { Client } = await import('@hightrusted/capture');
|
||||||
|
```
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
|
|
@ -102,13 +145,13 @@ Limits werden pro API-Key gemessen. Bei Überschreitung: HTTP 429 mit
|
||||||
git clone ssh://git@git.hightrusted.net:2222/hightrusted-capture/node.git
|
git clone ssh://git@git.hightrusted.net:2222/hightrusted-capture/node.git
|
||||||
cd node
|
cd node
|
||||||
npm install
|
npm install
|
||||||
npm test
|
npm test # 13 Tests mit Node native test runner
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [ ] v0.1 — Basis-Client (Promise-API), Verify, Download
|
- [x] v0.1 — Basis-Client (sync + async + webhook), Verify, Download, typisierte Errors
|
||||||
- [ ] v0.2 — Retry-Logik, Webhook-Signatur-Verifikation, vollständige TypeScript-Defs
|
- [ ] v0.2 — TypeScript-Defs, retry-after-Helper
|
||||||
- [ ] v0.3 — Stream-API für große PDFs
|
- [ ] v0.3 — Stream-API für große PDFs
|
||||||
- [ ] v1.0 — Stabile API, semantische Versionierung
|
- [ ] v1.0 — Stabile API, semantische Versionierung
|
||||||
|
|
||||||
|
|
@ -116,7 +159,7 @@ npm test
|
||||||
|
|
||||||
**Im selben Produkt** ([`hightrusted-capture`](https://git.hightrusted.net/hightrusted-capture)):
|
**Im selben Produkt** ([`hightrusted-capture`](https://git.hightrusted.net/hightrusted-capture)):
|
||||||
|
|
||||||
- [`openapi`](https://git.hightrusted.net/hightrusted-capture/openapi) — OpenAPI 3.1 Spec (Single Source of Truth)
|
- [`openapi`](https://git.hightrusted.net/hightrusted-capture/openapi) — OpenAPI 3.1 Spec
|
||||||
- [`postman`](https://git.hightrusted.net/hightrusted-capture/postman) — Postman Collection
|
- [`postman`](https://git.hightrusted.net/hightrusted-capture/postman) — Postman Collection
|
||||||
- [`examples`](https://git.hightrusted.net/hightrusted-capture/examples) — Beispiel-Anwendungen
|
- [`examples`](https://git.hightrusted.net/hightrusted-capture/examples) — Beispiel-Anwendungen
|
||||||
- [`python`](https://git.hightrusted.net/hightrusted-capture/python) — Python-SDK
|
- [`python`](https://git.hightrusted.net/hightrusted-capture/python) — Python-SDK
|
||||||
|
|
@ -124,16 +167,16 @@ npm test
|
||||||
|
|
||||||
**Plattform-übergreifend** ([`hightrusted`](https://git.hightrusted.net/hightrusted)):
|
**Plattform-übergreifend** ([`hightrusted`](https://git.hightrusted.net/hightrusted)):
|
||||||
|
|
||||||
- [`platform`](https://git.hightrusted.net/hightrusted/platform) — Plattform-Übersicht, Architektur, Produkt-Liste
|
- [`platform`](https://git.hightrusted.net/hightrusted/platform)
|
||||||
- [`developer-portal`](https://git.hightrusted.net/hightrusted/developer-portal) — gemeinsame Konventionen, Auth, Errors, Rate-Limits
|
- [`developer-portal`](https://git.hightrusted.net/hightrusted/developer-portal)
|
||||||
- [`compliance`](https://git.hightrusted.net/hightrusted/compliance) — DSGVO, AGB-Templates, Whitepaper
|
- [`compliance`](https://git.hightrusted.net/hightrusted/compliance)
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- **Doku:** https://capture.hightrusted.net/api/docs
|
- **Doku:** https://capture.hightrusted.net/api/docs
|
||||||
- **Status-Page:** https://status.hightrusted.net
|
- **Status:** https://status.hightrusted.net
|
||||||
- **Developer Support:** developers@hightrusted.net
|
- **Developer Support:** developers@hightrusted.net
|
||||||
- **Sicherheitslücken:** siehe [SECURITY.md](./SECURITY.md)
|
- **Sicherheit:** siehe [SECURITY.md](./SECURITY.md)
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,3 @@ Während der `v0.x`-Phase werden nur die jeweils aktuellste Minor-Version und
|
||||||
deren letzte zwei Patch-Versionen aktiv mit Sicherheits-Updates versorgt.
|
deren letzte zwei Patch-Versionen aktiv mit Sicherheits-Updates versorgt.
|
||||||
|
|
||||||
Ab `v1.0` gilt: aktuelle Major + vorherige Major (12 Monate Übergangsfrist).
|
Ab `v1.0` gilt: aktuelle Major + vorherige Major (12 Monate Übergangsfrist).
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
Diese Policy gilt für die SDKs in diesem Repository und die zugehörige
|
|
||||||
hightrusted CAPTURE API. Für Schwachstellen in anderen hightrusted-Produkten
|
|
||||||
(SIGN, ID, MEET, PAY, …) gilt jeweils die dortige `SECURITY.md`.
|
|
||||||
|
|
|
||||||
55
examples/quickstart.mjs
Normal file
55
examples/quickstart.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* hightrusted CAPTURE — Node.js Quickstart
|
||||||
|
*
|
||||||
|
* Voraussetzung:
|
||||||
|
* npm install @hightrusted/capture
|
||||||
|
*
|
||||||
|
* API-Key holen:
|
||||||
|
* https://capture.hightrusted.net/dashboard/api-keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client, RateLimitedError } from '@hightrusted/capture';
|
||||||
|
|
||||||
|
const client = new Client({ apiKey: process.env.HIGHTRUSTED_API_KEY });
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// 1. Synchrone Capture
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log('→ Erstelle Capture (synchron)...');
|
||||||
|
const capture = await client.capture({
|
||||||
|
url: 'https://example.com',
|
||||||
|
reference: 'quickstart-demo',
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
console.log(` ID: ${capture.id}`);
|
||||||
|
console.log(` Status: ${capture.status}`);
|
||||||
|
console.log(` Verify-URL: ${capture.verify_url}`);
|
||||||
|
console.log(` Zeitstempel: ${capture.timestamp.issued_at}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// 2. Verifikation (kostenlos)
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log('\n→ Verifiziere die Capture...');
|
||||||
|
const result = await client.verify({ source: capture.id });
|
||||||
|
console.log(` Gültig: ${result.valid}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// 3. PDF herunterladen
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log('\n→ Lade PDF herunter...');
|
||||||
|
const path = await client.downloadPdf(capture.id, `./capture_${capture.id.slice(0, 8)}.pdf`);
|
||||||
|
console.log(` Gespeichert: ${path}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Quota
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
console.log('\n→ Quota-Status...');
|
||||||
|
try {
|
||||||
|
const usage = await client.usage();
|
||||||
|
console.log(` Plan: ${usage.plan}`);
|
||||||
|
console.log(` Verbraucht: ${usage.used_calls}/${usage.included_calls}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RateLimitedError) {
|
||||||
|
console.log(` Rate-limited, retry in ${err.retryAfterSeconds}s`);
|
||||||
|
} else throw err;
|
||||||
|
}
|
||||||
49
package.json
Normal file
49
package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"name": "@hightrusted/capture",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Node.js SDK für die hightrusted CAPTURE API — forensische Web-Captures mit qualifiziertem Zeitstempel",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.mjs",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./src/index.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"CHANGELOG.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.mjs",
|
||||||
|
"lint": "eslint src/ test/"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"capture",
|
||||||
|
"screenshot",
|
||||||
|
"rfc3161",
|
||||||
|
"timestamp",
|
||||||
|
"eidas",
|
||||||
|
"forensic",
|
||||||
|
"evidence",
|
||||||
|
"hightrusted"
|
||||||
|
],
|
||||||
|
"author": "hightrusted GmbH <developers@hightrusted.net>",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://capture.hightrusted.net",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.hightrusted.net/hightrusted-capture/node.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.hightrusted.net/hightrusted-capture/node/issues",
|
||||||
|
"email": "developers@hightrusted.net"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
348
src/index.mjs
Normal file
348
src/index.mjs
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
/**
|
||||||
|
* hightrusted CAPTURE — Node.js SDK
|
||||||
|
*
|
||||||
|
* Forensische Web-Captures mit qualifiziertem Zeitstempel nach RFC 3161 / eIDAS Art. 41.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { Client } from '@hightrusted/capture';
|
||||||
|
* const client = new Client({ apiKey: 'ht_live_...' });
|
||||||
|
* const capture = await client.capture({ url: 'https://example.com' });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'https://capture.hightrusted.net/api/v1';
|
||||||
|
const DEFAULT_TIMEOUT_MS = 35_000;
|
||||||
|
const DEFAULT_USER_AGENT = 'hightrusted-capture-node/0.1.0';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Errors
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
export class HightrustedError extends Error {
|
||||||
|
constructor(message, { code, requestId, statusCode, raw } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HightrustedError';
|
||||||
|
this.code = code;
|
||||||
|
this.requestId = requestId;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.raw = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidApiKeyError extends HightrustedError {
|
||||||
|
constructor(...args) { super(...args); this.name = 'InvalidApiKeyError'; }
|
||||||
|
}
|
||||||
|
export class QuotaExceededError extends HightrustedError {
|
||||||
|
constructor(...args) { super(...args); this.name = 'QuotaExceededError'; }
|
||||||
|
}
|
||||||
|
export class CaptureNotFoundError extends HightrustedError {
|
||||||
|
constructor(...args) { super(...args); this.name = 'CaptureNotFoundError'; }
|
||||||
|
}
|
||||||
|
export class CaptureNotReadyError extends HightrustedError {
|
||||||
|
constructor(...args) { super(...args); this.name = 'CaptureNotReadyError'; }
|
||||||
|
}
|
||||||
|
export class UnreachableUrlError extends HightrustedError {
|
||||||
|
constructor(...args) { super(...args); this.name = 'UnreachableUrlError'; }
|
||||||
|
}
|
||||||
|
export class RateLimitedError extends HightrustedError {
|
||||||
|
constructor(message, opts = {}) {
|
||||||
|
super(message, opts);
|
||||||
|
this.name = 'RateLimitedError';
|
||||||
|
this.retryAfterSeconds = opts.retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class TsaUnavailableError extends HightrustedError {
|
||||||
|
constructor(...args) { super(...args); this.name = 'TsaUnavailableError'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_CODE_MAP = {
|
||||||
|
invalid_api_key: InvalidApiKeyError,
|
||||||
|
quota_exceeded: QuotaExceededError,
|
||||||
|
capture_not_found: CaptureNotFoundError,
|
||||||
|
capture_not_ready: CaptureNotReadyError,
|
||||||
|
unreachable_url: UnreachableUrlError,
|
||||||
|
rate_limited: RateLimitedError,
|
||||||
|
tsa_unavailable: TsaUnavailableError,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Webhook signature
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Prüft die HMAC-SHA-256-Signatur eines eingehenden Webhooks.
|
||||||
|
* @param {Buffer|string} body Roh-Body (Buffer oder UTF-8 String)
|
||||||
|
* @param {string} signatureHeader Wert von `X-Hightrusted-Signature`
|
||||||
|
* @param {string} secret Webhook-Secret aus dem Dashboard
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function verifyWebhookSignature(body, signatureHeader, secret) {
|
||||||
|
if (!body || !signatureHeader || !secret) return false;
|
||||||
|
|
||||||
|
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8');
|
||||||
|
const expected = 'sha256=' + createHmac('sha256', secret).update(buf).digest('hex');
|
||||||
|
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(signatureHeader);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Client
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
export class Client {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} [options.apiKey] Default: process.env.HIGHTRUSTED_API_KEY
|
||||||
|
* @param {string} [options.baseUrl] Default: https://capture.hightrusted.net/api/v1
|
||||||
|
* @param {number} [options.timeoutMs] Default: 35000
|
||||||
|
* @param {number} [options.maxRetries] Default: 3
|
||||||
|
* @param {typeof fetch} [options.fetch] Default: globalThis.fetch
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
apiKey,
|
||||||
|
baseUrl = DEFAULT_BASE_URL,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
maxRetries = 3,
|
||||||
|
fetch: fetchImpl = globalThis.fetch,
|
||||||
|
} = {}) {
|
||||||
|
const key = apiKey ?? process.env.HIGHTRUSTED_API_KEY;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
'API-Key fehlt. Setze entweder den Parameter `apiKey` oder die ' +
|
||||||
|
'Umgebungsvariable HIGHTRUSTED_API_KEY.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof fetchImpl !== 'function') {
|
||||||
|
throw new Error('fetch ist nicht verfügbar — Node.js 18+ wird benötigt.');
|
||||||
|
}
|
||||||
|
this.apiKey = key;
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
this.maxRetries = maxRetries;
|
||||||
|
this._fetch = fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Public API
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchrones Capture — wartet bis zu 30 s auf das fertige PDF.
|
||||||
|
*/
|
||||||
|
async capture({ url, reference, viewport, waitUntil, fullPage, coBranding } = {}) {
|
||||||
|
const body = this._buildCaptureBody({ url, reference, viewport, waitUntil, fullPage, coBranding });
|
||||||
|
return this._request('POST', '/captures', { body });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchrones Capture mit optionalem Polling.
|
||||||
|
*/
|
||||||
|
async captureAsync({
|
||||||
|
url,
|
||||||
|
reference,
|
||||||
|
waitForReady = true,
|
||||||
|
pollIntervalMs = 2000,
|
||||||
|
maxWaitMs = 60_000,
|
||||||
|
...rest
|
||||||
|
} = {}) {
|
||||||
|
const body = this._buildCaptureBody({ url, mode: 'async', reference, ...rest });
|
||||||
|
const queued = await this._request('POST', '/captures', { body });
|
||||||
|
|
||||||
|
if (!waitForReady) return queued;
|
||||||
|
|
||||||
|
const deadline = Date.now() + maxWaitMs;
|
||||||
|
await sleep(Math.min(3000, pollIntervalMs)); // initial 3 s
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const detail = await this.get(queued.id);
|
||||||
|
if (detail.status === 'ready') return detail;
|
||||||
|
if (detail.status === 'failed') {
|
||||||
|
throw new HightrustedError(`Capture failed: ${detail.error ?? 'unknown'}`, {
|
||||||
|
code: 'capture_failed',
|
||||||
|
raw: detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await sleep(pollIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Capture ${queued.id} nicht fertig nach ${maxWaitMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook-Capture — Server liefert das Ergebnis per HTTP-POST aus.
|
||||||
|
*/
|
||||||
|
async captureWebhook({ url, webhookUrl, reference, ...rest } = {}) {
|
||||||
|
if (!webhookUrl) throw new Error('webhookUrl ist Pflicht für captureWebhook');
|
||||||
|
const body = this._buildCaptureBody({
|
||||||
|
url,
|
||||||
|
mode: 'webhook',
|
||||||
|
webhookUrl,
|
||||||
|
reference,
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
return this._request('POST', '/captures', { body });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status / Detail einer einzelnen Capture. */
|
||||||
|
async get(id) {
|
||||||
|
return this._request('GET', `/captures/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Captures auflisten (paginiert per Cursor). */
|
||||||
|
async list({ status, reference, limit = 25, cursor } = {}) {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
if (reference) params.set('reference', reference);
|
||||||
|
if (cursor) params.set('cursor', cursor);
|
||||||
|
return this._request('GET', `/captures?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF herunterladen und in eine Datei schreiben.
|
||||||
|
* @returns {Promise<string>} Der Zielpfad.
|
||||||
|
*/
|
||||||
|
async downloadPdf(id, targetPath) {
|
||||||
|
const url = `${this.baseUrl}/captures/${encodeURIComponent(id)}/pdf`;
|
||||||
|
const resp = await this._fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this._headers(),
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
if (!resp.ok) await this._raiseFromResponse(resp);
|
||||||
|
|
||||||
|
await pipeline(Readable.fromWeb(resp.body), createWriteStream(targetPath));
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifiziert ein Capture per ID, Verify-URL oder PDF-Upload.
|
||||||
|
* @param {object} args
|
||||||
|
* @param {string} [args.source] Capture-ID oder Verify-URL
|
||||||
|
* @param {Blob|Buffer} [args.pdf] PDF-Daten als Blob/Buffer
|
||||||
|
*/
|
||||||
|
async verify({ source, pdf } = {}) {
|
||||||
|
if ((source == null) === (pdf == null)) {
|
||||||
|
throw new Error('Genau einer von `source` oder `pdf` muss gesetzt sein.');
|
||||||
|
}
|
||||||
|
const url = `${this.baseUrl}/verify`;
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
if (source != null) {
|
||||||
|
resp = await this._fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...this._headers(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source }),
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const form = new FormData();
|
||||||
|
const blob = pdf instanceof Blob ? pdf : new Blob([pdf], { type: 'application/pdf' });
|
||||||
|
form.append('pdf', blob, 'capture.pdf');
|
||||||
|
resp = await this._fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._headers(),
|
||||||
|
body: form,
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!resp.ok) await this._raiseFromResponse(resp);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktueller Verbrauch und Restkontingent. */
|
||||||
|
async usage() {
|
||||||
|
return this._request('GET', '/usage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
// Internals
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
_headers() {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'User-Agent': DEFAULT_USER_AGENT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCaptureBody({ url, mode, webhookUrl, reference, viewport, waitUntil, fullPage, coBranding }) {
|
||||||
|
if (!url) throw new Error('url ist Pflicht');
|
||||||
|
const body = { url };
|
||||||
|
if (mode != null) body.mode = mode;
|
||||||
|
if (webhookUrl != null) body.webhook_url = webhookUrl;
|
||||||
|
if (reference != null) body.reference = reference;
|
||||||
|
if (viewport != null) body.viewport = viewport;
|
||||||
|
if (waitUntil != null) body.wait_until = waitUntil;
|
||||||
|
if (fullPage != null) body.full_page = fullPage;
|
||||||
|
if (coBranding != null) {
|
||||||
|
body.co_branding = {};
|
||||||
|
if (coBranding.logoUrl) body.co_branding.logo_url = coBranding.logoUrl;
|
||||||
|
if (coBranding.footerText) body.co_branding.footer_text = coBranding.footerText;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _request(method, path, { body, params } = {}) {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const resp = await this._fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { ...this._headers(), 'Content-Type': 'application/json' },
|
||||||
|
body: body == null ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 429 && attempt < this.maxRetries) {
|
||||||
|
const retryAfter = parseInt(resp.headers.get('Retry-After') ?? '1', 10);
|
||||||
|
await sleep(retryAfter * 1000);
|
||||||
|
attempt++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status >= 500 && resp.status < 600 && attempt < this.maxRetries) {
|
||||||
|
await sleep(2 ** attempt * 1000);
|
||||||
|
attempt++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) await this._raiseFromResponse(resp);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _raiseFromResponse(resp) {
|
||||||
|
let payload, code, message, requestId;
|
||||||
|
try {
|
||||||
|
payload = await resp.json();
|
||||||
|
const err = payload?.error ?? {};
|
||||||
|
code = err.code ?? 'unknown_error';
|
||||||
|
message = err.message ?? resp.statusText;
|
||||||
|
requestId = err.request_id;
|
||||||
|
} catch {
|
||||||
|
code = 'unknown_error';
|
||||||
|
message = resp.statusText || 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExcClass = ERROR_CODE_MAP[code] ?? HightrustedError;
|
||||||
|
const opts = {
|
||||||
|
code,
|
||||||
|
requestId,
|
||||||
|
statusCode: resp.status,
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
if (ExcClass === RateLimitedError) {
|
||||||
|
opts.retryAfterSeconds = parseInt(resp.headers.get('Retry-After') ?? '0', 10) || null;
|
||||||
|
}
|
||||||
|
throw new ExcClass(message, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((res) => setTimeout(res, ms));
|
||||||
|
}
|
||||||
172
test/client.test.mjs
Normal file
172
test/client.test.mjs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// Tests für @hightrusted/capture
|
||||||
|
// Node.js native test runner (Node 18+)
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Client,
|
||||||
|
HightrustedError,
|
||||||
|
InvalidApiKeyError,
|
||||||
|
QuotaExceededError,
|
||||||
|
RateLimitedError,
|
||||||
|
verifyWebhookSignature,
|
||||||
|
} from '../src/index.mjs';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Mock-fetch helper
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
function mockFetch(handler) {
|
||||||
|
const calls = [];
|
||||||
|
const fetch = async (url, init) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return handler(url, init);
|
||||||
|
};
|
||||||
|
return { fetch, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body, { status = 200, headers = {} } = {}) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test('Client wirft, wenn kein API-Key gesetzt ist', () => {
|
||||||
|
const old = process.env.HIGHTRUSTED_API_KEY;
|
||||||
|
delete process.env.HIGHTRUSTED_API_KEY;
|
||||||
|
assert.throws(() => new Client(), /API-Key fehlt/);
|
||||||
|
if (old) process.env.HIGHTRUSTED_API_KEY = old;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Client liest API-Key aus ENV', () => {
|
||||||
|
const old = process.env.HIGHTRUSTED_API_KEY;
|
||||||
|
process.env.HIGHTRUSTED_API_KEY = 'ht_test_env';
|
||||||
|
const c = new Client({ fetch: () => {} });
|
||||||
|
assert.equal(c.apiKey, 'ht_test_env');
|
||||||
|
if (old) process.env.HIGHTRUSTED_API_KEY = old;
|
||||||
|
else delete process.env.HIGHTRUSTED_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture() sendet Bearer-Token', async () => {
|
||||||
|
const { fetch, calls } = mockFetch(() =>
|
||||||
|
jsonResponse({
|
||||||
|
id: '550e',
|
||||||
|
status: 'ready',
|
||||||
|
verify_url: 'https://verify.hightrusted.net/c/550e',
|
||||||
|
created_at: '2026-04-25T00:00:00Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch });
|
||||||
|
const result = await c.capture({ url: 'https://example.com' });
|
||||||
|
|
||||||
|
assert.equal(result.id, '550e');
|
||||||
|
assert.equal(calls[0].init.headers.Authorization, 'Bearer ht_test_xyz');
|
||||||
|
const body = JSON.parse(calls[0].init.body);
|
||||||
|
assert.equal(body.url, 'https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capture() mit reference + viewport', async () => {
|
||||||
|
const { fetch, calls } = mockFetch(() =>
|
||||||
|
jsonResponse({ id: 'x', status: 'ready', created_at: '2026-04-25T00:00:00Z' })
|
||||||
|
);
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch });
|
||||||
|
await c.capture({
|
||||||
|
url: 'https://example.com',
|
||||||
|
reference: 'case-001',
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init.body);
|
||||||
|
assert.equal(body.reference, 'case-001');
|
||||||
|
assert.equal(body.viewport.width, 1920);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verify() ohne Argumente wirft', async () => {
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch: () => {} });
|
||||||
|
await assert.rejects(() => c.verify(), /Genau einer/);
|
||||||
|
await assert.rejects(() => c.verify({ source: 'x', pdf: Buffer.from('y') }), /Genau einer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verify() per ID', async () => {
|
||||||
|
const { fetch, calls } = mockFetch(() => jsonResponse({ valid: true, capture_id: '550e' }));
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch });
|
||||||
|
const result = await c.verify({ source: '550e8400-e29b-41d4-a716-446655440000' });
|
||||||
|
assert.equal(result.valid, true);
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0].init.body);
|
||||||
|
assert.equal(body.source, '550e8400-e29b-41d4-a716-446655440000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('InvalidApiKeyError bei 401', async () => {
|
||||||
|
const { fetch } = mockFetch(() =>
|
||||||
|
jsonResponse(
|
||||||
|
{ error: { code: 'invalid_api_key', message: 'Bad key', request_id: 'r1' } },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch, maxRetries: 0 });
|
||||||
|
await assert.rejects(() => c.capture({ url: 'https://example.com' }), (err) => {
|
||||||
|
assert.ok(err instanceof InvalidApiKeyError);
|
||||||
|
assert.equal(err.code, 'invalid_api_key');
|
||||||
|
assert.equal(err.requestId, 'r1');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QuotaExceededError bei 402', async () => {
|
||||||
|
const { fetch } = mockFetch(() =>
|
||||||
|
jsonResponse(
|
||||||
|
{ error: { code: 'quota_exceeded', message: 'X', request_id: 'r1' } },
|
||||||
|
{ status: 402 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch, maxRetries: 0 });
|
||||||
|
await assert.rejects(() => c.capture({ url: 'https://example.com' }), QuotaExceededError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RateLimitedError mit retryAfterSeconds', async () => {
|
||||||
|
const { fetch } = mockFetch(() =>
|
||||||
|
jsonResponse(
|
||||||
|
{ error: { code: 'rate_limited', message: 'X', request_id: 'r1' } },
|
||||||
|
{ status: 429, headers: { 'Retry-After': '12' } }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const c = new Client({ apiKey: 'ht_test_xyz', fetch, maxRetries: 0 });
|
||||||
|
await assert.rejects(() => c.capture({ url: 'https://example.com' }), (err) => {
|
||||||
|
assert.ok(err instanceof RateLimitedError);
|
||||||
|
assert.equal(err.retryAfterSeconds, 12);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Webhook-Signatur
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
test('verifyWebhookSignature akzeptiert korrekte Signatur', () => {
|
||||||
|
const body = Buffer.from('{"event":"capture.ready"}');
|
||||||
|
const secret = 'wh_secret_test';
|
||||||
|
const sig = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
||||||
|
assert.equal(verifyWebhookSignature(body, sig, secret), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyWebhookSignature lehnt falsche Signatur ab', () => {
|
||||||
|
const body = Buffer.from('{"event":"capture.ready"}');
|
||||||
|
const badSig = 'sha256=' + '0'.repeat(64);
|
||||||
|
assert.equal(verifyWebhookSignature(body, badSig, 'secret'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyWebhookSignature lehnt leere Inputs ab', () => {
|
||||||
|
assert.equal(verifyWebhookSignature('', 'sha256=x', 'secret'), false);
|
||||||
|
assert.equal(verifyWebhookSignature('x', '', 'secret'), false);
|
||||||
|
assert.equal(verifyWebhookSignature('x', 'sha256=x', ''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyWebhookSignature akzeptiert auch String-Body', () => {
|
||||||
|
const body = '{"event":"capture.ready"}';
|
||||||
|
const secret = 'wh_secret_test';
|
||||||
|
const sig = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
||||||
|
assert.equal(verifyWebhookSignature(body, sig, secret), true);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue