diff --git a/examples/ecommerce/shopify-guardrail/README.md b/examples/ecommerce/shopify-guardrail/README.md new file mode 100644 index 0000000..adbc085 --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/README.md @@ -0,0 +1,58 @@ +# APort Shopify Refund Guardrail + +This example is a deployable Shopify webhook app that checks refund events against the APort `payments.refund.v1` policy before a team treats the refund as approved. + +## What It Does + +- Verifies Shopify refund webhook signatures with the `X-Shopify-Hmac-Sha256` header. +- Fetches the related Shopify order when an Admin API token is configured. +- Sends refund amount, order context, and agent passport details to APort `/v1/verify`. +- Allows policy-approved refunds. +- Tags denied refunds with `aport-refund-review` so operators can review them manually. +- Returns clear JSON outcomes for approved, held, invalid, and verification-failed requests. + +## Setup + +```bash +cd examples/ecommerce/shopify-guardrail +cp env.example .env +npm install +npm start +``` + +Register the webhook URL in Shopify: + +```text +POST https://your-domain.example/webhooks/refunds/create +Topic: refunds/create +Format: JSON +``` + +## Environment + +| Variable | Required | Description | +| --- | --- | --- | +| `SHOPIFY_WEBHOOK_SECRET` | yes | Shopify webhook signing secret. | +| `SHOPIFY_SHOP_DOMAIN` | yes | Store domain, for example `your-store.myshopify.com`. | +| `SHOPIFY_ADMIN_ACCESS_TOKEN` | no | Admin API token used to fetch and tag orders. | +| `APORT_API_BASE_URL` | no | Defaults to `https://api.aport.io`. | +| `APORT_API_KEY` | yes | APort API key. | +| `APORT_POLICY_ID` | no | Defaults to `payments.refund.v1`. | +| `APORT_AGENT_PASSPORT_ID` | no | Passport ID used for the refund automation agent. | +| `REFUND_HOLD_TAG` | no | Tag applied when APort denies a refund. | + +## Verification Flow + +1. Shopify sends a `refunds/create` webhook. +2. The app rejects the request unless the HMAC signature is valid. +3. The app builds an APort verification payload with the refund amount, currency, order ID, refund ID, shop, and policy ID. +4. If APort returns `allowed: true` or `decision: "allow"`, the webhook returns `approved`. +5. Otherwise the app tags the order for manual review and returns `held_for_review`. + +## Tests + +```bash +npm test +``` + +The test suite covers invalid webhook signatures, approved refunds, denied refunds with Shopify order tagging, and the APort payload builder. diff --git a/examples/ecommerce/shopify-guardrail/env.example b/examples/ecommerce/shopify-guardrail/env.example new file mode 100644 index 0000000..19ab24a --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/env.example @@ -0,0 +1,9 @@ +PORT=3000 +SHOPIFY_WEBHOOK_SECRET=replace-with-shopify-webhook-secret +SHOPIFY_SHOP_DOMAIN=your-store.myshopify.com +SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_replace_with_admin_token +APORT_API_BASE_URL=https://api.aport.io +APORT_API_KEY=aport_live_replace_with_api_key +APORT_POLICY_ID=payments.refund.v1 +APORT_AGENT_PASSPORT_ID=shopify-refund-agent +REFUND_HOLD_TAG=aport-refund-review diff --git a/examples/ecommerce/shopify-guardrail/package.json b/examples/ecommerce/shopify-guardrail/package.json new file mode 100644 index 0000000..4952325 --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/package.json @@ -0,0 +1,15 @@ +{ + "name": "aport-shopify-refund-guardrail", + "version": "0.1.0", + "description": "Shopify refund webhook guardrail using APort policy verification", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js", + "test": "node --test tests/*.test.js" + }, + "dependencies": { + "express": "^4.19.2" + }, + "devDependencies": {} +} diff --git a/examples/ecommerce/shopify-guardrail/src/aport.js b/examples/ecommerce/shopify-guardrail/src/aport.js new file mode 100644 index 0000000..586963f --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/src/aport.js @@ -0,0 +1,85 @@ +export class APortVerificationError extends Error { + constructor(message, details = undefined) { + super(message); + this.name = 'APortVerificationError'; + this.details = details; + } +} + +export function buildRefundVerificationPayload(refund, order, config) { + const refundTotal = calculateRefundTotal(refund); + + return { + policyId: config.aportPolicyId, + passportId: config.aportAgentPassportId, + action: 'payments.refund.v1', + resource: { + type: 'shopify_refund', + shop: config.shopifyShopDomain, + orderId: String(refund.order_id || order?.id || ''), + refundId: String(refund.id || ''), + }, + context: { + refund, + order, + amount: refundTotal.amount, + currency: refundTotal.currency, + createdAt: refund.created_at, + }, + }; +} + +export async function verifyRefundWithAPort(refund, order, config, fetchImpl = globalThis.fetch) { + if (!fetchImpl) { + throw new APortVerificationError('No fetch implementation is available'); + } + + const response = await fetchImpl(`${config.aportApiBaseUrl}/v1/verify`, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.aportApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildRefundVerificationPayload(refund, order, config)), + }); + + let body; + try { + body = await response.json(); + } catch { + body = {}; + } + + if (!response.ok) { + throw new APortVerificationError('APort verification request failed', { + status: response.status, + body, + }); + } + + return { + allowed: body.allowed === true || body.decision === 'allow', + decision: body.decision || (body.allowed ? 'allow' : 'deny'), + reason: body.reason || body.message || 'No reason provided', + raw: body, + }; +} + +function calculateRefundTotal(refund) { + const transactions = Array.isArray(refund.transactions) ? refund.transactions : []; + const refundLineItems = Array.isArray(refund.refund_line_items) ? refund.refund_line_items : []; + + const transactionTotal = transactions + .filter((transaction) => ['refund', 'suggested_refund'].includes(transaction.kind)) + .reduce((sum, transaction) => sum + Number(transaction.amount || 0), 0); + + const lineItemTotal = refundLineItems + .reduce((sum, item) => sum + Number(item.subtotal || item.total_tax || 0), 0); + + const amount = transactionTotal || lineItemTotal; + const currency = transactions.find((transaction) => transaction.currency)?.currency + || refund.currency + || 'USD'; + + return { amount, currency }; +} diff --git a/examples/ecommerce/shopify-guardrail/src/app.js b/examples/ecommerce/shopify-guardrail/src/app.js new file mode 100644 index 0000000..f11416d --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/src/app.js @@ -0,0 +1,72 @@ +import express from 'express'; +import { verifyRefundWithAPort } from './aport.js'; +import { fetchShopifyOrder, tagOrderForReview, verifyShopifyWebhook } from './shopify.js'; + +export function createApp(config, dependencies = {}) { + const app = express(); + const fetchImpl = dependencies.fetch || globalThis.fetch; + const logger = dependencies.logger || console; + + app.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + + app.post('/webhooks/refunds/create', express.raw({ type: 'application/json' }), async (req, res) => { + const hmac = req.header('x-shopify-hmac-sha256'); + + if (!verifyShopifyWebhook(req.body, hmac, config.shopifyWebhookSecret)) { + return res.status(401).json({ error: 'Invalid Shopify webhook signature' }); + } + + let refund; + try { + refund = JSON.parse(req.body.toString('utf8')); + } catch { + return res.status(400).json({ error: 'Invalid JSON webhook payload' }); + } + + try { + const order = await fetchShopifyOrder(refund.order_id, config, fetchImpl); + const decision = await verifyRefundWithAPort(refund, order, config, fetchImpl); + + if (!decision.allowed) { + await tagOrderForReview(refund.order_id, config, fetchImpl); + logger.warn('Refund held for review by APort policy', { + orderId: refund.order_id, + refundId: refund.id, + reason: decision.reason, + }); + + return res.status(202).json({ + status: 'held_for_review', + decision: decision.decision, + reason: decision.reason, + }); + } + + logger.info('Refund approved by APort policy', { + orderId: refund.order_id, + refundId: refund.id, + }); + + return res.json({ + status: 'approved', + decision: decision.decision, + reason: decision.reason, + }); + } catch (error) { + logger.error('Refund guardrail failed', { + orderId: refund.order_id, + refundId: refund.id, + error: error.message, + }); + + return res.status(502).json({ + status: 'verification_failed', + error: 'Refund verification is temporarily unavailable', + }); + } + }); + + return app; +} diff --git a/examples/ecommerce/shopify-guardrail/src/config.js b/examples/ecommerce/shopify-guardrail/src/config.js new file mode 100644 index 0000000..49d71a5 --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/src/config.js @@ -0,0 +1,25 @@ +export function loadConfig(env = process.env) { + return { + port: Number(env.PORT || 3000), + shopifyWebhookSecret: env.SHOPIFY_WEBHOOK_SECRET, + shopifyShopDomain: env.SHOPIFY_SHOP_DOMAIN, + shopifyAdminAccessToken: env.SHOPIFY_ADMIN_ACCESS_TOKEN, + aportApiBaseUrl: env.APORT_API_BASE_URL || 'https://api.aport.io', + aportApiKey: env.APORT_API_KEY, + aportPolicyId: env.APORT_POLICY_ID || 'payments.refund.v1', + aportAgentPassportId: env.APORT_AGENT_PASSPORT_ID || 'shopify-refund-agent', + refundHoldTag: env.REFUND_HOLD_TAG || 'aport-refund-review', + }; +} + +export function assertRequiredConfig(config) { + const missing = [ + ['SHOPIFY_WEBHOOK_SECRET', config.shopifyWebhookSecret], + ['SHOPIFY_SHOP_DOMAIN', config.shopifyShopDomain], + ['APORT_API_KEY', config.aportApiKey], + ].filter(([, value]) => !value); + + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.map(([key]) => key).join(', ')}`); + } +} diff --git a/examples/ecommerce/shopify-guardrail/src/server.js b/examples/ecommerce/shopify-guardrail/src/server.js new file mode 100644 index 0000000..e3ad5c2 --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/src/server.js @@ -0,0 +1,11 @@ +import { createApp } from './app.js'; +import { assertRequiredConfig, loadConfig } from './config.js'; + +const config = loadConfig(); +assertRequiredConfig(config); + +const app = createApp(config); + +app.listen(config.port, () => { + console.log(`APort Shopify refund guardrail listening on port ${config.port}`); +}); diff --git a/examples/ecommerce/shopify-guardrail/src/shopify.js b/examples/ecommerce/shopify-guardrail/src/shopify.js new file mode 100644 index 0000000..7a7c00f --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/src/shopify.js @@ -0,0 +1,84 @@ +import crypto from 'node:crypto'; + +export class ShopifyWebhookError extends Error { + constructor(message) { + super(message); + this.name = 'ShopifyWebhookError'; + } +} + +export function verifyShopifyWebhook(rawBody, hmacHeader, secret) { + if (!secret) { + throw new ShopifyWebhookError('SHOPIFY_WEBHOOK_SECRET is required'); + } + + if (!hmacHeader) { + return false; + } + + const digest = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('base64'); + + const expected = Buffer.from(digest, 'utf8'); + const received = Buffer.from(hmacHeader, 'utf8'); + + return expected.length === received.length && crypto.timingSafeEqual(expected, received); +} + +export async function fetchShopifyOrder(orderId, config, fetchImpl = globalThis.fetch) { + if (!orderId || !config.shopifyAdminAccessToken) { + return null; + } + + const response = await fetchImpl( + `https://${config.shopifyShopDomain}/admin/api/2024-10/orders/${orderId}.json`, + { + headers: { + 'X-Shopify-Access-Token': config.shopifyAdminAccessToken, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new ShopifyWebhookError(`Failed to fetch Shopify order ${orderId}`); + } + + const body = await response.json(); + return body.order || null; +} + +export async function tagOrderForReview(orderId, config, fetchImpl = globalThis.fetch) { + if (!orderId || !config.shopifyAdminAccessToken || !config.refundHoldTag) { + return; + } + + const order = await fetchShopifyOrder(orderId, config, fetchImpl); + const existingTags = order?.tags + ? order.tags.split(',').map((tag) => tag.trim()).filter(Boolean) + : []; + + if (existingTags.includes(config.refundHoldTag)) { + return; + } + + const tags = [...existingTags, config.refundHoldTag].join(', '); + + const response = await fetchImpl( + `https://${config.shopifyShopDomain}/admin/api/2024-10/orders/${orderId}.json`, + { + method: 'PUT', + headers: { + 'X-Shopify-Access-Token': config.shopifyAdminAccessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ order: { id: orderId, tags } }), + }, + ); + + if (!response.ok) { + throw new ShopifyWebhookError(`Failed to tag Shopify order ${orderId}`); + } +} diff --git a/examples/ecommerce/shopify-guardrail/tests/app.test.js b/examples/ecommerce/shopify-guardrail/tests/app.test.js new file mode 100644 index 0000000..1bb1145 --- /dev/null +++ b/examples/ecommerce/shopify-guardrail/tests/app.test.js @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { test } from 'node:test'; +import { createApp } from '../src/app.js'; +import { buildRefundVerificationPayload } from '../src/aport.js'; + +const config = { + shopifyWebhookSecret: 'top-secret', + shopifyShopDomain: 'demo.myshopify.com', + shopifyAdminAccessToken: 'shpat_test', + aportApiBaseUrl: 'https://aport.example', + aportApiKey: 'aport_test', + aportPolicyId: 'payments.refund.v1', + aportAgentPassportId: 'shopify-refund-agent', + refundHoldTag: 'aport-refund-review', +}; + +test('rejects webhooks with invalid Shopify HMAC', async () => { + const app = createApp(config, { fetch: async () => assert.fail('fetch should not be called') }); + const server = app.listen(0); + + try { + const response = await fetch(url(server), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-shopify-hmac-sha256': 'bad-signature', + }, + body: JSON.stringify({ id: 1, order_id: 2 }), + }); + + assert.equal(response.status, 401); + } finally { + server.close(); + } +}); + +test('approves refund when APort allows the payments.refund.v1 action', async () => { + const calls = []; + const refund = { id: 1001, order_id: 2002, transactions: [{ kind: 'refund', amount: '42.50', currency: 'USD' }] }; + const app = createApp(config, { fetch: mockFetch(calls, { allowed: true, reason: 'within policy' }) }); + const server = app.listen(0); + + try { + const response = await fetch(url(server), signedRequest(refund)); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.status, 'approved'); + assert.equal(calls.length, 2); + assert.equal(JSON.parse(calls[1].body).action, 'payments.refund.v1'); + } finally { + server.close(); + } +}); + +test('holds refund and tags order when APort denies the request', async () => { + const calls = []; + const refund = { id: 1001, order_id: 2002, transactions: [{ kind: 'refund', amount: '125.00', currency: 'USD' }] }; + const app = createApp(config, { fetch: mockFetch(calls, { allowed: false, reason: 'amount above limit' }) }); + const server = app.listen(0); + + try { + const response = await fetch(url(server), signedRequest(refund)); + const body = await response.json(); + + assert.equal(response.status, 202); + assert.equal(body.status, 'held_for_review'); + assert.equal(calls.length, 4); + assert.equal(JSON.parse(calls[3].body).order.tags, 'existing, aport-refund-review'); + } finally { + server.close(); + } +}); + +test('buildRefundVerificationPayload includes refund amount and policy metadata', () => { + const payload = buildRefundVerificationPayload( + { id: 10, order_id: 20, transactions: [{ kind: 'refund', amount: '12.34', currency: 'USD' }] }, + { id: 20 }, + config, + ); + + assert.equal(payload.policyId, 'payments.refund.v1'); + assert.equal(payload.passportId, 'shopify-refund-agent'); + assert.equal(payload.context.amount, 12.34); + assert.equal(payload.resource.type, 'shopify_refund'); +}); + +function signedRequest(payload) { + const body = JSON.stringify(payload); + const hmac = crypto + .createHmac('sha256', config.shopifyWebhookSecret) + .update(Buffer.from(body)) + .digest('base64'); + + return { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-shopify-hmac-sha256': hmac, + }, + body, + }; +} + +function mockFetch(calls, aportDecision) { + return async (requestUrl, options = {}) => { + calls.push({ requestUrl: String(requestUrl), ...options }); + + if (String(requestUrl).includes('/admin/api/')) { + if (options.method === 'PUT') { + return jsonResponse({ order: { id: 2002 } }); + } + return jsonResponse({ order: { id: 2002, tags: 'existing' } }); + } + + return jsonResponse(aportDecision); + }; +} + +function jsonResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, + }; +} + +function url(server) { + const { port } = server.address(); + return `http://127.0.0.1:${port}/webhooks/refunds/create`; +}