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