/** * Client JavaScript (Node 18+ / Deno / navigateur) pour l'API ClientZen v1. * ⚠ Côté navigateur, un jeton d'API serait exposé à tout visiteur : n'appelez l'API que depuis votre serveur. * import { ClientZen } from './clientzen.mjs'; * const cz = new ClientZen(process.env.CLIENTZEN_TOKEN); * const { data } = await cz.get('/customers', { q: 'Petit' }); * const created = await cz.post('/customers', { type: 'company', name: 'Boulangerie Petit' }, { idempotencyKey: 'form-42' }); */ export class ClientZenApiError extends Error { constructor(status, problem) { super(`ClientZen API ${status} : ${problem?.title ?? 'erreur'}${problem?.detail ? ' — ' + problem.detail : ''}`); this.status = status; this.problem = problem; } } export class ClientZen { constructor(token, baseUrl = 'https://clientzen.fr/api/v1') { this.token = token; this.baseUrl = baseUrl; } async get(path, query = {}) { const qs = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== '')).toString(); return this.#json('GET', path + (qs ? `?${qs}` : '')); } async post(path, body, { idempotencyKey } = {}) { return this.#json('POST', path, body, idempotencyKey); } async patch(path, body) { return this.#json('PATCH', path, body); } async download(path) { const r = await this.#fetch('GET', path, undefined, undefined, 'application/pdf'); if (!r.ok) throw new ClientZenApiError(r.status, await r.json().catch(() => ({}))); return new Uint8Array(await r.arrayBuffer()); } async *each(path, query = {}) { let page = 1, last = 1; do { const res = await this.get(path, { ...query, page, per_page: 100 }); for (const row of res.data ?? []) yield row; last = res.meta?.last_page ?? 1; page++; } while (page <= last); } async #json(method, path, body, idempotencyKey) { const r = await this.#fetch(method, path, body, idempotencyKey, 'application/json'); const json = await r.json().catch(() => ({})); if (!r.ok) throw new ClientZenApiError(r.status, json); return json; } async #fetch(method, path, body, idempotencyKey, accept) { const headers = { Authorization: `Bearer ${this.token}`, Accept: accept, 'User-Agent': 'ClientZen-JS-SDK/1' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; return fetch(this.baseUrl + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' }); } } /** Vérifie la signature d'un webhook entrant : en-tête `X-ClientZen-Signature: t=…,v1=…`, tolérance 5 min. (Node : import { createHmac, timingSafeEqual } from 'node:crypto') */ export function verifyWebhookSignature(rawBody, signatureHeader, secret, { createHmac, timingSafeEqual }, toleranceSeconds = 300) { const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signatureHeader ?? ''); if (!m) return false; if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSeconds) return false; const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex'); return expected.length === m[2].length && timingSafeEqual(Buffer.from(expected), Buffer.from(m[2])); }