/** Node 24+, server-only Shopify and WooCommerce adapter. * Verify untouched bytes BEFORE JSON parsing; acknowledge only after durable accept(). * Supply readOrder(orderId): authenticated merchant API lookup returning the provider's current order. * resolveContact(order): returns an existing WingsFlow contact ID with separately recorded consent. * Run drain() in a worker. This adapter never grants customer consent or verifies payment from headers. */ import { createHmac, createHash, timingSafeEqual } from 'node:crypto'; import { DatabaseSync } from 'node:sqlite'; const digest = (v) => createHash('sha256').update(v).digest('hex'); const id = (v) => { if ((typeof v === 'number' && !Number.isSafeInteger(v)) || !/^[1-9][0-9]{0,19}$/.test(String(v))) throw Error('Valid provider resource ID required'); return String(v); }; export function verifyCommerceSignature(provider, raw, headers, secret, store) { if ( !['shopify', 'woocommerce'].includes(provider) || !Buffer.isBuffer(raw) || raw.length > 2 * 1024 * 1024 || typeof secret !== 'string' || secret.length < 16 ) throw Error('Invalid webhook configuration or size'); const h = new Headers(headers), signature = h.get( provider === 'shopify' ? 'x-shopify-hmac-sha256' : 'x-wc-webhook-signature', ) || ''; if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) throw Error('Invalid webhook signature'); const expected = createHmac('sha256', secret).update(raw).digest(), actual = Buffer.from(signature, 'base64'); if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw Error('Invalid webhook signature'); if ( provider === 'shopify' && (!/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(store) || h.get('x-shopify-shop-domain') !== store) ) throw Error('Unexpected Shopify store'); if (provider === 'woocommerce' && new URL(store).protocol !== 'https:') throw Error('HTTPS WooCommerce store required'); return JSON.parse(raw.toString('utf8')); } export class CommerceAdapter { constructor({ provider, store, secret, database, readOrder, resolveContact, client, }) { if (!database || database === ':memory:') throw Error('Durable database path required'); if (typeof readOrder !== 'function' || typeof resolveContact !== 'function') throw Error('Authenticated order lookup and contact resolver required'); Object.assign(this, { provider, store, secret, readOrder, resolveContact, client, }); this.db = new DatabaseSync(database); this.db .exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS commerce_config(key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS commerce_inbox(id TEXT PRIMARY KEY,order_id TEXT NOT NULL,topic TEXT NOT NULL,received_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',attempts INTEGER NOT NULL DEFAULT 0,due_at INTEGER NOT NULL DEFAULT 0,error TEXT); CREATE TABLE IF NOT EXISTS commerce_outbox(id TEXT PRIMARY KEY,body TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',attempts INTEGER NOT NULL DEFAULT 0,due_at INTEGER NOT NULL DEFAULT 0,result_id TEXT,error TEXT); `); const scope = JSON.stringify([provider, store]); this.db .prepare('INSERT OR IGNORE INTO commerce_config VALUES(?,?)') .run('scope', scope); if ( this.db .prepare('SELECT value FROM commerce_config WHERE key=?') .get('scope').value !== scope ) throw Error('Use a separate database for each provider/store'); } accept(raw, headers) { const payload = verifyCommerceSignature( this.provider, raw, headers, this.secret, this.store, ), h = new Headers(headers); const topic = h.get( this.provider === 'shopify' ? 'x-shopify-topic' : 'x-wc-webhook-topic', ); const allowed = this.provider === 'shopify' ? [ 'orders/create', 'orders/paid', 'orders/updated', 'refunds/create', 'fulfillments/create', ] : ['order.created', 'order.updated']; if (!allowed.includes(topic)) throw Error('Unsupported webhook topic'); // Topic headers are unsigned. They never establish payment state; worker re-reads authoritative order. const orderId = id(payload.order_id || payload.id), key = digest(this.store + ':' + digest(raw)); this.db .prepare( 'INSERT OR IGNORE INTO commerce_inbox(id,order_id,topic,received_at) VALUES(?,?,?,?)', ) .run(key, orderId, topic, new Date().toISOString()); return { accepted: true, id: key }; } async drain() { const now = Date.now(); for (const row of this.db .prepare( "SELECT * FROM commerce_inbox WHERE state='pending' AND due_at<=? ORDER BY received_at LIMIT 50", ) .all(now)) { try { const order = await this.readOrder(row.order_id); if (id(order.id) !== row.order_id) throw Error('Provider returned a different order'); const contact = await this.resolveContact(order); if (!/^[0-9a-f-]{36}$/i.test(contact || '')) throw Error('Map customer and record consent before processing'); const events = commerceEvents( this.provider, this.store, order, contact, ); this.db.exec('BEGIN IMMEDIATE'); try { for (const event of events) this.db .prepare( 'INSERT OR IGNORE INTO commerce_outbox(id,body) VALUES(?,?)', ) .run(event.source_event_id, JSON.stringify(event)); this.db .prepare( "UPDATE commerce_inbox SET state='processed',error=NULL WHERE id=?", ) .run(row.id); this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } } catch { this.db .prepare( "UPDATE commerce_inbox SET attempts=attempts+1,due_at=?,error='Order verification or customer mapping failed; inspect merchant backend',state=CASE WHEN attempts>=11 THEN 'review' ELSE 'pending' END WHERE id=?", ) .run( now + Math.min(3600000, 30000 * 2 ** Math.min(row.attempts, 7)), row.id, ); } } for (const row of this.db .prepare( "SELECT * FROM commerce_outbox WHERE state='pending' AND due_at<=? LIMIT 50", ) .all(now)) { try { const result = await this.client.submitEvent( JSON.parse(row.body), 'commerce:' + row.id, ); this.db .prepare( "UPDATE commerce_outbox SET state='admitted',result_id=?,error=NULL WHERE id=?", ) .run(result.data.id, row.id); } catch (e) { const retry = !e.status || e.status === 429 || e.status >= 500 || e.retryable === true; const delay = Math.max( Number(e.retryAfter || 0) * 1000, Math.min(3600000, 30000 * 2 ** Math.min(row.attempts, 7)), ); this.db .prepare( 'UPDATE commerce_outbox SET attempts=attempts+1,due_at=?,state=?,error=? WHERE id=?', ) .run( now + delay, retry ? 'pending' : 'review', e.code || 'transport_outcome_unconfirmed', row.id, ); } } return this.status(); } status() { return { inbox: this.db .prepare( 'SELECT state,count(*) AS count FROM commerce_inbox GROUP BY state', ) .all(), outbox: this.db .prepare( 'SELECT state,count(*) AS count FROM commerce_outbox GROUP BY state', ) .all(), }; } close() { this.db.close(); } } export function commerceEvents(provider, store, o, contact) { const woo = provider === 'woocommerce', orderId = id(o.id), customer = woo ? [o.billing?.first_name, o.billing?.last_name].filter(Boolean).join(' ') : [o.customer?.first_name, o.customer?.last_name] .filter(Boolean) .join(' '); const currency = String(o.currency || ''); if (!/^[A-Z]{3}$/.test(currency)) throw Error('Valid currency required'); const amount = String(woo ? o.total : o.total_price); if (!/^\d+(\.\d{1,2})?$/.test(amount)) throw Error('Valid order total required'); const created = woo ? o.date_created_gmt : o.created_at; if (!created) throw Error('Provider creation time required'); const date = (v) => { const d = new Date(woo && !/[zZ]|[+-]\d\d:\d\d$/.test(v) ? v + 'Z' : v); if (!Number.isFinite(d.getTime())) throw Error('Provider timestamp required'); return d.toISOString(); }; const base = { customer_name: customer || 'Customer', order_id: String(woo ? o.number || orderId : o.name || orderId), amount, currency, }, events = []; function emit(event, businessId, at, data) { events.push({ source_event_id: digest( JSON.stringify([provider, store, orderId, event, businessId]), ), event, schema_version: '1', occurred_at: date(at), contact_id: contact, data, }); } emit('order.created', orderId, created, base); const paid = woo ? ['processing', 'completed'].includes(o.status) && Boolean(o.date_paid_gmt) && Boolean(o.transaction_id) : o.financial_status === 'paid'; if (paid) emit( 'payment.success', orderId, woo ? o.date_paid_gmt : o.processed_at || created, { ...base, payment_id: String(woo ? o.transaction_id : orderId) }, ); if (woo && o.status === 'failed' && !o.date_paid_gmt) emit('payment.failed', orderId, o.date_modified_gmt || created, base); // Shopify fulfilments are verified from the current order, never an unsigned topic header. if (!woo) for (const f of o.fulfillments || []) { if (f.status !== 'success') continue; const url = f.tracking_url || ''; if (!url.startsWith('https://')) continue; emit('order.shipped', id(f.id), f.created_at, { customer_name: base.customer_name, order_id: base.order_id, tracking_url: url, carrier: f.tracking_company || 'Courier', }); } // Refund notifications need the verified refund transaction, not just an order refund flag. if (!woo) for (const r of o.refunds || []) for (const t of r.transactions || []) { if ( t.kind === 'refund' && t.status === 'success' && /^\d+(\.\d{1,2})?$/.test(String(t.amount)) ) emit('payment.refunded', id(t.id), t.created_at || r.created_at, { customer_name: base.customer_name, order_id: base.order_id, refund_id: String(r.id), amount: String(t.amount), currency: String(t.currency || currency), }); } return events; }