node/src/index.mjs
Stefan Schmidt-Egermann 1f2d02b916
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
2026-04-25 12:26:04 +02:00

348 lines
13 KiB
JavaScript

/**
* 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));
}