- 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
348 lines
13 KiB
JavaScript
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));
|
|
}
|