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:
Stefan Schmidt-Egermann 2026-04-25 12:26:04 +02:00
parent 4c1e8a235b
commit 1f2d02b916
Signed by: SSE
GPG key ID: DE0FCB225FF13A91
7 changed files with 750 additions and 77 deletions

20
LICENSE
View file

@ -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
View file

@ -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

View file

@ -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
View 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
View 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
View 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
View 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);
});