Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions examples/ecommerce/shopify-guardrail/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions examples/ecommerce/shopify-guardrail/env.example
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions examples/ecommerce/shopify-guardrail/package.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
85 changes: 85 additions & 0 deletions examples/ecommerce/shopify-guardrail/src/aport.js
Original file line number Diff line number Diff line change
@@ -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 };
}
72 changes: 72 additions & 0 deletions examples/ecommerce/shopify-guardrail/src/app.js
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions examples/ecommerce/shopify-guardrail/src/config.js
Original file line number Diff line number Diff line change
@@ -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(', ')}`);
}
}
11 changes: 11 additions & 0 deletions examples/ecommerce/shopify-guardrail/src/server.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
84 changes: 84 additions & 0 deletions examples/ecommerce/shopify-guardrail/src/shopify.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading