mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
Add packages/shopify (@hanzo/shopify): a Shopify embedded admin app that puts
Hanzo AI over the store as system of record.
- Product content: generate/rewrite descriptions + SEO title/meta from product
attributes, then write back via the productUpdate mutation (only changed fields).
- Orders/support: summarize an order, draft a customer reply, extract issues.
- Ask: freeform question over a product or order, grounded in its data.
Server (src/server, holds the only secrets):
- oauth.ts Shopify OAuth request shaping + OAuth-callback HMAC verify (hex over
Shopify-canonical URLSearchParams form) + state (CSRF) check.
- shopify-api.ts Admin GraphQL wrappers (getProduct/getOrder/updateProduct) —
pure request shaping + parsing + productUpdate userErrors.
- hanzo.ts thin over the PUBLISHED @hanzo/ai createAiClient; product/order
context assembly + prompt building. No transport reimplemented.
- actions.ts product + order AI action catalogs, one prompt each over ask.
- webhooks.ts webhook HMAC verify (base64 over RAW body) + topic routing
(orders/create, app/uninstalled, the 3 GDPR topics).
- server.ts OAuth + /v1/* proxy the frontend calls + /webhooks. HMAC verified
before any param/body is trusted; shop validated to *.myshopify.com.
Frontend (src/app): Polaris + App Bridge React panel — api.ts (proxy client,
no secrets), context.ts (launch context → GID), session-fetch.ts (App Bridge
session-token fetch), App/Assistant/main. App Bridge loaded from Shopify CDN;
public API key stamped into the app, secret never bundled.
Plus shopify.app.toml, an admin-link extension, build.js (esbuild: app bundle +
Node server), tsconfig, vitest config, README. 108 vitest tests (pure), tsc
--noEmit clean, build green. Model calls via @hanzo/ai to api.hanzo.ai/v1 only.
Register packages/shopify in pnpm-workspace.yaml.
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { createHmac } from 'node:crypto';
|
|
import {
|
|
computeWebhookHmac,
|
|
constantTimeEqualB64,
|
|
verifyWebhook,
|
|
routeWebhook,
|
|
} from '../src/server/webhooks';
|
|
|
|
const SECRET = 'shpss_app_secret_abc';
|
|
|
|
// The reference computation Shopify performs for a webhook:
|
|
// base64(HMAC-SHA256(secret, rawBody)).
|
|
function refB64(secret: string, body: string): string {
|
|
return createHmac('sha256', secret).update(body, 'utf8').digest('base64');
|
|
}
|
|
|
|
describe('computeWebhookHmac', () => {
|
|
it('is base64(HMAC-SHA256(secret, rawBody)) — matches the Shopify reference', () => {
|
|
const body = '{"id":12345}';
|
|
expect(computeWebhookHmac(SECRET, body)).toBe(refB64(SECRET, body));
|
|
});
|
|
it('is base64-shaped and deterministic', () => {
|
|
const a = computeWebhookHmac(SECRET, 'x');
|
|
expect(a).toBe(computeWebhookHmac(SECRET, 'x'));
|
|
expect(a).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
|
});
|
|
});
|
|
|
|
describe('constantTimeEqualB64', () => {
|
|
it('true for equal, false for different / different-length without throwing', () => {
|
|
const a = refB64(SECRET, 'x');
|
|
expect(constantTimeEqualB64(a, a)).toBe(true);
|
|
expect(constantTimeEqualB64(a, refB64(SECRET, 'y'))).toBe(false);
|
|
expect(constantTimeEqualB64(a, 'AAAA')).toBe(false);
|
|
expect(constantTimeEqualB64('', a)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('verifyWebhook (trust gate)', () => {
|
|
const body = JSON.stringify({ id: 12345, admin_graphql_api_id: 'gid://shopify/Order/12345' });
|
|
const good = refB64(SECRET, body);
|
|
|
|
it('accepts a webhook signed with the app secret', () => {
|
|
expect(verifyWebhook(SECRET, good, body)).toBe(true);
|
|
});
|
|
it('rejects a tampered body', () => {
|
|
expect(verifyWebhook(SECRET, good, body + ' ')).toBe(false);
|
|
});
|
|
it('rejects a wrong secret', () => {
|
|
expect(verifyWebhook('other', good, body)).toBe(false);
|
|
});
|
|
it('rejects a missing header', () => {
|
|
expect(verifyWebhook(SECRET, undefined, body)).toBe(false);
|
|
});
|
|
it('rejects an empty secret', () => {
|
|
expect(verifyWebhook('', good, body)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('routeWebhook', () => {
|
|
it('routes orders/create to an actionable event with the GID', () => {
|
|
const action = routeWebhook('orders/create', 'my-store.myshopify.com', {
|
|
id: 12345,
|
|
admin_graphql_api_id: 'gid://shopify/Order/12345',
|
|
});
|
|
expect(action.kind).toBe('order_created');
|
|
if (action.kind === 'order_created') {
|
|
expect(action.shop).toBe('my-store.myshopify.com');
|
|
expect(action.orderId).toBe('gid://shopify/Order/12345');
|
|
}
|
|
});
|
|
it('falls back to the numeric id when no GID is present', () => {
|
|
const action = routeWebhook('orders/create', 's.myshopify.com', { id: 99 });
|
|
if (action.kind === 'order_created') expect(action.orderId).toBe('99');
|
|
});
|
|
it('routes app/uninstalled', () => {
|
|
const action = routeWebhook('app/uninstalled', 's.myshopify.com', {});
|
|
expect(action.kind).toBe('app_uninstalled');
|
|
if (action.kind === 'app_uninstalled') expect(action.shop).toBe('s.myshopify.com');
|
|
});
|
|
it.each(['customers/data_request', 'customers/redact', 'shop/redact'])(
|
|
'routes the GDPR topic %s',
|
|
(topic) => {
|
|
const action = routeWebhook(topic, 's.myshopify.com', {});
|
|
expect(action.kind).toBe('gdpr');
|
|
if (action.kind === 'gdpr') expect(action.topic).toBe(topic);
|
|
},
|
|
);
|
|
it('ignores an unknown topic (still a clean ack)', () => {
|
|
const action = routeWebhook('products/update', 's.myshopify.com', {});
|
|
expect(action.kind).toBe('ignored');
|
|
if (action.kind === 'ignored') expect(action.topic).toBe('products/update');
|
|
});
|
|
it('is case-insensitive on the topic header', () => {
|
|
expect(routeWebhook('ORDERS/CREATE', 's.myshopify.com', { id: 1 }).kind).toBe('order_created');
|
|
});
|
|
});
|