/** Node 20+ backend client. Copy to the Vue site's server code, never its browser bundle. */ import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyCommerceWebhook({ rawBody, secret, timestamp, signature, now = Date.now() }) { if (typeof window !== 'undefined') throw new Error('Webhook verification belongs on the server.'); if (!Buffer.isBuffer(rawBody) || rawBody.length > 65536 || !/^istmo_whk_[a-f0-9]{64}$/.test(secret ?? '') || !/^\d{10}$/.test(timestamp ?? '') || !/^v1=[a-f0-9]{64}$/.test(signature ?? '') || !Number.isFinite(now) || Math.abs(now/1000-Number(timestamp))>300) throw new Error('Invalid webhook signature.'); const expected = createHmac('sha256',secret).update(`${timestamp}.`).update(rawBody).digest(); if (!timingSafeEqual(expected,Buffer.from(signature.slice(3),'hex'))) throw new Error('Invalid webhook signature.'); const event = JSON.parse(rawBody.toString('utf8')); if (event?.version!=='1' || typeof event.id!=='string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(event.id) || typeof event.integrationId!=='string' || typeof event.data?.checkoutId!=='string') throw new Error('Invalid webhook event.'); return event; } export class CommerceError extends Error { constructor(status, code, message) { super(message); this.name = 'CommerceError'; this.status = status; this.code = code; } } export function createCommerceClient({ baseUrl, key, allowLoopbackHttp = false, fetchImpl = globalThis.fetch }) { if (typeof window !== 'undefined') throw new Error('Commerce credentials belong on the server.'); const url = new URL(baseUrl); const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); if (url.username || url.password || url.search || url.hash || url.pathname.replace(/\/$/, '') !== '/api/v1/commerce' || url.protocol !== 'https:' && !(allowLoopbackHttp && loopback && url.protocol === 'http:')) { throw new Error('Use an HTTPS URL ending in /api/v1/commerce (HTTP loopback requires explicit QA opt-in).'); } if (typeof key !== 'string' || !/^istmo_com_[a-f0-9]{64}$/.test(key)) throw new Error('Invalid commerce credential.'); const root = url.toString().replace(/\/$/, ''); async function call(path, method = 'GET', body) { // Never redirect a credential or retry a write implicitly. The caller owns recovery. const response = await fetchImpl(root + path, { method, redirect: 'error', signal: AbortSignal.timeout(10_000), headers: { 'x-istmo-commerce-key': key, 'Content-Type': 'application/json', Accept: 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); let payload; try { payload = await response.json(); } catch { throw new CommerceError(response.status, 'COMMERCE_RESPONSE_INVALID', 'Respuesta no válida; conserve la referencia del pedido.'); } if (!response.ok || payload?.success !== true) { throw new CommerceError(response.status, payload?.error?.code ?? 'COMMERCE_RESPONSE_INVALID', payload?.error?.message ?? 'No se pudo confirmar la operación; conserve la referencia del pedido.'); } if (!payload.data || typeof payload.data !== 'object') { throw new CommerceError(response.status, 'COMMERCE_RESPONSE_INVALID', 'Respuesta incompleta; conserve la referencia del pedido.'); } return payload.data; } return Object.freeze({ webhookDeliveries({ after, limit = 50 } = {}) { if (!Number.isInteger(limit) || limit<1 || limit>100) throw new Error('limit must be 1..100'); const query = new URLSearchParams({limit:String(limit)}); if (after) query.set('after',after); return call('/webhook-deliveries?'+query.toString()); }, retryWebhook(eventId) { if (typeof eventId!=='string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(eventId)) throw new Error('Invalid eventId'); return call('/webhook-deliveries/'+encodeURIComponent(eventId)+'/retry','POST'); }, checkoutCommand(input) { if (!input || typeof input.commandId !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(input.commandId)) throw new Error('Provide a stable UUID commandId.'); return call('/checkout-commands', 'POST', input); }, checkoutCommandResult(commandId) { if (typeof commandId !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(commandId)) throw new Error('Invalid commandId'); return call('/checkout-commands/' + encodeURIComponent(commandId)); }, catalog({ after, limit = 50 } = {}) { if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('limit must be 1..100'); const query = new URLSearchParams({ limit: String(limit) }); if (after) query.set('after', after); return call('/catalog?' + query.toString()); }, createOrder(input) { if (!input || typeof input.externalId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/.test(input.externalId)) { throw new Error('Provide a stable externalId before submitting or retrying an order.'); } return call('/orders', 'POST', input); }, order(externalId) { if (typeof externalId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,99}$/.test(externalId)) throw new Error('Invalid externalId'); return call('/orders/' + encodeURIComponent(externalId)); }, }); }