Merge remote-tracking branch 'origin/feat/shopify'

This commit is contained in:
Hanzo Dev
2026-07-03 20:54:07 -07:00
31 changed files with 3489 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
# Hanzo AI for Shopify
AI for your Shopify store, where the store is the system of record. An embedded
admin app (Polaris + App Bridge) over a small Node backend:
- **Product content** — generate or rewrite product **descriptions**, and write
**SEO titles / meta descriptions**, from the product's own attributes, then
**write the result back** to the product (`productUpdate`).
- **Orders & support** — summarize an order, draft a customer reply, and extract
issues from an order's note.
- **Ask** — a freeform question about a product or an order, grounded in its data.
Every model call goes through the **published `@hanzo/ai`** headless client to the
Hanzo gateway (`https://api.hanzo.ai/v1`, a Zen model by default). Shopify data
goes through the **Admin GraphQL API**. The backend holds the Shopify API secret
and the Hanzo key; the React frontend never sees a secret.
## Architecture
```
Shopify Admin ──(iframe, App Bridge)──► Polaris React app (src/app/)
│ session-token fetch
Node backend (src/server/)
┌───────────────┼────────────────────┐
│ │ │
Shopify OAuth Admin GraphQL API @hanzo/ai ──► api.hanzo.ai/v1
+ webhook HMAC (product / order / (createAiClient)
verification productUpdate)
```
- `src/server/oauth.ts` — OAuth request shaping + **OAuth-callback HMAC** verify
(hex over sorted query params) + state (CSRF) check.
- `src/server/shopify-api.ts` — Admin GraphQL wrappers: `getProduct`, `getOrder`,
`updateProduct` (the `productUpdate` mutation) — pure request shaping + parsing.
- `src/server/hanzo.ts` — thin over **`@hanzo/ai`** (`createAiClient`); owns
product/order context assembly + prompt building. Never reimplements transport.
- `src/server/actions.ts` — the AI action catalog (product content + order/support),
one prompt each over the single ask primitive.
- `src/server/webhooks.ts`**webhook HMAC** verify (base64 over the RAW body) +
topic routing (`orders/create`, `app/uninstalled`, the three GDPR topics).
- `src/server/server.ts` — the HTTP service; the ONLY place the secret + Hanzo key
live. OAuth, the `/v1/*` proxy the frontend calls, and `/webhooks`.
- `src/app/` — the Polaris + App Bridge admin panel (`api.ts`, `context.ts`,
`session-fetch.ts`, `App.tsx`, `Assistant.tsx`, `main.tsx`).
All logic-heavy code is **pure and unit-tested**; the HTTP server and the React
DOM glue are thin over it.
## Create the Shopify app (Partner Dashboard)
1. **Partners → Apps → Create app → Create app manually.** Note the **API key**
(public — the OAuth `client_id`) and the **API secret key** (server-only — it
signs the OAuth exchange AND is the HMAC key for the callback and every
webhook).
2. **App setup → App URL:** `https://shopify.hanzo.ai/` (where the embedded app
is served — see Deploy).
3. **App setup → Allowed redirection URL(s):** add
`https://shopify.hanzo.ai/oauth/callback` (the exact `SHOPIFY_REDIRECT_URI`).
4. **Embedded app:** leave "Embed app in Shopify admin" **on** (`embedded = true`).
Or manage it all from `shopify.app.toml`:
```bash
npm i -g @shopify/cli
shopify app config link # bind shopify.app.toml to your Partner app
shopify app deploy # push scopes, URLs, webhooks, and the admin-link extension
```
Put your Partner **API key** into `shopify.app.toml`'s `client_id` (it is public).
The **secret** never goes in the toml — only in the server environment.
### OAuth scopes
`read_products, write_products, read_orders` — declared in `shopify.app.toml`
(`[access_scopes]`) and in `config.ts` (`OAUTH_SCOPES`). Read/write products for
content write-back; read orders for support + order insight. Nothing else.
## OAuth flow + HMAC (the trust gates)
1. **Install:** `GET /oauth/install?shop=<store>.myshopify.com` → the server
validates the shop domain (`isShopDomain`), mints a `state` nonce, and 302s to
`https://{shop}/admin/oauth/authorize?client_id&scope&redirect_uri&state`
(offline access — a long-lived token the webhook can use).
2. **Callback:** Shopify redirects to `/oauth/callback?code&hmac&shop&state&…`.
The server, **in order**:
- **verifies the `hmac`** — `hex(HMAC-SHA256(apiSecret, sortedParams))`,
constant-time compare (`verifyCallbackHmac`). A bad signature is a `401`
before anything else.
- checks `state` against the nonce it issued (CSRF).
- exchanges the `code` at `POST https://{shop}/admin/oauth/access_token`
(secret in the JSON body, over TLS) for the **offline access token**, stored
server-side keyed by shop.
3. **Webhooks:** `POST /webhooks` — the server **verifies the
`X-Shopify-Hmac-Sha256` header** — `base64(HMAC-SHA256(apiSecret, RAW_BODY))`,
computed over the raw bytes, constant-time compare (`verifyWebhook`). A bad
signature is a `401` before any action. Then it routes on `X-Shopify-Topic`.
> The OAuth callback HMAC (hex, sorted query params) and the webhook HMAC (base64,
> raw body) are **different** signatures — both keyed by the same API secret. Both
> are implemented and tested here.
## Product write-back flow
1. The panel loads a product: `GET /v1/product?shop=&id=` → Admin GraphQL
`product(id:)`.
2. The merchant clicks e.g. **Rewrite description**:
`POST /v1/product/action {shop,id,action,model}` → the server reads the product,
assembles a grounded prompt (the product's attributes, fenced as data), runs
`@hanzo/ai`, and returns the generated content.
3. The panel previews the content in an editable box. On **Save to product**:
`POST /v1/product/update {shop,id,descriptionHtml | seoTitle | seoDescription}`
→ the server sends the **`productUpdate`** mutation with **only the changed
field** (a description rewrite never clobbers the SEO title), surfacing any
`userErrors` as a real failure.
## Run it
```bash
pnpm install
pnpm build # → dist/index.html + dist/app.js + dist/app.css (app), dist/server.js (service)
pnpm test # vitest — the pure modules
pnpm typecheck # tsc --noEmit
# The service (holds the secret + Hanzo key):
SHOPIFY_API_KEY=... # public client id
SHOPIFY_API_SECRET=... # SERVER ONLY — OAuth + all HMAC
SHOPIFY_REDIRECT_URI=https://shopify.hanzo.ai/oauth/callback
HANZO_API_KEY=hk-... # optional — lets the webhook run a model call
PORT=8791
node dist/server.js
```
The app bundle is built with the **public** API key stamped into its
`shopify-api-key` meta tag (App Bridge needs it); the **secret is never bundled**
`SHOPIFY_API_SECRET` is read from the server's environment only.
```bash
SHOPIFY_API_KEY=<public-key> HANZO_SHOPIFY_BASE=https://shopify.hanzo.ai pnpm build
```
## Deploy (embedded app + App Store path)
- **App** (`dist/index.html` + `app.js` + `app.css`) is served as static assets
over `hanzoai/static` behind `hanzoai/ingress` at the **App URL**
(`https://shopify.hanzo.ai/`). Shopify loads it in the admin iframe; App Bridge
(loaded from Shopify's CDN) provides the session token the frontend sends to the
service.
- **Service** (`dist/server.js`) runs behind the same ingress: `/oauth/*`,
`/v1/*`, `/webhooks`, `/healthz`. In production, persist the per-shop offline
token and the OAuth `state` nonces to KMS/Valkey (the in-memory maps in
`server.ts` are single-instance).
- **Extension:** `extensions/product-assistant` is an admin **link** that adds
"Hanzo AI" to the product and order detail pages and deep-links the embedded app
with the resource in context. `shopify app deploy` ships it.
- **App Store:** an embedded app that (a) uses **session-token auth** (App Bridge),
(b) verifies **OAuth + webhook HMAC**, and (c) subscribes to the three
**GDPR/privacy webhooks** (`customers/data_request`, `customers/redact`,
`shop/redact` — all routed and acknowledged here) meets the core App Store
review requirements. Submit from the Partner Dashboard after `shopify app deploy`.
## Security notes
- The Shopify **API secret** and the **Hanzo key** live only in the server
environment — never in the app bundle, never in `shopify.app.toml`.
- Every OAuth callback and every webhook is **HMAC-verified** (constant-time)
before it is trusted.
- The `shop` parameter is validated against `*.myshopify.com` (`isShopDomain`)
before it is ever placed in a URL — no request can be pointed at an arbitrary
host.
- Model calls use `@hanzo/ai` against `api.hanzo.ai/v1` — no `/api/` prefix, one
gateway, the same wire shape as every other Hanzo surface.
+108
View File
@@ -0,0 +1,108 @@
// Build @hanzo/shopify into dist/: bundle the embedded Polaris/App-Bridge admin
// app (main.tsx) as ESM for the browser, stamp index.html with the app's public
// API key + entry + backend base, and bundle the OAuth + Admin-proxy + webhook
// server for Node. No framework — esbuild + Node stdlib only.
//
// node build.js → production build
// node build.js --watch → rebuild on change
// SHOPIFY_API_KEY=abc HANZO_SHOPIFY_BASE=https://x node build.js
//
// Output layout:
// dist/index.html dist/app.js dist/app.css (embedded admin app)
// dist/server.js (service)
//
// The app is HOSTED over hanzoai/static behind hanzoai/ingress at
// shopify.hanzo.ai (the app URL Shopify loads in the admin iframe); the server
// runs as a small service (OAuth callback + Admin proxy + webhooks). All model
// calls go to api.hanzo.ai regardless of the host base. App Bridge is loaded from
// Shopify's CDN (kept external), per Shopify's embedded-app requirement.
import esbuild from 'esbuild';
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = (process.env.HANZO_SHOPIFY_BASE || '').replace(/\/+$/, '');
// The PUBLIC Shopify API key (OAuth client id) — stamped into the app's meta tag
// so App Bridge can init. NEVER the secret (that stays server-side only).
const SHOPIFY_API_KEY = process.env.SHOPIFY_API_KEY || '__SHOPIFY_API_KEY__';
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const appSrc = join(src, 'app');
const dist = join(__dirname, 'dist');
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle the embedded admin app as ESM for the browser. React + Polaris are
// bundled in (the page is served standalone, no import map); App Bridge is
// external (loaded from Shopify's CDN as a global, never bundled).
const appCtx = await esbuild.context({
entryPoints: { app: join(appSrc, 'main.tsx') },
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
jsx: 'automatic',
loader: { '.css': 'css', '.svg': 'dataurl', '.png': 'dataurl', '.woff': 'dataurl', '.woff2': 'dataurl' },
define: { 'process.env.NODE_ENV': JSON.stringify(watch ? 'development' : 'production') },
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await appCtx.rebuild();
// Stamp index.html: the entry, the public API key, and the backend base.
const html = readFileSync(join(appSrc, 'index.html'), 'utf8')
.split('__ENTRY__').join('app.js')
.split('__SHOPIFY_API_KEY__').join(SHOPIFY_API_KEY)
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
writeFileSync(join(dist, 'index.html'), html);
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
const serverCtx = await esbuild.context({
entryPoints: [join(src, 'server', 'server.ts')],
outfile: join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
packages: 'external',
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
logLevel: 'info',
});
await serverCtx.rebuild();
console.log(`Hanzo AI for Shopify built -> dist/ (base ${BASE || 'same-origin'})`);
console.log(' App: dist/index.html · Service: dist/server.js');
if (watch) {
await appCtx.watch();
await serverCtx.watch();
console.log('watching...');
} else {
await appCtx.dispose();
await serverCtx.dispose();
}
}
// The Polaris CSS import (in main.tsx) also emits dist/app.css; esbuild names it
// after the entry, so no extra copy step is needed. Confirm it exists post-build.
build()
.then(() => {
if (!watch && !existsSync(join(dist, 'app.css'))) {
// Polaris ships its stylesheet via the '@shopify/polaris/build/esm/styles.css'
// import; esbuild bundles it to app.css. If a future Polaris path changes,
// fail loudly rather than ship an unstyled app.
console.warn('note: dist/app.css not emitted — check the Polaris styles import in main.tsx');
}
})
.catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,23 @@
# Admin link extension — adds "Hanzo AI" to the product and order detail pages so
# a merchant opens the assistant with that resource already in context. This is
# an admin *link* (the simplest, config-only surface): it deep-links the embedded
# app with ?resource=&id=, which the app reads via parseLaunchContext and jumps
# straight to. No JS bundle to build — the target is the embedded app itself.
# Docs: shopify.dev/docs/apps/build/admin/actions-blocks/build-admin-action
api_version = "2025-01"
[[extensions]]
name = "Hanzo AI"
handle = "hanzo-ai-assistant"
type = "admin_link"
# On a product page → open the app on the Products tab with that product.
[[extensions.targeting]]
target = "admin.product-details.action.link"
url = "app://?resource=product&id={{ resource.id }}"
# On an order page → open the app on the Orders tab with that order.
[[extensions.targeting]]
target = "admin.order-details.action.link"
url = "app://?resource=order&id={{ resource.id }}"
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@hanzo/shopify",
"version": "0.1.0",
"description": "Hanzo AI for Shopify — an embedded admin app: generate/rewrite product descriptions + SEO, summarize orders and draft customer replies, and ask about a product or order. A Polaris + App Bridge React panel over a Node backend that does Shopify OAuth, proxies the Admin GraphQL API, verifies every Shopify HMAC (OAuth callback + webhooks), and runs the model gateway through the published @hanzo/ai over api.hanzo.ai.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "^0.13.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"peerDependencies": {
"@shopify/app-bridge-react": "^4.1.6",
"@shopify/polaris": "^13.9.0",
"@shopify/shopify-api": "^11.0.0"
},
"devDependencies": {
"@shopify/app-bridge-react": "^4.1.6",
"@shopify/polaris": "^13.9.0",
"@shopify/shopify-api": "^11.0.0",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"shopify",
"ecommerce",
"product-content",
"seo",
"orders",
"ai",
"embedded-app",
"polaris",
"app-bridge"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/shopify"
}
}
+56
View File
@@ -0,0 +1,56 @@
# Hanzo AI for Shopify — app configuration (shopify app deploy reads this).
# The `client_id` is the app's PUBLIC API key from the Partner Dashboard; the
# API SECRET is never in this file — it lives only in the server's environment
# (SHOPIFY_API_SECRET). Docs: shopify.dev/docs/apps/build/cli-for-apps/app-configuration
#
# Fill `client_id` and the two URLs from your Partner app, then:
# shopify app config link # bind this toml to the Partner app
# shopify app deploy # push scopes, URLs, and the extension
name = "Hanzo AI"
handle = "hanzo-ai"
client_id = "REPLACE_WITH_PARTNER_APP_CLIENT_ID"
application_url = "https://shopify.hanzo.ai/"
embedded = true
[access_scopes]
# The only scopes the app uses: read/write products (content write-back) and
# read orders (support + order insight). Nothing we do not use.
scopes = "read_products,write_products,read_orders"
[auth]
# The OAuth callback the server verifies (HMAC + state) and exchanges the code at.
redirect_urls = [
"https://shopify.hanzo.ai/oauth/callback"
]
[webhooks]
api_version = "2025-01"
# Order creation → the server can auto-draft a support summary.
[[webhooks.subscriptions]]
topics = [ "orders/create" ]
uri = "https://shopify.hanzo.ai/webhooks"
# App uninstall → the server drops the shop's stored offline token.
[[webhooks.subscriptions]]
topics = [ "app/uninstalled" ]
uri = "https://shopify.hanzo.ai/webhooks"
# The three GDPR/privacy topics every public app must handle to pass review.
[[webhooks.subscriptions]]
topics = [ "customers/data_request", "customers/redact", "shop/redact" ]
uri = "https://shopify.hanzo.ai/webhooks"
[app_proxy]
# Not used — the embedded app talks to the server directly over its own host.
[pos]
embedded = false
[build]
# The embedded app is served as static assets over hanzoai/static; the CLI does
# not build it (build.js does). Point automatically_update_urls_on_dev off so a
# local `shopify app dev` never rewrites the production application_url.
automatically_update_urls_on_dev = false
include_config_on_deploy = true
+37
View File
@@ -0,0 +1,37 @@
// The app root: Polaris AppProvider wrapping the assistant. Polaris needs its
// AppProvider (theme + i18n) at the root; App Bridge is loaded by the CDN script
// in index.html (it registers the global `window.shopify`), so there is no React
// provider for it — components read the session token through the authenticated
// fetch built in main.tsx. This file is deliberately thin: it wires the provider
// and hands the built Api + launch context to the Assistant.
import { AppProvider, Page, Text, BlockStack, Banner } from '@shopify/polaris';
import enTranslations from '@shopify/polaris/locales/en.json';
import { Assistant } from './Assistant';
import type { Api } from './api';
import type { LaunchContext } from './context';
export interface AppProps {
api: Api;
ctx: LaunchContext;
}
export function App({ api, ctx }: AppProps): JSX.Element {
return (
<AppProvider i18n={enTranslations}>
<Page title="Hanzo AI">
<BlockStack gap="400">
<Text as="p" variant="bodyMd" tone="subdued">
Generate product content, handle orders, and ask about your store powered by the Hanzo model gateway.
</Text>
{!ctx.shop && (
<Banner tone="warning">
No shop context. Open this app from your Shopify admin so it can act on your store.
</Banner>
)}
<Assistant api={api} ctx={ctx} />
</BlockStack>
</Page>
</AppProvider>
);
}
+330
View File
@@ -0,0 +1,330 @@
// The Hanzo AI assistant panel — a Polaris React component. All the logic-heavy
// work (action catalogs, prompt assembly, request shaping, HMAC) lives in its
// own tested modules on the server + in api.ts; this component binds them to
// Polaris UI. It talks ONLY to the backend proxy (createApi) — it never holds a
// secret and never calls Shopify or api.hanzo.ai directly.
//
// Two tabs: Products (read a product, run a content action, preview, write back)
// and Orders (read an order, run a support action, copy). The action catalogs
// are the server's, mirrored here for labels so the buttons never drift from
// what the server will run.
import { useCallback, useEffect, useState } from 'react';
import {
Badge,
BlockStack,
Box,
Button,
ButtonGroup,
Card,
InlineStack,
Select,
Tabs,
Text,
TextField,
Banner,
} from '@shopify/polaris';
import type { Api, Product, Order } from './api';
import type { LaunchContext } from './context';
import { toGid } from './context';
// The action buttons the panel shows. Ids MUST match the server's PRODUCT_ACTIONS
// / ORDER_ACTIONS keys — the server validates the id, so a mismatch is a clean
// 400, but we keep them in sync so the UI is honest.
const PRODUCT_ACTIONS = [
{ id: 'writeDescription', label: 'Write description', target: 'descriptionHtml' as const },
{ id: 'rewriteDescription', label: 'Rewrite description', target: 'descriptionHtml' as const },
{ id: 'seoTitle', label: 'SEO title', target: 'seoTitle' as const },
{ id: 'seoDescription', label: 'SEO meta description', target: 'seoDescription' as const },
];
const ORDER_ACTIONS = [
{ id: 'summarize', label: 'Summarize order' },
{ id: 'draftReply', label: 'Draft customer reply' },
{ id: 'extractIssues', label: 'Extract issues' },
];
export interface AssistantProps {
api: Api;
ctx: LaunchContext;
}
export function Assistant({ api, ctx }: AssistantProps): JSX.Element {
const [tab, setTab] = useState(ctx.resource === 'order' ? 1 : 0);
const [models, setModels] = useState<string[]>([]);
const [model, setModel] = useState('');
useEffect(() => {
api
.listModels()
.then((ids) => {
setModels(ids);
if (ids.length) setModel((m) => m || ids[0]);
})
.catch(() => setModels([]));
}, [api]);
const modelSelect =
models.length > 0 ? (
<Select
label="Model"
labelInline
options={models.map((id) => ({ label: id, value: id }))}
value={model}
onChange={setModel}
/>
) : null;
const tabs = [
{ id: 'products', content: 'Products' },
{ id: 'orders', content: 'Orders' },
];
return (
<BlockStack gap="400">
<Tabs tabs={tabs} selected={tab} onSelect={setTab} />
<Box paddingInlineStart="400" paddingInlineEnd="400" paddingBlockEnd="400">
{tab === 0 ? (
<ProductPanel api={api} model={model} modelSelect={modelSelect} initialId={ctx.resource === 'product' ? ctx.resourceId : ''} />
) : (
<OrderPanel api={api} model={model} modelSelect={modelSelect} initialId={ctx.resource === 'order' ? ctx.resourceId : ''} />
)}
</Box>
</BlockStack>
);
}
// ---- Product panel --------------------------------------------------------
function ProductPanel(props: { api: Api; model: string; modelSelect: JSX.Element | null; initialId: string }): JSX.Element {
const { api, model, modelSelect, initialId } = props;
const [idInput, setIdInput] = useState(initialId);
const [product, setProduct] = useState<Product | null>(null);
const [output, setOutput] = useState('');
const [target, setTarget] = useState<'descriptionHtml' | 'seoTitle' | 'seoDescription'>('descriptionHtml');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ tone: 'success' | 'critical' | 'info'; text: string } | null>(null);
const load = useCallback(async () => {
const gid = toGid('Product', idInput);
if (!gid) return;
setBusy(true);
setStatus(null);
try {
setProduct(await api.getProduct(gid));
setOutput('');
} catch (e: any) {
setStatus({ tone: 'critical', text: e?.message || 'Failed to load product' });
setProduct(null);
} finally {
setBusy(false);
}
}, [api, idInput]);
useEffect(() => {
if (initialId) void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialId]);
const run = useCallback(
async (actionId: string, writeTarget: 'descriptionHtml' | 'seoTitle' | 'seoDescription') => {
if (!product) return;
setBusy(true);
setStatus(null);
setTarget(writeTarget);
try {
setOutput(await api.runProductAction(product.id, actionId, model));
} catch (e: any) {
setStatus({ tone: 'critical', text: e?.message || 'Generation failed' });
} finally {
setBusy(false);
}
},
[api, product, model],
);
const writeBack = useCallback(async () => {
if (!product || !output) return;
setBusy(true);
setStatus(null);
try {
const w =
target === 'descriptionHtml'
? { descriptionHtml: output }
: target === 'seoTitle'
? { seoTitle: output.trim() }
: { seoDescription: output.trim() };
await api.updateProduct(product.id, w);
setStatus({ tone: 'success', text: `Saved to ${target === 'descriptionHtml' ? 'description' : target === 'seoTitle' ? 'SEO title' : 'SEO meta description'}.` });
} catch (e: any) {
setStatus({ tone: 'critical', text: e?.message || 'Save failed' });
} finally {
setBusy(false);
}
}, [api, product, output, target]);
return (
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<InlineStack gap="200" align="start" blockAlign="end">
<Box width="70%">
<TextField
label="Product ID or GID"
value={idInput}
onChange={setIdInput}
autoComplete="off"
placeholder="gid://shopify/Product/123 or 123"
/>
</Box>
<Button onClick={() => void load()} loading={busy} variant="primary">
Load
</Button>
</InlineStack>
{product && (
<InlineStack gap="200" blockAlign="center">
<Text as="span" variant="headingSm">{product.title}</Text>
{product.status && <Badge>{product.status}</Badge>}
</InlineStack>
)}
</BlockStack>
</Card>
{product && (
<Card>
<BlockStack gap="300">
<InlineStack align="space-between" blockAlign="center">
<Text as="h3" variant="headingSm">Generate content</Text>
{modelSelect}
</InlineStack>
<ButtonGroup>
{PRODUCT_ACTIONS.map((a) => (
<Button key={a.id} onClick={() => void run(a.id, a.target)} disabled={busy}>
{a.label}
</Button>
))}
</ButtonGroup>
</BlockStack>
</Card>
)}
{status && <Banner tone={status.tone}>{status.text}</Banner>}
{output && (
<Card>
<BlockStack gap="300">
<TextField label="Generated content" value={output} onChange={setOutput} multiline={6} autoComplete="off" />
<InlineStack gap="200">
<Button onClick={() => void writeBack()} variant="primary" loading={busy}>
Save to product
</Button>
</InlineStack>
</BlockStack>
</Card>
)}
</BlockStack>
);
}
// ---- Order panel ----------------------------------------------------------
function OrderPanel(props: { api: Api; model: string; modelSelect: JSX.Element | null; initialId: string }): JSX.Element {
const { api, model, modelSelect, initialId } = props;
const [idInput, setIdInput] = useState(initialId);
const [order, setOrder] = useState<Order | null>(null);
const [output, setOutput] = useState('');
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ tone: 'success' | 'critical' | 'info'; text: string } | null>(null);
const load = useCallback(async () => {
const gid = toGid('Order', idInput);
if (!gid) return;
setBusy(true);
setStatus(null);
try {
setOrder(await api.getOrder(gid));
setOutput('');
} catch (e: any) {
setStatus({ tone: 'critical', text: e?.message || 'Failed to load order' });
setOrder(null);
} finally {
setBusy(false);
}
}, [api, idInput]);
useEffect(() => {
if (initialId) void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialId]);
const run = useCallback(
async (actionId: string) => {
if (!order) return;
setBusy(true);
setStatus(null);
try {
setOutput(await api.runOrderAction(order.id, actionId, model));
} catch (e: any) {
setStatus({ tone: 'critical', text: e?.message || 'Generation failed' });
} finally {
setBusy(false);
}
},
[api, order, model],
);
return (
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<InlineStack gap="200" align="start" blockAlign="end">
<Box width="70%">
<TextField
label="Order ID or GID"
value={idInput}
onChange={setIdInput}
autoComplete="off"
placeholder="gid://shopify/Order/123 or 123"
/>
</Box>
<Button onClick={() => void load()} loading={busy} variant="primary">
Load
</Button>
</InlineStack>
{order && (
<InlineStack gap="200" blockAlign="center">
<Text as="span" variant="headingSm">{order.name}</Text>
{order.financialStatus && <Badge>{order.financialStatus}</Badge>}
{order.fulfillmentStatus && <Badge>{order.fulfillmentStatus}</Badge>}
</InlineStack>
)}
</BlockStack>
</Card>
{order && (
<Card>
<BlockStack gap="300">
<InlineStack align="space-between" blockAlign="center">
<Text as="h3" variant="headingSm">Order actions</Text>
{modelSelect}
</InlineStack>
<ButtonGroup>
{ORDER_ACTIONS.map((a) => (
<Button key={a.id} onClick={() => void run(a.id)} disabled={busy}>
{a.label}
</Button>
))}
</ButtonGroup>
</BlockStack>
</Card>
)}
{status && <Banner tone={status.tone}>{status.text}</Banner>}
{output && (
<Card>
<TextField label="Result" value={output} onChange={setOutput} multiline={8} autoComplete="off" />
</Card>
)}
</BlockStack>
);
}
+126
View File
@@ -0,0 +1,126 @@
// The admin panel's backend client — pure request shaping over the server proxy.
// The frontend NEVER holds the Shopify secret or the Hanzo key: it calls the
// server's /v1/* endpoints with its shop domain, and the server holds the token
// and talks to Shopify + api.hanzo.ai. This module is pure over an injected
// `fetch` (App Bridge's authenticated fetch in the app; a mock in tests), so the
// request shaping is unit-testable without a browser.
//
// Every method returns typed data or throws with the server's error message, so
// the React panel has ONE way to handle failure: try/catch on await.
// A product as the panel consumes it (the server's parseProduct output).
export interface Product {
id: string;
title: string;
handle: string;
descriptionHtml: string;
description: string;
productType: string;
vendor: string;
tags: string[];
status: string;
seoTitle: string;
seoDescription: string;
}
// An order as the panel consumes it (the server's parseOrder output).
export interface Order {
id: string;
name: string;
note: string;
email: string;
financialStatus: string;
fulfillmentStatus: string;
createdAt: string;
totalAmount: string;
currency: string;
customerName: string;
shipTo: string;
lineItems: Array<{ title: string; quantity: number; sku: string }>;
}
// The fields a product write-back may carry. Only what is set is sent.
export interface ProductWriteback {
descriptionHtml?: string;
seoTitle?: string;
seoDescription?: string;
}
export interface ApiOptions {
/** Backend base (the server behind hanzoai/ingress). '' means same-origin. */
baseUrl?: string;
/** The shop domain (from App Bridge host/shop). Sent on every call. */
shop: string;
/**
* The fetch to use. In the app this is App Bridge's authenticated fetch (adds
* the session-token Authorization header so the server can trust the shop).
* In tests, a mock. Defaults to global fetch.
*/
fetch?: typeof fetch;
}
// asError turns a non-ok response body into a thrown Error carrying the server's
// message. One place decodes the { error } envelope so every call fails the same
// way.
async function asError(resp: Response): Promise<never> {
let msg = `HTTP ${resp.status}`;
try {
const data: any = await resp.json();
if (data?.error) msg = String(data.error);
} catch {
/* non-JSON body — keep the status */
}
throw new Error(msg);
}
// createApi builds the panel's typed client bound to a shop + fetch. Every method
// is a single request to the server proxy.
export function createApi(opts: ApiOptions) {
const base = (opts.baseUrl ?? '').replace(/\/+$/, '');
const doFetch = opts.fetch ?? fetch;
const shop = opts.shop;
async function getJson(path: string, query: Record<string, string>): Promise<any> {
const q = new URLSearchParams({ shop, ...query }).toString();
const resp = await doFetch(`${base}${path}?${q}`);
if (!resp.ok) return asError(resp);
return resp.json();
}
async function postJson(path: string, body: Record<string, unknown>): Promise<any> {
const resp = await doFetch(`${base}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shop, ...body }),
});
if (!resp.ok) return asError(resp);
return resp.json();
}
return {
getProduct(id: string): Promise<Product> {
return getJson('/v1/product', { id });
},
getOrder(id: string): Promise<Order> {
return getJson('/v1/order', { id });
},
async listModels(): Promise<string[]> {
const data = await getJson('/v1/models', {});
return Array.isArray(data?.models) ? data.models : [];
},
async runProductAction(id: string, action: string, model?: string): Promise<string> {
const data = await postJson('/v1/product/action', { id, action, model });
return String(data?.content ?? '');
},
async runOrderAction(id: string, action: string, model?: string): Promise<string> {
const data = await postJson('/v1/order/action', { id, action, model });
return String(data?.content ?? '');
},
async updateProduct(id: string, w: ProductWriteback): Promise<{ id: string; title: string }> {
const data = await postJson('/v1/product/update', { id, ...w });
return data?.product ?? { id, title: '' };
},
};
}
export type Api = ReturnType<typeof createApi>;
+54
View File
@@ -0,0 +1,54 @@
// Reading the embedded-app launch context — pure, browser-string-in / values-out,
// so it is unit-testable without an iframe. Shopify launches the embedded admin
// app inside an iframe with `shop`, `host`, and (from an admin block/action
// extension on a product/order page) the resource `id` in the URL query. The
// React panel reads this once at mount.
// The launch context the panel needs. `shop` scopes every backend call; `host`
// is App Bridge's base64 host param; `resource` + `resourceId` are set when the
// app opened from a product or order page (an admin link/action), so the panel
// can jump straight to that subject.
export interface LaunchContext {
shop: string;
host: string;
resource: 'product' | 'order' | '';
resourceId: string;
}
// Shopify sometimes passes a bare numeric id and sometimes a GID
// (gid://shopify/Product/123). toGid normalizes a value to a GID for a resource
// kind — the Admin GraphQL API takes GIDs. A value already in gid:// form is
// returned unchanged; a bare number is wrapped. Pure.
export function toGid(kind: 'Product' | 'Order', id: string): string {
const v = id.trim();
if (!v) return '';
if (v.startsWith('gid://')) return v;
return `gid://shopify/${kind}/${v}`;
}
// resourceKind maps a `resource` query value to the GID kind, defaulting to ''
// (no resource). Only 'product' and 'order' are surfaces this app acts on.
function resourceOf(v: string | null): 'product' | 'order' | '' {
return v === 'product' || v === 'order' ? v : '';
}
// parseLaunchContext reads the iframe URL's query string into a LaunchContext.
// A GID passed for the resource id is preserved; a bare id is normalized to a GID
// for the named resource. Pure.
export function parseLaunchContext(search: string): LaunchContext {
const q = new URLSearchParams(search);
const resource = resourceOf(q.get('resource'));
const rawId = q.get('id') ?? '';
const resourceId =
resource === 'product'
? toGid('Product', rawId)
: resource === 'order'
? toGid('Order', rawId)
: rawId;
return {
shop: q.get('shop') ?? '',
host: q.get('host') ?? '',
resource,
resourceId,
};
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for Shopify</title>
<!--
App Bridge is loaded from Shopify's CDN and MUST be the first script, with
the app's API key in a meta tag. Shopify requires this for an embedded admin
app so the iframe registers window.shopify (session tokens, navigation).
build.js stamps __SHOPIFY_API_KEY__ (public — the client id, never the secret)
and __ENTRY__ / __BASE__.
-->
<meta name="shopify-api-key" content="__SHOPIFY_API_KEY__" />
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
// The embedded-app browser entry point — the one impure, browser-only module. It
// reads the launch context, builds the App Bridge authenticated fetch, wires the
// backend Api, and mounts the Polaris React app. Everything logic-heavy it uses
// (context parsing, request shaping, session-token fetch) is a pure, tested
// module; this file is the DOM glue.
//
// App Bridge (window.shopify) is loaded by the CDN script tag in index.html with
// the app's api-key meta, per Shopify's embedded-app requirement. We read the
// session token through it on every request (session-fetch) so the server can
// trust which shop is calling.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@shopify/polaris/build/esm/styles.css';
import { App } from './App';
import { createApi } from './api';
import { parseLaunchContext } from './context';
import { authenticatedFetch } from './session-fetch';
// The App Bridge global the CDN script registers. idToken() returns a fresh
// session-token JWT. Typed minimally — we use only idToken.
declare global {
interface Window {
shopify?: { idToken(): Promise<string> };
}
}
// The backend base is stamped into the page as <meta name="hanzo:base"> at build
// time (build.js). Empty means same-origin (the app and the API served together).
function backendBase(): string {
const meta = document.querySelector('meta[name="hanzo:base"]');
return (meta?.getAttribute('content') ?? '').replace(/\/+$/, '');
}
function boot(): void {
const ctx = parseLaunchContext(window.location.search);
const getIdToken: () => Promise<string> = () =>
window.shopify ? window.shopify.idToken() : Promise.resolve('');
const fetchImpl = authenticatedFetch(fetch.bind(window), getIdToken);
const api = createApi({ baseUrl: backendBase(), shop: ctx.shop, fetch: fetchImpl });
const root = document.getElementById('root');
if (!root) throw new Error('missing #root');
createRoot(root).render(
<StrictMode>
<App api={api} ctx={ctx} />
</StrictMode>,
);
}
boot();
+31
View File
@@ -0,0 +1,31 @@
// App Bridge session-token fetch — pure and injectable so it is unit-testable
// without App Bridge or a browser. Shopify's embedded-app auth model: App Bridge
// (window.shopify) mints a short-lived session token (a signed JWT) via
// `shopify.idToken()`; the frontend attaches it as `Authorization: Bearer <jwt>`
// on every backend call, and the server verifies it (the shop identity is in the
// token, so the server does not trust the shop query param blindly). This wraps a
// base fetch to add that header, refreshing the token per request (tokens expire
// in ~1 minute, so we never cache one).
//
// getIdToken is injected: in the app it is `() => window.shopify.idToken()`; in
// tests it is a stub. That keeps this module free of any global and fully pure.
export type IdTokenSource = () => Promise<string>;
// authenticatedFetch returns a fetch that, before each request, fetches a fresh
// session token and adds it as a Bearer header. It merges (does not clobber) any
// caller headers. If token retrieval fails, the request still goes out without
// the header — the server will reject it — so a token outage surfaces as a clean
// 401 from the server rather than a thrown fetch here.
export function authenticatedFetch(baseFetch: typeof fetch, getIdToken: IdTokenSource): typeof fetch {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
try {
const token = await getIdToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
} catch {
/* no token — let the server 401 */
}
return baseFetch(input, { ...init, headers });
};
}
+122
View File
@@ -0,0 +1,122 @@
// Shopify config — the api.hanzo.ai model gateway (default model + endpoints),
// the Shopify Admin API version + OAuth scopes, and the server-side secret set
// (Shopify API key/secret + webhook HMAC). Endpoints and the bearer choice
// mirror @hanzo/docusign and @hanzo/notion so the productivity suite stays DRY;
// the commerce-specific pieces (product-context assembly, the AI actions) live
// in hanzo.ts / actions.ts, not here.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` (createAiClient) defaults
// here too. /v1 only, never an /api/ prefix (api.hanzo.ai IS the api host).
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
// the gateway routes it.
export const DEFAULT_MODEL = 'zen5';
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-shopify';
// chatCompletionsURL / modelsURL — the model gateway endpoints. The client
// (createAiClient) builds these itself; these exist for tests + honest docs.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- Shopify Admin API ----------------------------------------------------
//
// Shopify Admin API is versioned by calendar quarter (YYYY-MM). We pin ONE
// version and move it forward deliberately — never a speculative future one.
// The GraphQL Admin API is the primary surface (productUpdate, product/order
// reads); REST is legacy. Every call is against the per-shop host
// `https://{shop}/admin/api/{version}/graphql.json`.
export const ADMIN_API_VERSION = '2025-01';
// The OAuth scopes the app requests. read/write products for content write-back,
// read_orders for order insight + support drafting. No scope we do not use.
export const OAUTH_SCOPES = ['read_products', 'write_products', 'read_orders'] as const;
// Product description text budget. A product body_html plus its metafields and
// variants can run long; this caps the characters of product context we attach
// to any one request so it fits comfortably in a model window alongside the
// reply. Honest truncation, never silent drop.
export const PRODUCT_CHAR_BUDGET = 20_000;
// A Shopify shop domain looks like `my-store.myshopify.com`. isShopDomain is the
// boundary guard: OAuth install/callback and webhook handlers validate the shop
// parameter against this before it is ever placed in a URL, so an attacker
// cannot point the OAuth dance or an API call at an arbitrary host. Shopify's
// rule: hostname ending in `.myshopify.com`, only [a-z0-9-] in the store slug.
export function isShopDomain(shop: string | undefined | null): shop is string {
return typeof shop === 'string' && /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(shop);
}
// adminGraphqlUrl builds the GraphQL Admin API endpoint for a shop. Pure — the
// request wrappers in shopify-api.ts take this string. Callers MUST have passed
// `shop` through isShopDomain first (server.ts does).
export function adminGraphqlUrl(shop: string, version: string = ADMIN_API_VERSION): string {
return `https://${shop}/admin/api/${version}/graphql.json`;
}
// ---- Server-side configuration (Shopify OAuth + webhook HMAC) --------------
//
// These are read from the environment by src/server/server.ts. They NEVER reach
// the browser bundle: the API key (client id) is public, but the API secret is
// server-only — it signs the OAuth exchange AND is the HMAC key Shopify uses to
// sign both the OAuth callback and every webhook. readServerConfig throws on a
// missing secret so a server that can neither complete OAuth nor verify a
// webhook refuses to start.
export interface ServerConfig {
/** Shopify API key / OAuth client id (public — appears in the authorize URL). */
shopifyApiKey: string;
/**
* Shopify API secret (SERVER ONLY). Two jobs: the OAuth token-exchange
* `client_secret`, AND the HMAC-SHA256 key for the OAuth callback signature
* and every webhook signature. One secret, both trust gates.
*/
shopifyApiSecret: string;
/** OAuth redirect registered on the app (e.g. https://shopify.hanzo.ai/oauth/callback). */
shopifyRedirectUri: string;
/** The scopes string sent to /admin/oauth/authorize (space is not used — comma). */
scopes: string;
/**
* Hanzo API key the server uses to run AI on behalf of a shop when no user
* bearer is in the loop (the webhook path). Optional: without it the server
* still does OAuth + Admin API + verifies webhooks, but the webhook cannot
* run a model call (it logs and skips), which readServerConfig reports.
*/
hanzoApiKey: string;
/** Listen port. */
port: number;
}
// readServerConfig fails fast (throws) if a required Shopify secret is missing.
// hanzoApiKey is the one optional field (see above). Pure given an env map, so
// it is unit-tested without touching process.env.
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
const shopifyApiKey = env.SHOPIFY_API_KEY;
const shopifyApiSecret = env.SHOPIFY_API_SECRET;
const shopifyRedirectUri = env.SHOPIFY_REDIRECT_URI;
const missing: string[] = [];
if (!shopifyApiKey) missing.push('SHOPIFY_API_KEY');
if (!shopifyApiSecret) missing.push('SHOPIFY_API_SECRET');
if (!shopifyRedirectUri) missing.push('SHOPIFY_REDIRECT_URI');
if (missing.length > 0) {
throw new Error(`Missing required environment: ${missing.join(', ')}`);
}
return {
shopifyApiKey: shopifyApiKey!,
shopifyApiSecret: shopifyApiSecret!,
shopifyRedirectUri: shopifyRedirectUri!,
scopes: env.SHOPIFY_SCOPES || OAUTH_SCOPES.join(','),
hanzoApiKey: env.HANZO_API_KEY || '',
port: Number(env.PORT) || 8791,
};
}
+122
View File
@@ -0,0 +1,122 @@
// The AI actions over a product or an order — each a prompt template applied to
// the assembled context via the single `askProduct` / `askOrder` primitives in
// hanzo.ts. There is exactly ONE code path to the model per subject: an action
// is (id → prompt + subject), and the panel, the picker, and the webhook all
// resolve an id here and call the matching ask. No action speaks to the gateway
// directly.
import { askOrder, askProduct, type AskOptions } from './hanzo.js';
import type { ProductInfo, OrderInfo } from './shopify-api.js';
// A product action's prompt is written to produce paste-ready content (a
// description, an SEO title, a meta description). Prompts are specific and
// output-shaped so results drop straight into the Shopify field.
export const PRODUCT_ACTIONS = {
writeDescription: {
label: 'Write description',
prompt:
'Write a compelling product description for this product. 24 short ' +
'paragraphs (or a short intro plus a bullet list of key features/benefits). ' +
'Lead with the benefit, ground every claim in the product data above, and ' +
'match a confident, on-brand retail tone. Return only the description text, ' +
'ready to paste.',
},
rewriteDescription: {
label: 'Rewrite description',
prompt:
'Rewrite the current product description above to be clearer, more ' +
'persuasive, and better structured, WITHOUT adding any fact not present in ' +
'the product data. Keep it roughly the same length. Return only the rewritten ' +
'description, ready to paste.',
},
seoTitle: {
label: 'SEO title',
prompt:
'Write an SEO page title for this product: at most 60 characters, ' +
'front-loading the most important keyword, natural (not keyword-stuffed), and ' +
'accurate to the product. Return only the title text on a single line.',
},
seoDescription: {
label: 'SEO meta description',
prompt:
'Write an SEO meta description for this product: 140160 characters, one or ' +
'two sentences, benefit-led, and accurate to the product data. Return only ' +
'the meta description text on a single line.',
},
} as const;
// An order action's prompt produces a summary, a customer reply, or an issue
// extraction — the support/order-insight surface.
export const ORDER_ACTIONS = {
summarize: {
label: 'Summarize order',
prompt:
'Summarize this order for a support agent at a glance: who ordered, what and ' +
'how much, the payment and fulfillment status, where it ships, and anything ' +
'notable in the note. Short bullets. No preamble.',
},
draftReply: {
label: 'Draft customer reply',
prompt:
'Draft a warm, professional reply to the customer about this order. Address ' +
'them by name if known, reference the order by its name/number, and speak ' +
'only to what the order data supports (status, items, shipping). Do not ' +
'promise dates, refunds, or policies that are not in the data. Return only ' +
'the email body, ready to send.',
},
extractIssues: {
label: 'Extract issues',
prompt:
'Read this order (especially the internal note) and extract any customer ' +
'issues, requests, or risks as a short prioritized list. For each: a one-line ' +
'description and a suggested next action. If nothing stands out, say so. Do ' +
'not invent issues that are not supported by the data.',
},
} as const;
// A product / order action id from the respective catalog.
export type ProductActionId = keyof typeof PRODUCT_ACTIONS;
export type OrderActionId = keyof typeof ORDER_ACTIONS;
// isProductActionId / isOrderActionId narrow an arbitrary string to a known id.
// Boundary guards — the panel and the server validate an inbound id here before
// running it.
export function isProductActionId(id: string): id is ProductActionId {
return Object.prototype.hasOwnProperty.call(PRODUCT_ACTIONS, id);
}
export function isOrderActionId(id: string): id is OrderActionId {
return Object.prototype.hasOwnProperty.call(ORDER_ACTIONS, id);
}
// productActionPrompt / orderActionPrompt resolve an id to its prompt. Each
// throws on an unknown id (a boundary error surfaced to the caller) rather than
// silently running a default.
export function productActionPrompt(id: string): string {
if (!isProductActionId(id)) throw new Error(`Unknown product action: ${id}`);
return PRODUCT_ACTIONS[id].prompt;
}
export function orderActionPrompt(id: string): string {
if (!isOrderActionId(id)) throw new Error(`Unknown order action: ${id}`);
return ORDER_ACTIONS[id].prompt;
}
// productActionList / orderActionList are the ordered catalogs for building the
// UI, derived from the action maps so the panel and the catalog can never drift.
export function productActionList(): Array<{ id: ProductActionId; label: string }> {
return (Object.keys(PRODUCT_ACTIONS) as ProductActionId[]).map((id) => ({ id, label: PRODUCT_ACTIONS[id].label }));
}
export function orderActionList(): Array<{ id: OrderActionId; label: string }> {
return (Object.keys(ORDER_ACTIONS) as OrderActionId[]).map((id) => ({ id, label: ORDER_ACTIONS[id].label }));
}
// runProductAction / runOrderAction are the single entry points each surface
// calls: resolve the action's prompt and run it over the assembled context via
// the matching ask. One code path from an id to the model per subject. Async so
// an unknown id surfaces as a rejected promise (not a synchronous throw), giving
// callers ONE way to handle failure: await/.catch.
export async function runProductAction(id: string, p: ProductInfo, opts: AskOptions = {}): Promise<string> {
return askProduct(productActionPrompt(id), p, opts);
}
export async function runOrderAction(id: string, o: OrderInfo, opts: AskOptions = {}): Promise<string> {
return askOrder(orderActionPrompt(id), o, opts);
}
+222
View File
@@ -0,0 +1,222 @@
// The Hanzo call and its request/response shaping over a PRODUCT or ORDER —
// pure, host-agnostic, and fully unit-testable (no Shopify SDK, no DOM). The
// Polaris panel and the webhook server are the thin glue that read a product/
// order and hand it here.
//
// This is a THIN wrapper over the PUBLISHED headless client `@hanzo/ai`
// (createAiClient) — we do NOT reimplement the transport. This module owns only
// the commerce-aware layer: product/order context assembly + truncation + prompt
// building, kept pure so it is tested and reused by both the panel backend and
// the (browser-less) webhook. The AI actions (their prompt templates) live in
// actions.ts, layered on `ask`.
import { createAiClient, type AiClient } from '@hanzo/ai';
import { DEFAULT_MODEL, HANZO_API_BASE_URL, PRODUCT_CHAR_BUDGET } from '../config.js';
import type { ProductInfo, OrderInfo } from './shopify-api.js';
// A message in the OpenAI-compatible schema — the shape @hanzo/ai's
// chat.completions.create takes. Kept as a local alias so the prompt-assembly
// functions (buildProductMessages/buildOrderMessages) have a precise, testable
// return type independent of the SDK's broader union.
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// SYSTEM_PROMPT grounds every answer in the store data provided. It forbids
// inventing facts about the product or order (the failure mode that makes a
// commerce assistant dangerous — fake specs, fake shipping promises), and keeps
// output ready to paste into a Shopify field. Merchant-facing, brand-neutral.
export const SYSTEM_PROMPT =
'You are Hanzo AI, an assistant for a Shopify merchant. You help write product ' +
'content (descriptions, SEO titles and meta descriptions) and handle orders and ' +
'support. Work ONLY from the product or order data provided below — never invent ' +
'specifications, materials, prices, dates, shipping promises, or policies that ' +
'are not supported by the data. Write in clear, natural, conversion-minded prose ' +
'for product content, and a warm, professional tone for customer replies. Return ' +
'only the requested content, ready to paste — no preamble, no meta commentary.';
// ---- Product context assembly ---------------------------------------------
//
// A product's attributes are assembled into a compact, labeled block the model
// reads as data (never as instructions). We include the existing description so
// "rewrite" has the current copy to improve, and cap the whole block at the
// budget — HTML descriptions can be long. Honest truncation, never silent drop.
// The result of rendering a product down to what we attach: the text and whether
// anything was truncated (so the prompt and UI can say so honestly).
export interface ProductContext {
text: string;
truncated: boolean;
}
// stripHtml reduces an HTML description to readable text for the context block
// (the model does not need the markup to understand the copy, and tags waste
// budget). Minimal + total: drop tags, collapse whitespace. Pure.
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim();
}
// buildProductContext renders the product attributes into a labeled block,
// capped at `budget` characters. The current description is the last (and
// longest) field, so truncation trims it rather than the structured attributes
// the model most needs. Pure and total. `truncated` is true whenever the render
// was cut to fit.
export function buildProductContext(p: ProductInfo, budget: number = PRODUCT_CHAR_BUDGET): ProductContext {
const lines: string[] = [];
lines.push(`Title: ${p.title}`);
if (p.productType) lines.push(`Type: ${p.productType}`);
if (p.vendor) lines.push(`Vendor: ${p.vendor}`);
if (p.tags.length) lines.push(`Tags: ${p.tags.join(', ')}`);
for (const o of p.options) {
if (o.name && o.values.length) lines.push(`Option ${o.name}: ${o.values.join(', ')}`);
}
if (p.variants.length) {
const vs = p.variants
.map((v) => [v.title, v.sku && `SKU ${v.sku}`, v.price && `$${v.price}`].filter(Boolean).join(' — '))
.join('; ');
lines.push(`Variants: ${vs}`);
}
if (p.seoTitle) lines.push(`Current SEO title: ${p.seoTitle}`);
if (p.seoDescription) lines.push(`Current SEO description: ${p.seoDescription}`);
const currentDesc = stripHtml(p.descriptionHtml || p.description);
if (currentDesc) lines.push(`Current description: ${currentDesc}`);
const full = lines.join('\n');
if (full.length <= budget) return { text: full, truncated: false };
return { text: full.slice(0, budget), truncated: true };
}
// ---- Order context assembly -----------------------------------------------
export interface OrderContext {
text: string;
truncated: boolean;
}
// buildOrderContext renders an order into a labeled block. Orders are small; the
// budget only bites on a huge line-item list or a long note, in which case we
// truncate the tail honestly. Pure and total.
export function buildOrderContext(o: OrderInfo, budget: number = PRODUCT_CHAR_BUDGET): OrderContext {
const lines: string[] = [];
lines.push(`Order: ${o.name}`);
if (o.createdAt) lines.push(`Placed: ${o.createdAt}`);
if (o.customerName) lines.push(`Customer: ${o.customerName}`);
if (o.email) lines.push(`Email: ${o.email}`);
if (o.financialStatus) lines.push(`Payment: ${o.financialStatus}`);
if (o.fulfillmentStatus) lines.push(`Fulfillment: ${o.fulfillmentStatus}`);
if (o.totalAmount) lines.push(`Total: ${o.totalAmount} ${o.currency}`.trim());
if (o.shipTo) lines.push(`Ship to: ${o.shipTo}`);
if (o.lineItems.length) {
const items = o.lineItems
.map((li) => `${li.quantity}× ${li.title}${li.sku ? ` (SKU ${li.sku})` : ''}`)
.join('; ');
lines.push(`Items: ${items}`);
}
if (o.note) lines.push(`Internal note: ${o.note}`);
const full = lines.join('\n');
if (full.length <= budget) return { text: full, truncated: false };
return { text: full.slice(0, budget), truncated: true };
}
// ---- Prompt assembly ------------------------------------------------------
// contextNote is the one honest sentence prepended when the render was cut, so
// the model does not answer as though it saw everything.
function contextNote(truncated: boolean, kind: 'product' | 'order'): string {
return truncated
? `Note: the ${kind} data below was truncated to fit — answer only from what is shown.`
: '';
}
// buildMessages fences the store data as DATA (never instructions) and rides the
// honest note inside the user turn so it is never lost. Shared by product and
// order paths — one assembly, one shape. Pure.
export function buildMessages(task: string, contextText: string, note: string, fence: string): ChatMessage[] {
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
const notePrefix = note ? `${note}\n\n` : '';
const user: ChatMessage = {
role: 'user',
content:
`${notePrefix}---- ${fence} ----\n${contextText}\n---- end ${fence} ----\n\n` +
`---- task ----\n${task}`,
};
return [system, user];
}
// buildProductMessages / buildOrderMessages are the two public assemblers the
// actions + freeform ask use. Each windows its context and builds the message
// list. Pure — the whole prompt is asserted in a test.
export function buildProductMessages(task: string, p: ProductInfo): ChatMessage[] {
const ctx = buildProductContext(p);
return buildMessages(task, ctx.text, contextNote(ctx.truncated, 'product'), 'product');
}
export function buildOrderMessages(task: string, o: OrderInfo): ChatMessage[] {
const ctx = buildOrderContext(o);
return buildMessages(task, ctx.text, contextNote(ctx.truncated, 'order'), 'order');
}
// ---- The single call path -------------------------------------------------
export interface AskOptions {
model?: string;
temperature?: number;
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// client resolves an AiClient: the injected one (tests) or a real published
// @hanzo/ai client pointed at the gateway with the caller's bearer. One place
// constructs it so the token/baseURL wiring is identical for every surface.
function client(opts: AskOptions): AiClient {
return opts.client ?? createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL });
}
// runChat is the single path from a built message list to the model. Every
// Shopify surface (the actions, a freeform question, the webhook auto-draft)
// funnels here. Non-streaming: the panel renders the final text and the webhook
// stores a finished draft. token may be empty (the gateway serves anonymous/
// limited models).
export async function runChat(messages: ChatMessage[], opts: AskOptions = {}): Promise<string> {
const res = await client(opts).chat.completions.create({
model: opts.model ?? DEFAULT_MODEL,
messages,
temperature: opts.temperature,
stream: false,
}, { signal: opts.signal });
const content = res.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content === '') {
throw new Error('Hanzo API returned no content');
}
return content;
}
// askProduct / askOrder run ONE completion over a product/order with a freeform
// task. The actions in actions.ts resolve their prompt and call these.
export async function askProduct(task: string, p: ProductInfo, opts: AskOptions = {}): Promise<string> {
return runChat(buildProductMessages(task, p), opts);
}
export async function askOrder(task: string, o: OrderInfo, opts: AskOptions = {}): Promise<string> {
return runChat(buildOrderMessages(task, o), opts);
}
// listModels returns the model ids the caller may route to, from /v1/models via
// the headless client. Org-scoped by the bearer; an empty token lists public
// models.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const models = await client(opts).models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+165
View File
@@ -0,0 +1,165 @@
// Shopify OAuth (Authorization Code Grant) — pure request shaping + the OAuth
// callback HMAC verification. The API secret is held by the server (server.ts)
// and never reaches the browser; these functions build the exact URL/body of
// each OAuth call and verify Shopify's signature on the redirect, so the wire
// shape and the trust gate are unit-testable without a network round-trip.
//
// Shopify's flow (shopify.dev/docs/apps/auth/oauth):
// 1. redirect the merchant to https://{shop}/admin/oauth/authorize?…
// 2. Shopify redirects back to our redirect_uri with ?code&hmac&shop&state&…
// — we VERIFY the `hmac` (HMAC-SHA256 over the sorted remaining params,
// hex) before trusting anything, then check `state` (CSRF).
// 3. POST https://{shop}/admin/oauth/access_token with the code + secret to
// get the offline access token, stored server-side per shop.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { isShopDomain } from '../config.js';
// authorizeUrl is where the install flow sends the merchant's browser to grant
// the app. `scope` is comma-joined (Shopify's convention, NOT space), `state`
// is the CSRF nonce the server generates and re-checks on callback. Offline
// access mode is the default (no `grant_options[]=per-user`), which is what a
// background webhook needs — a long-lived token, not a per-user online one.
export function authorizeUrl(args: {
shop: string;
apiKey: string;
scopes: string;
redirectUri: string;
state: string;
}): string {
const q = new URLSearchParams({
client_id: args.apiKey,
scope: args.scopes,
redirect_uri: args.redirectUri,
state: args.state,
});
return `https://${args.shop}/admin/oauth/authorize?${q.toString()}`;
}
// A prepared token-exchange request: the pieces a single fetch needs. Pure
// output so a test asserts the URL + JSON body without opening a socket. The
// secret rides ONLY in the body, over TLS to Shopify's own host.
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
// tokenExchange builds the code→token request against the shop's
// /admin/oauth/access_token. Shopify takes a JSON body of client_id +
// client_secret + code and returns { access_token, scope }.
export function tokenExchange(args: {
shop: string;
apiKey: string;
apiSecret: string;
code: string;
}): PreparedRequest {
return {
url: `https://${args.shop}/admin/oauth/access_token`,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: args.apiKey,
client_secret: args.apiSecret,
code: args.code,
}),
};
}
// The token response we care about. Shopify returns access_token (the offline
// token) + the granted scope. parseTokenResponse validates the one field we
// must have and surfaces Shopify's own error otherwise.
export interface ShopifyTokenSet {
access_token: string;
scope: string;
}
export function parseTokenResponse(data: any): ShopifyTokenSet {
if (!data || typeof data !== 'object') throw new Error('empty token response');
if (data.error || data.errors) {
const reason = data.error_description || data.error || JSON.stringify(data.errors);
throw new Error(`Shopify OAuth error: ${reason}`);
}
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
throw new Error('Shopify OAuth response missing access_token');
}
return { access_token: data.access_token, scope: String(data.scope ?? '') };
}
// ---- OAuth callback HMAC verification (the trust gate) --------------------
//
// Shopify signs the OAuth redirect: the `hmac` query param is
// hex(HMAC-SHA256(apiSecret, message)), where `message` is the OTHER query
// params (everything except `hmac` and the legacy `signature`) sorted by key
// and joined `key=value&key=value` — with the ORIGINAL, still-URL-encoded
// values. We must reconstruct that exact string. A callback that fails here is
// discarded before any token exchange. Docs:
// shopify.dev/docs/apps/auth/oauth/getting-started#step-4-confirm-installation
// callbackMessage builds the signed message from a param map: drop hmac +
// signature, sort the rest by key, then re-encode as an
// application/x-www-form-urlencoded query string. This EXACTLY matches Shopify's
// canonical form (stringifyQueryForAdmin → ProcessedQuery.stringify, which is a
// URLSearchParams.toString()): keys/values are percent-encoded, spaces become
// `+`. Building the raw `key=value` join instead would fail to validate any
// callback whose values contain encodable characters (e.g. `host`, `state`), so
// we mirror URLSearchParams here. `params` are the decoded values (as
// URLSearchParams.forEach yields them); URLSearchParams re-encodes them the same
// way Shopify did when it signed. Pure.
export function callbackMessage(params: Record<string, string>): string {
const sorted = new URLSearchParams();
for (const key of Object.keys(params).filter((k) => k !== 'hmac' && k !== 'signature').sort()) {
sorted.append(key, params[key]);
}
return sorted.toString();
}
// computeCallbackHmac is hex(HMAC-SHA256(apiSecret, message)). Both verification
// and any test fixture are built from it, so the value we compare is produced
// exactly as Shopify produces it.
export function computeCallbackHmac(apiSecret: string, message: string): string {
return createHmac('sha256', apiSecret).update(message, 'utf8').digest('hex');
}
// constantTimeEqualHex compares two hex digests without leaking length/position
// via timing. Different-length inputs are unequal and short-circuit safely (we
// never feed mismatched buffers to timingSafeEqual, which would throw).
export function constantTimeEqualHex(a: string, b: string): boolean {
const ba = Buffer.from(a, 'hex');
const bb = Buffer.from(b, 'hex');
if (ba.length === 0 || ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
// verifyCallbackHmac is the OAuth trust gate: reconstruct the signed message
// from the callback params, recompute the hex HMAC with the app secret, and
// constant-time compare against the provided `hmac` param. Returns false on any
// missing input rather than throwing, so a malformed callback is a clean reject.
export function verifyCallbackHmac(
apiSecret: string,
params: Record<string, string>,
): boolean {
const provided = params.hmac;
if (!apiSecret || !provided) return false;
const expected = computeCallbackHmac(apiSecret, callbackMessage(params));
return constantTimeEqualHex(provided, expected);
}
// ---- Callback parameter validation ----------------------------------------
// A validated OAuth callback: the shop, the code to exchange, and the state to
// re-check. parseCallback pulls these from the query map AFTER the HMAC passed;
// it re-validates the shop domain (defense in depth — never build a URL against
// an unverified host) and throws on a missing code.
export interface OAuthCallback {
shop: string;
code: string;
state: string;
}
export function parseCallback(params: Record<string, string>): OAuthCallback {
const shop = params.shop;
if (!isShopDomain(shop)) throw new Error('invalid or missing shop domain');
const code = params.code;
if (!code) throw new Error('missing code');
return { shop, code, state: params.state ?? '' };
}
+345
View File
@@ -0,0 +1,345 @@
// The Hanzo AI for Shopify service. This is the ONLY place the Shopify API secret
// and the Hanzo API key exist — both read from the environment (never bundled,
// never sent to the browser). It does OAuth, proxies the Admin GraphQL API + the
// @hanzo/ai model gateway, and verifies every Shopify HMAC. The embedded Polaris
// frontend calls the /v1/* proxy endpoints with its shop domain; the offline
// access token stays here.
//
// GET /oauth/install?shop=… → redirect the merchant to Shopify consent
// GET /oauth/callback?… → verify HMAC + state, exchange code, store
// the offline token per shop
// GET /v1/product?shop=&id= → read a product (Admin GraphQL)
// GET /v1/order?shop=&id= → read an order (Admin GraphQL)
// GET /v1/models?shop= → list model ids the shop may route to
// POST /v1/product/action → run an AI product action, return content
// POST /v1/order/action → run an AI order action, return content
// POST /v1/product/update → write AI content back (productUpdate)
// POST /webhooks → verify the Shopify webhook HMAC, route
// GET /healthz → readiness
//
// Dependency-free Node http handler over the pure modules (config, oauth,
// shopify-api, webhooks, hanzo, actions) so it is deployable behind
// hanzoai/ingress as a small service at shopify.hanzo.ai.
//
// node dist/server.js (after build.js bundles it)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { isShopDomain, readServerConfig, type ServerConfig } from '../config.js';
import {
authorizeUrl,
tokenExchange,
parseTokenResponse,
verifyCallbackHmac,
parseCallback,
} from './oauth.js';
import {
getProduct,
getOrder,
updateProduct,
parseProduct,
parseOrder,
parseProductUpdate,
type PreparedGraphql,
type ProductWriteback,
} from './shopify-api.js';
import { verifyWebhook, routeWebhook, type WebhookAction } from './webhooks.js';
import { runProductAction, runOrderAction, isProductActionId, isOrderActionId } from './actions.js';
import { listModels, type AskOptions } from './hanzo.js';
// A stored per-shop authorization: the offline access token to call the Admin
// API. In-memory here keyed by shop domain; a production deployment persists
// this to KMS/Valkey (keyed by shop) so the webhook and the panel can act after
// a restart. The install flow populates it; every other endpoint reads it.
const shops = new Map<string, { accessToken: string; scope: string }>();
// Fresh OAuth `state` nonces awaiting their callback. A production deployment
// persists these (Valkey, TTL) so state survives a restart and cannot be
// replayed; in-process is enough for a single instance and keeps the trust gate
// real (an unknown state is rejected).
const pendingStates = new Set<string>();
async function readRawBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function log(fields: Record<string, unknown>): void {
console.log(JSON.stringify(fields));
}
// send performs a PreparedGraphql request and returns the parsed JSON. The token
// header + body are already shaped by shopify-api; this is the one fetch.
async function send(req: PreparedGraphql): Promise<any> {
const resp = await fetch(req.url, { method: 'POST', headers: req.headers, body: req.body });
const text = await resp.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Shopify Admin API ${resp.status}: ${text.slice(0, 200)}`);
}
return data;
}
// requireShopAuth resolves the shop from a query/body value and its stored token,
// or throws a boundary error (turned into a 400/401 by the caller). Every proxy
// endpoint runs input through this before touching Shopify — a request for an
// unknown or un-installed shop never reaches the Admin API.
function requireShopAuth(shop: string | undefined): { shop: string; accessToken: string } {
if (!isShopDomain(shop)) throw new Error('invalid or missing shop');
const auth = shops.get(shop);
if (!auth) throw new Error('shop not installed');
return { shop, accessToken: auth.accessToken };
}
// GET /oauth/install?shop=… → 302 to Shopify's consent screen. The shop is
// validated (never build the authorize URL against an unverified host); `state`
// is a fresh CSRF nonce we remember for the callback.
function handleInstall(cfg: ServerConfig, url: URL, res: ServerResponse): void {
const shop = url.searchParams.get('shop') ?? undefined;
if (!isShopDomain(shop)) return json(res, 400, { error: 'invalid or missing shop' });
const state = randomUUID();
pendingStates.add(state);
const dest = authorizeUrl({
shop,
apiKey: cfg.shopifyApiKey,
scopes: cfg.scopes,
redirectUri: cfg.shopifyRedirectUri,
state,
});
res.writeHead(302, { Location: dest });
res.end();
}
// GET /oauth/callback → the trust gate + token exchange. Order matters:
// 1. verify the HMAC signature (authenticity) BEFORE trusting any param
// 2. check state (CSRF) against what we issued
// 3. exchange the code for the offline token (secret stays in the body)
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
const params: Record<string, string> = {};
url.searchParams.forEach((v, k) => (params[k] = v));
if (!verifyCallbackHmac(cfg.shopifyApiSecret, params)) {
log({ msg: 'oauth callback: HMAC verification failed' });
return json(res, 401, { error: 'invalid hmac' });
}
let cb;
try {
cb = parseCallback(params);
} catch (e: any) {
return json(res, 400, { error: e?.message || 'invalid callback' });
}
if (!pendingStates.delete(cb.state)) {
log({ msg: 'oauth callback: unknown state (possible CSRF)', shop: cb.shop });
return json(res, 401, { error: 'invalid state' });
}
const ex = tokenExchange({
shop: cb.shop,
apiKey: cfg.shopifyApiKey,
apiSecret: cfg.shopifyApiSecret,
code: cb.code,
});
try {
const resp = await fetch(ex.url, { method: 'POST', headers: ex.headers, body: ex.body });
const tokenSet = parseTokenResponse(await resp.json().catch(() => ({})));
shops.set(cb.shop, { accessToken: tokenSet.access_token, scope: tokenSet.scope });
log({ msg: 'oauth: shop installed', shop: cb.shop, scope: tokenSet.scope });
return json(res, 200, { ok: true, installed: true, shop: cb.shop });
} catch (e: any) {
return json(res, 400, { error: e?.message || 'token exchange failed' });
}
}
// GET /v1/product?shop=&id= → read a product for the panel.
async function handleGetProduct(url: URL, res: ServerResponse): Promise<void> {
const { shop, accessToken } = requireShopAuth(url.searchParams.get('shop') ?? undefined);
const id = url.searchParams.get('id');
if (!id) return json(res, 400, { error: 'missing product id' });
const data = await send(getProduct(shop, accessToken, id));
return json(res, 200, parseProduct(data));
}
// GET /v1/order?shop=&id= → read an order for the panel.
async function handleGetOrder(url: URL, res: ServerResponse): Promise<void> {
const { shop, accessToken } = requireShopAuth(url.searchParams.get('shop') ?? undefined);
const id = url.searchParams.get('id');
if (!id) return json(res, 400, { error: 'missing order id' });
const data = await send(getOrder(shop, accessToken, id));
return json(res, 200, parseOrder(data));
}
// GET /v1/models?shop= → the model ids this shop may route to. Uses the server's
// Hanzo key (the shop's org context) so the picker matches what a run will use.
async function handleModels(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
requireShopAuth(url.searchParams.get('shop') ?? undefined);
const models = await listModels({ token: cfg.hanzoApiKey });
return json(res, 200, { models });
}
// askOpts builds the @hanzo/ai options for a proxied run: the server's Hanzo key
// is the bearer (the merchant authenticates to Shopify via App Bridge, not to
// Hanzo directly), and the request may name a model.
function askOpts(cfg: ServerConfig, model: unknown): AskOptions {
return { token: cfg.hanzoApiKey, model: typeof model === 'string' && model ? model : undefined };
}
// POST /v1/product/action { shop, id, action, model? } → read the product, run
// the AI action, return the generated content. The panel then previews it and,
// on confirm, calls /v1/product/update.
async function handleProductAction(cfg: ServerConfig, body: any, res: ServerResponse): Promise<void> {
const { shop, accessToken } = requireShopAuth(body?.shop);
if (!isProductActionId(String(body?.action))) return json(res, 400, { error: 'unknown product action' });
if (!body?.id) return json(res, 400, { error: 'missing product id' });
const product = parseProduct(await send(getProduct(shop, accessToken, String(body.id))));
const content = await runProductAction(String(body.action), product, askOpts(cfg, body?.model));
return json(res, 200, { content });
}
// POST /v1/order/action { shop, id, action, model? } → read the order, run the
// AI order/support action, return the content.
async function handleOrderAction(cfg: ServerConfig, body: any, res: ServerResponse): Promise<void> {
const { shop, accessToken } = requireShopAuth(body?.shop);
if (!isOrderActionId(String(body?.action))) return json(res, 400, { error: 'unknown order action' });
if (!body?.id) return json(res, 400, { error: 'missing order id' });
const order = parseOrder(await send(getOrder(shop, accessToken, String(body.id))));
const content = await runOrderAction(String(body.action), order, askOpts(cfg, body?.model));
return json(res, 200, { content });
}
// POST /v1/product/update { shop, id, descriptionHtml?, seoTitle?, seoDescription? }
// → write AI content back via productUpdate. This is the write-back flow: the
// panel generated + previewed content, the merchant confirmed, and only the
// fields present in the body are written (a description rewrite never clobbers
// the SEO title). userErrors surface as a thrown 400.
async function handleProductUpdate(body: any, res: ServerResponse): Promise<void> {
const { shop, accessToken } = requireShopAuth(body?.shop);
if (!body?.id) return json(res, 400, { error: 'missing product id' });
const w: ProductWriteback = {
id: String(body.id),
descriptionHtml: typeof body.descriptionHtml === 'string' ? body.descriptionHtml : undefined,
seoTitle: typeof body.seoTitle === 'string' ? body.seoTitle : undefined,
seoDescription: typeof body.seoDescription === 'string' ? body.seoDescription : undefined,
};
const updated = parseProductUpdate(await send(updateProduct(shop, accessToken, w)));
log({ msg: 'product updated', shop, productId: updated.id });
return json(res, 200, { ok: true, product: updated });
}
// handleWebhookAction dispatches a verified webhook. Order creation could
// auto-draft a support summary (needs the shop's token + Hanzo key); app
// uninstall drops the stored token; GDPR topics are acknowledged (a real
// deployment fulfills the data request/redaction against its own store here).
// Never throws into the http handler — logs and returns.
async function handleWebhookAction(cfg: ServerConfig, action: WebhookAction): Promise<void> {
switch (action.kind) {
case 'order_created':
log({ msg: 'webhook: order created', shop: action.shop, orderId: action.orderId, canDraft: !!cfg.hanzoApiKey });
// A deployment that wants an auto-drafted summary reads the order via the
// shop's stored token and runs runOrderAction('summarize', …), then stores
// the result (hanzoai/docdb) or notifies the merchant. Delivery target is a
// per-deployment choice (documented in the README).
return;
case 'app_uninstalled':
shops.delete(action.shop);
log({ msg: 'webhook: app uninstalled, token dropped', shop: action.shop });
return;
case 'gdpr':
log({ msg: 'webhook: gdpr topic acknowledged', topic: action.topic, shop: action.shop });
return;
case 'ignored':
log({ msg: 'webhook: ignored', topic: action.topic, shop: action.shop });
return;
}
}
// POST /webhooks → verify, then dispatch. Verification is the trust gate: a
// webhook with a bad/absent HMAC is a 401 before any action. Shopify sends the
// signature in X-Shopify-Hmac-Sha256, the shop in X-Shopify-Shop-Domain, and the
// topic in X-Shopify-Topic.
async function handleWebhook(cfg: ServerConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
const raw = await readRawBody(req);
const headerHmac = req.headers['x-shopify-hmac-sha256'];
const hmac = Array.isArray(headerHmac) ? headerHmac[0] : headerHmac;
if (!verifyWebhook(cfg.shopifyApiSecret, hmac, raw)) {
log({ msg: 'webhook: signature verification failed' });
return json(res, 401, { error: 'invalid signature' });
}
const topic = String(req.headers['x-shopify-topic'] ?? '');
const shop = String(req.headers['x-shopify-shop-domain'] ?? '');
let body: any;
try {
body = JSON.parse(raw);
} catch {
return json(res, 400, { error: 'invalid json' });
}
// Acknowledge immediately; do the work after (Shopify retries slow endpoints).
json(res, 200, { ok: true });
void handleWebhookAction(cfg, routeWebhook(topic, shop, body));
}
// createHandler is the request router. Every route is a small handler over the
// pure modules. Exported so a test can drive it with a mock req/res if desired.
export function createHandler(cfg: ServerConfig) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
try {
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, url, res);
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
if (req.method === 'GET' && url.pathname === '/v1/product') return await handleGetProduct(url, res);
if (req.method === 'GET' && url.pathname === '/v1/order') return await handleGetOrder(url, res);
if (req.method === 'GET' && url.pathname === '/v1/models') return await handleModels(cfg, url, res);
if (req.method === 'POST' && url.pathname === '/webhooks') return await handleWebhook(cfg, req, res);
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
if (req.method === 'POST') {
const raw = await readRawBody(req);
let body: any = {};
if (raw) {
try {
body = JSON.parse(raw);
} catch {
return json(res, 400, { error: 'invalid json' });
}
}
if (url.pathname === '/v1/product/action') return await handleProductAction(cfg, body, res);
if (url.pathname === '/v1/order/action') return await handleOrderAction(cfg, body, res);
if (url.pathname === '/v1/product/update') return await handleProductUpdate(body, res);
}
return json(res, 404, { error: 'not found' });
} catch (e: any) {
const msg = e?.message || 'error';
// Boundary errors (bad input, un-installed shop) are 400; the rest 500.
const status = /invalid|missing|not installed|unknown|not found/i.test(msg) ? 400 : 500;
log({ msg: 'request error', path: url.pathname, error: msg, status });
return json(res, status, { error: msg });
}
};
}
// main boots the server when run directly. Import-safe: only the direct entry
// listens, so tests import the handlers without opening a port.
export function main(): void {
const cfg = readServerConfig(process.env);
const server = createServer(createHandler(cfg));
server.listen(cfg.port, () => {
log({ msg: 'hanzo shopify service up', port: cfg.port, ai: !!cfg.hanzoApiKey });
});
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
main();
}
+278
View File
@@ -0,0 +1,278 @@
// Shopify Admin GraphQL API — pure request shaping + response parsing. Every
// function returns a PreparedGraphql (url + headers + JSON body) or parses a
// response body; none opens a socket, so the whole API surface is unit-testable
// without a network. server.ts is the thin glue that fetches these shapes with a
// shop's offline access token.
//
// The endpoint is `https://{shop}/admin/api/{version}/graphql.json` (see
// config.adminGraphqlUrl). Authorization is the per-shop token in the
// X-Shopify-Access-Token header (NOT a Bearer). Docs:
// shopify.dev/docs/api/admin-graphql
import { adminGraphqlUrl } from '../config.js';
// A prepared GraphQL POST: the pieces a single fetch needs. The access token
// rides in the X-Shopify-Access-Token header, per Shopify's Admin API.
export interface PreparedGraphql {
url: string;
headers: Record<string, string>;
body: string;
}
// graphql builds a PreparedGraphql from a query + variables against a shop.
// One place assembles the token header + JSON body so every call is identical.
export function graphql(
shop: string,
accessToken: string,
query: string,
variables: Record<string, unknown>,
): PreparedGraphql {
return {
url: adminGraphqlUrl(shop),
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': accessToken,
},
body: JSON.stringify({ query, variables }),
};
}
// ---- Queries / mutations --------------------------------------------------
// PRODUCT_QUERY reads the fields the AI needs to write content: title, current
// description (as body HTML + plain text), product type, vendor, tags, the SEO
// title/description, and the first handful of variants for price/option context.
// We ask for exactly what the prompt uses — no over-fetching.
export const PRODUCT_QUERY = /* GraphQL */ `
query ProductForAI($id: ID!) {
product(id: $id) {
id
title
handle
descriptionHtml
description
productType
vendor
tags
status
seo { title description }
options { name values }
variants(first: 10) {
nodes { title sku price }
}
}
}
`;
// getProduct — fetch one product by its GID (gid://shopify/Product/123).
export function getProduct(shop: string, accessToken: string, id: string): PreparedGraphql {
return graphql(shop, accessToken, PRODUCT_QUERY, { id });
}
// PRODUCT_UPDATE_MUTATION writes AI-generated content back: the description HTML
// and/or the SEO title/description. `input` is Shopify's ProductInput; we only
// ever send the fields we changed. userErrors is requested so a validation
// failure surfaces as data, not a thrown 200.
export const PRODUCT_UPDATE_MUTATION = /* GraphQL */ `
mutation UpdateProduct($input: ProductInput!) {
productUpdate(input: $input) {
product { id title descriptionHtml seo { title description } }
userErrors { field message }
}
}
`;
// The write-back fields the AI produces. Any subset may be present — we send
// only what is set, so a "rewrite description" never clobbers the SEO title.
export interface ProductWriteback {
id: string;
descriptionHtml?: string;
seoTitle?: string;
seoDescription?: string;
}
// buildProductInput turns a ProductWriteback into Shopify's ProductInput shape,
// including `seo` only when a title or description is set. Pure so the exact
// mutation variables are asserted in a test. Throws on a missing id (the mutation
// cannot target a product without it) — a boundary error surfaced to the caller.
export function buildProductInput(w: ProductWriteback): Record<string, unknown> {
if (!w.id) throw new Error('productUpdate requires a product id');
const input: Record<string, unknown> = { id: w.id };
if (w.descriptionHtml !== undefined) input.descriptionHtml = w.descriptionHtml;
if (w.seoTitle !== undefined || w.seoDescription !== undefined) {
const seo: Record<string, unknown> = {};
if (w.seoTitle !== undefined) seo.title = w.seoTitle;
if (w.seoDescription !== undefined) seo.description = w.seoDescription;
input.seo = seo;
}
return input;
}
// updateProduct — the productUpdate mutation with the built input.
export function updateProduct(shop: string, accessToken: string, w: ProductWriteback): PreparedGraphql {
return graphql(shop, accessToken, PRODUCT_UPDATE_MUTATION, { input: buildProductInput(w) });
}
// ORDER_QUERY reads the fields the AI needs to summarize an order and draft a
// customer reply: name/number, financial + fulfillment status, totals, the
// customer, line items, the shipping address, and the internal note. Enough to
// answer "what is this order" and "draft a reply" without a second round trip.
export const ORDER_QUERY = /* GraphQL */ `
query OrderForAI($id: ID!) {
order(id: $id) {
id
name
note
email
displayFinancialStatus
displayFulfillmentStatus
createdAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { firstName lastName email }
shippingAddress { city province country }
lineItems(first: 50) {
nodes { title quantity sku }
}
}
}
`;
// getOrder — fetch one order by its GID (gid://shopify/Order/123).
export function getOrder(shop: string, accessToken: string, id: string): PreparedGraphql {
return graphql(shop, accessToken, ORDER_QUERY, { id });
}
// ---- Response parsing -----------------------------------------------------
// The normalized product our prompt assembly consumes. Snake/camel from the wire
// is normalized here so nothing downstream touches the GraphQL shape.
export interface ProductInfo {
id: string;
title: string;
handle: string;
descriptionHtml: string;
description: string;
productType: string;
vendor: string;
tags: string[];
status: string;
seoTitle: string;
seoDescription: string;
options: Array<{ name: string; values: string[] }>;
variants: Array<{ title: string; sku: string; price: string }>;
}
// graphqlErrors extracts the top-level `errors` array a GraphQL endpoint returns
// on a bad query (distinct from userErrors on a mutation). Returns the joined
// messages, or '' when there are none. Pure.
export function graphqlErrors(data: any): string {
const errs = data?.errors;
if (!Array.isArray(errs) || errs.length === 0) return '';
return errs.map((e: any) => String(e?.message ?? e)).join('; ');
}
// parseProduct reads a getProduct response into a ProductInfo. Throws on a
// GraphQL error or a null product (a bad id) so the caller learns immediately.
export function parseProduct(data: any): ProductInfo {
const err = graphqlErrors(data);
if (err) throw new Error(`Shopify GraphQL error: ${err}`);
const p = data?.data?.product;
if (!p) throw new Error('product not found');
return {
id: String(p.id ?? ''),
title: String(p.title ?? ''),
handle: String(p.handle ?? ''),
descriptionHtml: String(p.descriptionHtml ?? ''),
description: String(p.description ?? ''),
productType: String(p.productType ?? ''),
vendor: String(p.vendor ?? ''),
tags: Array.isArray(p.tags) ? p.tags.map(String) : [],
status: String(p.status ?? ''),
seoTitle: String(p.seo?.title ?? ''),
seoDescription: String(p.seo?.description ?? ''),
options: Array.isArray(p.options)
? p.options.map((o: any) => ({
name: String(o?.name ?? ''),
values: Array.isArray(o?.values) ? o.values.map(String) : [],
}))
: [],
variants: Array.isArray(p.variants?.nodes)
? p.variants.nodes.map((v: any) => ({
title: String(v?.title ?? ''),
sku: String(v?.sku ?? ''),
price: String(v?.price ?? ''),
}))
: [],
};
}
// The normalized order our prompt assembly + summary consume.
export interface OrderInfo {
id: string;
name: string;
note: string;
email: string;
financialStatus: string;
fulfillmentStatus: string;
createdAt: string;
totalAmount: string;
currency: string;
customerName: string;
shipTo: string;
lineItems: Array<{ title: string; quantity: number; sku: string }>;
}
// parseOrder reads a getOrder response into an OrderInfo. Throws on a GraphQL
// error or a null order.
export function parseOrder(data: any): OrderInfo {
const err = graphqlErrors(data);
if (err) throw new Error(`Shopify GraphQL error: ${err}`);
const o = data?.data?.order;
if (!o) throw new Error('order not found');
const money = o.totalPriceSet?.shopMoney ?? {};
const cust = o.customer ?? {};
const addr = o.shippingAddress ?? {};
const customerName = [cust.firstName, cust.lastName].filter(Boolean).map(String).join(' ').trim();
const shipTo = [addr.city, addr.province, addr.country].filter(Boolean).map(String).join(', ');
return {
id: String(o.id ?? ''),
name: String(o.name ?? ''),
note: String(o.note ?? ''),
email: String(o.email ?? cust.email ?? ''),
financialStatus: String(o.displayFinancialStatus ?? ''),
fulfillmentStatus: String(o.displayFulfillmentStatus ?? ''),
createdAt: String(o.createdAt ?? ''),
totalAmount: String(money.amount ?? ''),
currency: String(money.currencyCode ?? ''),
customerName,
shipTo,
lineItems: Array.isArray(o.lineItems?.nodes)
? o.lineItems.nodes.map((li: any) => ({
title: String(li?.title ?? ''),
quantity: Number(li?.quantity ?? 0) || 0,
sku: String(li?.sku ?? ''),
}))
: [],
};
}
// parseProductUpdate reads a productUpdate response, surfacing userErrors (a
// validation failure Shopify returns inside a 200) as a thrown error so the
// write-back path has ONE way to fail: reject. Returns the updated product's id
// + title on success. Pure.
export function parseProductUpdate(data: any): { id: string; title: string } {
const err = graphqlErrors(data);
if (err) throw new Error(`Shopify GraphQL error: ${err}`);
const result = data?.data?.productUpdate;
if (!result) throw new Error('productUpdate returned no result');
const userErrors = Array.isArray(result.userErrors) ? result.userErrors : [];
if (userErrors.length > 0) {
const msg = userErrors
.map((e: any) => `${Array.isArray(e?.field) ? e.field.join('.') : e?.field ?? ''}: ${e?.message ?? ''}`.trim())
.join('; ');
throw new Error(`productUpdate failed: ${msg}`);
}
const product = result.product;
if (!product?.id) throw new Error('productUpdate returned no product');
return { id: String(product.id), title: String(product.title ?? '') };
}
+90
View File
@@ -0,0 +1,90 @@
// Shopify webhooks: HMAC signature verification and topic routing — all pure
// over their inputs so they are unit-testable without an http server. server.ts
// is the thin http glue that reads the raw body + headers and calls these.
// Crypto is Node's stdlib (node:crypto), never a dependency.
//
// Shopify signs every webhook with the app's API secret. Unlike the OAuth
// callback (hex over sorted query params), a webhook signature is:
// X-Shopify-Hmac-Sha256: base64(HMAC-SHA256(apiSecret, RAW_REQUEST_BODY))
// computed over the RAW body bytes. We must NOT JSON.parse-and-reserialize
// before verifying (key order/whitespace would differ and every webhook would
// fail). This is the whole trust boundary: a webhook that fails here is
// discarded before it is acted on. The shop + topic ride in headers
// (X-Shopify-Shop-Domain, X-Shopify-Topic). Docs:
// shopify.dev/docs/apps/build/webhooks/subscribe/verify-webhooks
import { createHmac, timingSafeEqual } from 'node:crypto';
// computeWebhookHmac is base64(HMAC-SHA256(apiSecret, rawBody)) — the value
// Shopify puts in X-Shopify-Hmac-Sha256. Both verification and any test fixture
// are built from it, so the value we compare is produced exactly as Shopify
// produces it.
export function computeWebhookHmac(apiSecret: string, rawBody: string): string {
return createHmac('sha256', apiSecret).update(rawBody, 'utf8').digest('base64');
}
// constantTimeEqualB64 compares two base64 digests without leaking
// length/position via timing. Different-length inputs are unequal and
// short-circuit safely (never feeding mismatched buffers to timingSafeEqual,
// which would throw).
export function constantTimeEqualB64(a: string, b: string): boolean {
const ba = Buffer.from(a, 'base64');
const bb = Buffer.from(b, 'base64');
if (ba.length === 0 || ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
// verifyWebhook is the trust gate: recompute base64(HMAC-SHA256(apiSecret,
// rawBody)) and constant-time compare against the X-Shopify-Hmac-Sha256 header.
// Returns false on any missing input rather than throwing, so a malformed
// request is a clean reject.
export function verifyWebhook(apiSecret: string, headerHmac: string | undefined, rawBody: string): boolean {
if (!apiSecret || !headerHmac) return false;
return constantTimeEqualB64(headerHmac, computeWebhookHmac(apiSecret, rawBody));
}
// ---- Topic routing --------------------------------------------------------
//
// After a webhook is verified, we route on the X-Shopify-Topic header into a
// discriminated action so the server can dispatch. We act on order creation
// (auto-draft a support summary), app uninstall (drop the stored token), and
// the three GDPR "mandatory" topics every public app MUST handle to pass App
// Store review. Everything unknown is acknowledged and ignored — a clean 200.
export type WebhookAction =
| { kind: 'order_created'; shop: string; orderId: string }
| { kind: 'app_uninstalled'; shop: string }
| { kind: 'gdpr'; topic: string; shop: string }
| { kind: 'ignored'; topic: string; shop: string };
// The three GDPR/privacy topics Shopify requires a public app to subscribe to
// and respond to. shopify.dev/docs/apps/build/privacy-law-compliance
const GDPR_TOPICS = new Set([
'customers/data_request',
'customers/redact',
'shop/redact',
]);
// gid://shopify/Order/12345 → "gid://shopify/Order/12345". We keep the full GID
// (the GraphQL Admin API takes GIDs); the numeric REST `id` is a fallback. Pure.
function orderIdOf(body: any): string {
return String(body?.admin_graphql_api_id ?? body?.id ?? '');
}
// routeWebhook turns a topic + PARSED, ALREADY-VERIFIED body into a
// WebhookAction. Verification is the caller's job (verifyWebhook) — this is pure
// dispatch, unit-testable with plain objects. `shop` is the
// X-Shopify-Shop-Domain header value.
export function routeWebhook(topic: string, shop: string, body: any): WebhookAction {
const t = topic.toLowerCase();
if (t === 'orders/create') {
return { kind: 'order_created', shop, orderId: orderIdOf(body) };
}
if (t === 'app/uninstalled') {
return { kind: 'app_uninstalled', shop };
}
if (GDPR_TOPICS.has(t)) {
return { kind: 'gdpr', topic: t, shop };
}
return { kind: 'ignored', topic: t, shop };
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest';
import type { AiClient } from '@hanzo/ai';
import {
PRODUCT_ACTIONS,
ORDER_ACTIONS,
isProductActionId,
isOrderActionId,
productActionPrompt,
orderActionPrompt,
productActionList,
orderActionList,
runProductAction,
runOrderAction,
} from '../src/server/actions';
import type { ProductInfo, OrderInfo } from '../src/server/shopify-api';
const PRODUCT: ProductInfo = {
id: 'gid://shopify/Product/1',
title: 'Wool Beanie',
handle: 'wool-beanie',
descriptionHtml: '<p>Warm.</p>',
description: 'Warm.',
productType: 'Hats',
vendor: 'Acme',
tags: [],
status: 'ACTIVE',
seoTitle: '',
seoDescription: '',
options: [],
variants: [],
};
const ORDER: OrderInfo = {
id: 'gid://shopify/Order/2',
name: '#1002',
note: '',
email: '',
financialStatus: 'PAID',
fulfillmentStatus: 'UNFULFILLED',
createdAt: '',
totalAmount: '10.00',
currency: 'USD',
customerName: 'Sam',
shipTo: '',
lineItems: [],
};
function capturingClient(): { client: AiClient; lastTask: () => string } {
let lastMessages: any[] = [];
const client = {
chat: {
completions: {
create: async (params: any) => {
lastMessages = params.messages;
return { id: 'x', object: 'chat.completion', created: 0, model: params.model, choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }] };
},
},
},
models: { list: async () => [] },
} as unknown as AiClient;
// the task is the tail after "---- task ----\n"
const lastTask = () => String(lastMessages[1]?.content ?? '').split('---- task ----\n')[1] ?? '';
return { client, lastTask };
}
describe('action catalogs', () => {
it('product actions expose the four content actions', () => {
expect(Object.keys(PRODUCT_ACTIONS)).toEqual([
'writeDescription',
'rewriteDescription',
'seoTitle',
'seoDescription',
]);
});
it('order actions expose summarize / draftReply / extractIssues', () => {
expect(Object.keys(ORDER_ACTIONS)).toEqual(['summarize', 'draftReply', 'extractIssues']);
});
it('lists are derived from the catalogs (labels never drift)', () => {
expect(productActionList().map((a) => a.id)).toEqual(Object.keys(PRODUCT_ACTIONS));
expect(orderActionList().map((a) => a.id)).toEqual(Object.keys(ORDER_ACTIONS));
expect(productActionList()[0].label).toBe(PRODUCT_ACTIONS.writeDescription.label);
});
});
describe('id guards', () => {
it('narrow known ids and reject unknown', () => {
expect(isProductActionId('writeDescription')).toBe(true);
expect(isProductActionId('summarize')).toBe(false);
expect(isOrderActionId('summarize')).toBe(true);
expect(isOrderActionId('writeDescription')).toBe(false);
});
});
describe('prompt resolution', () => {
it('resolves a known product/order prompt', () => {
expect(productActionPrompt('seoTitle')).toBe(PRODUCT_ACTIONS.seoTitle.prompt);
expect(orderActionPrompt('draftReply')).toBe(ORDER_ACTIONS.draftReply.prompt);
});
it('throws on an unknown id', () => {
expect(() => productActionPrompt('nope')).toThrow(/Unknown product action/);
expect(() => orderActionPrompt('nope')).toThrow(/Unknown order action/);
});
});
describe('runProductAction / runOrderAction', () => {
it('runs the resolved prompt over the subject as the task', async () => {
const p = capturingClient();
await runProductAction('seoTitle', PRODUCT, { client: p.client });
expect(p.lastTask()).toBe(PRODUCT_ACTIONS.seoTitle.prompt);
const o = capturingClient();
await runOrderAction('summarize', ORDER, { client: o.client });
expect(o.lastTask()).toBe(ORDER_ACTIONS.summarize.prompt);
});
it('rejects (not throws synchronously) on an unknown id', async () => {
await expect(runProductAction('nope', PRODUCT)).rejects.toThrow(/Unknown product action/);
await expect(runOrderAction('nope', ORDER)).rejects.toThrow(/Unknown order action/);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { createApi } from '../src/app/api';
const SHOP = 'my-store.myshopify.com';
// A fetch stub recording requests and replaying queued responses. Lets us assert
// the exact URL + body the panel's client shapes, with no network.
function stubFetch(responses: Array<{ status?: number; body: unknown }>) {
const calls: Array<{ url: string; init?: RequestInit }> = [];
let i = 0;
const fetchImpl = (async (url: any, init?: RequestInit) => {
calls.push({ url: String(url), init });
const r = responses[i++] ?? { status: 200, body: {} };
return new Response(JSON.stringify(r.body), { status: r.status ?? 200, headers: { 'Content-Type': 'application/json' } });
}) as unknown as typeof fetch;
return { fetch: fetchImpl, calls };
}
describe('createApi request shaping', () => {
it('getProduct GETs /v1/product with the shop + id query', async () => {
const s = stubFetch([{ body: { id: 'gid://shopify/Product/1', title: 'Beanie' } }]);
const api = createApi({ baseUrl: 'https://shopify.hanzo.ai', shop: SHOP, fetch: s.fetch });
const p = await api.getProduct('gid://shopify/Product/1');
expect(p.title).toBe('Beanie');
const url = new URL(s.calls[0].url);
expect(url.pathname).toBe('/v1/product');
expect(url.searchParams.get('shop')).toBe(SHOP);
expect(url.searchParams.get('id')).toBe('gid://shopify/Product/1');
});
it('runProductAction POSTs /v1/product/action with shop+id+action+model and returns content', async () => {
const s = stubFetch([{ body: { content: 'A great description.' } }]);
const api = createApi({ shop: SHOP, fetch: s.fetch });
const out = await api.runProductAction('gid://shopify/Product/1', 'writeDescription', 'zen5');
expect(out).toBe('A great description.');
expect(s.calls[0].url).toContain('/v1/product/action');
expect(s.calls[0].init?.method).toBe('POST');
expect(JSON.parse(String(s.calls[0].init?.body))).toEqual({
shop: SHOP,
id: 'gid://shopify/Product/1',
action: 'writeDescription',
model: 'zen5',
});
});
it('updateProduct POSTs /v1/product/update with only the fields set', async () => {
const s = stubFetch([{ body: { ok: true, product: { id: 'gid://shopify/Product/1', title: 'Beanie' } } }]);
const api = createApi({ shop: SHOP, fetch: s.fetch });
const res = await api.updateProduct('gid://shopify/Product/1', { descriptionHtml: '<p>new</p>' });
expect(res).toEqual({ id: 'gid://shopify/Product/1', title: 'Beanie' });
expect(JSON.parse(String(s.calls[0].init?.body))).toEqual({
shop: SHOP,
id: 'gid://shopify/Product/1',
descriptionHtml: '<p>new</p>',
});
});
it('listModels reads { models } from /v1/models', async () => {
const s = stubFetch([{ body: { models: ['zen5', 'zen5-mini'] } }]);
const api = createApi({ shop: SHOP, fetch: s.fetch });
expect(await api.listModels()).toEqual(['zen5', 'zen5-mini']);
});
it('throws the server error message on a non-ok response', async () => {
const s = stubFetch([{ status: 400, body: { error: 'shop not installed' } }]);
const api = createApi({ shop: SHOP, fetch: s.fetch });
await expect(api.getProduct('gid://shopify/Product/1')).rejects.toThrow('shop not installed');
});
it('same-origin base ("" ) builds a root-relative URL', async () => {
const s = stubFetch([{ body: { id: 'x', name: '#1', lineItems: [] } }]);
const api = createApi({ shop: SHOP, fetch: s.fetch });
await api.getOrder('gid://shopify/Order/1');
expect(s.calls[0].url.startsWith('/v1/order?')).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import {
HANZO_API_BASE_URL,
DEFAULT_MODEL,
ADMIN_API_VERSION,
OAUTH_SCOPES,
chatCompletionsURL,
modelsURL,
isShopDomain,
adminGraphqlUrl,
readServerConfig,
} from '../src/config';
describe('gateway URLs', () => {
it('build /v1 endpoints on api.hanzo.ai with no /api/ prefix', () => {
expect(chatCompletionsURL()).toBe(`${HANZO_API_BASE_URL}/v1/chat/completions`);
expect(modelsURL()).toBe(`${HANZO_API_BASE_URL}/v1/models`);
expect(chatCompletionsURL()).not.toContain('/api/');
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
});
it('defaults to a zen model', () => {
expect(DEFAULT_MODEL).toBe('zen5');
});
});
describe('OAuth scopes', () => {
it('requests exactly read/write products + read orders', () => {
expect([...OAUTH_SCOPES]).toEqual(['read_products', 'write_products', 'read_orders']);
});
});
describe('isShopDomain (boundary guard)', () => {
it('accepts a valid myshopify.com store domain', () => {
expect(isShopDomain('my-store.myshopify.com')).toBe(true);
expect(isShopDomain('store123.myshopify.com')).toBe(true);
});
it('rejects anything not a myshopify.com store', () => {
expect(isShopDomain('evil.com')).toBe(false);
expect(isShopDomain('my-store.myshopify.com.evil.com')).toBe(false);
expect(isShopDomain('https://my-store.myshopify.com')).toBe(false);
expect(isShopDomain('my-store.myshopify.com/admin')).toBe(false);
expect(isShopDomain('UPPER.myshopify.com')).toBe(false);
expect(isShopDomain('.myshopify.com')).toBe(false);
expect(isShopDomain(undefined)).toBe(false);
expect(isShopDomain('')).toBe(false);
});
});
describe('adminGraphqlUrl', () => {
it('builds the versioned graphql endpoint for a shop', () => {
expect(adminGraphqlUrl('my-store.myshopify.com')).toBe(
`https://my-store.myshopify.com/admin/api/${ADMIN_API_VERSION}/graphql.json`,
);
});
it('honors an explicit version', () => {
expect(adminGraphqlUrl('s.myshopify.com', '2024-10')).toBe(
'https://s.myshopify.com/admin/api/2024-10/graphql.json',
);
});
it('never contains an /api/v1 or /api/ hanzo-style prefix mistake (this is the shop host)', () => {
expect(adminGraphqlUrl('s.myshopify.com')).toContain('/admin/api/');
});
});
describe('readServerConfig', () => {
const base = {
SHOPIFY_API_KEY: 'apikey',
SHOPIFY_API_SECRET: 'secret',
SHOPIFY_REDIRECT_URI: 'https://shopify.hanzo.ai/oauth/callback',
};
it('reads a full config and defaults scopes/port; hanzoApiKey optional', () => {
const cfg = readServerConfig(base);
expect(cfg.shopifyApiKey).toBe('apikey');
expect(cfg.shopifyApiSecret).toBe('secret');
expect(cfg.shopifyRedirectUri).toBe('https://shopify.hanzo.ai/oauth/callback');
expect(cfg.scopes).toBe('read_products,write_products,read_orders');
expect(cfg.hanzoApiKey).toBe('');
expect(cfg.port).toBe(8791);
});
it('honors SHOPIFY_SCOPES, HANZO_API_KEY and PORT', () => {
const cfg = readServerConfig({ ...base, SHOPIFY_SCOPES: 'read_products', HANZO_API_KEY: 'hk-x', PORT: '9100' });
expect(cfg.scopes).toBe('read_products');
expect(cfg.hanzoApiKey).toBe('hk-x');
expect(cfg.port).toBe(9100);
});
it.each(['SHOPIFY_API_KEY', 'SHOPIFY_API_SECRET', 'SHOPIFY_REDIRECT_URI'])(
'throws when %s is missing',
(key) => {
const env: Record<string, string | undefined> = { ...base };
delete env[key];
expect(() => readServerConfig(env)).toThrow(key);
},
);
it('never exposes the secret in a way the browser bundle could read (it is server-only by construction)', () => {
// The secret is only ever returned from readServerConfig, which server.ts
// calls; nothing in src/app imports config's server fields. This asserts the
// field exists exactly once as a config value (documentation of intent).
const cfg = readServerConfig(base);
expect(cfg.shopifyApiSecret).toBe('secret');
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { toGid, parseLaunchContext } from '../src/app/context';
describe('toGid', () => {
it('wraps a bare numeric id into a GID for the kind', () => {
expect(toGid('Product', '123')).toBe('gid://shopify/Product/123');
expect(toGid('Order', '9')).toBe('gid://shopify/Order/9');
});
it('leaves an existing GID unchanged', () => {
expect(toGid('Product', 'gid://shopify/Product/123')).toBe('gid://shopify/Product/123');
});
it('returns empty for empty input', () => {
expect(toGid('Product', '')).toBe('');
expect(toGid('Product', ' ')).toBe('');
});
});
describe('parseLaunchContext', () => {
it('reads shop + host', () => {
const ctx = parseLaunchContext('?shop=my-store.myshopify.com&host=abc123');
expect(ctx.shop).toBe('my-store.myshopify.com');
expect(ctx.host).toBe('abc123');
expect(ctx.resource).toBe('');
expect(ctx.resourceId).toBe('');
});
it('normalizes a bare product id to a GID', () => {
const ctx = parseLaunchContext('?shop=s.myshopify.com&resource=product&id=555');
expect(ctx.resource).toBe('product');
expect(ctx.resourceId).toBe('gid://shopify/Product/555');
});
it('normalizes a bare order id to a GID', () => {
const ctx = parseLaunchContext('?resource=order&id=777');
expect(ctx.resource).toBe('order');
expect(ctx.resourceId).toBe('gid://shopify/Order/777');
});
it('preserves a GID that is already passed', () => {
const ctx = parseLaunchContext('?resource=product&id=gid://shopify/Product/1');
expect(ctx.resourceId).toBe('gid://shopify/Product/1');
});
it('ignores an unknown resource type', () => {
const ctx = parseLaunchContext('?resource=collection&id=1');
expect(ctx.resource).toBe('');
expect(ctx.resourceId).toBe('1');
});
});
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect } from 'vitest';
import type { AiClient } from '@hanzo/ai';
import {
SYSTEM_PROMPT,
stripHtml,
buildProductContext,
buildOrderContext,
buildProductMessages,
buildOrderMessages,
runChat,
askProduct,
askOrder,
listModels,
type ChatMessage,
} from '../src/server/hanzo';
import type { ProductInfo, OrderInfo } from '../src/server/shopify-api';
const PRODUCT: ProductInfo = {
id: 'gid://shopify/Product/1',
title: 'Wool Beanie',
handle: 'wool-beanie',
descriptionHtml: '<p>A <strong>warm</strong> wool beanie.</p>',
description: 'A warm wool beanie.',
productType: 'Hats',
vendor: 'Acme',
tags: ['winter', 'wool'],
status: 'ACTIVE',
seoTitle: 'Wool Beanie',
seoDescription: 'Warm wool beanie',
options: [{ name: 'Color', values: ['Black', 'Grey'] }],
variants: [{ title: 'Black', sku: 'WB-BLK', price: '24.00' }],
};
const ORDER: OrderInfo = {
id: 'gid://shopify/Order/2',
name: '#1002',
note: 'Customer asked to expedite.',
email: 'buyer@example.com',
financialStatus: 'PAID',
fulfillmentStatus: 'UNFULFILLED',
createdAt: '2026-01-02T10:00:00Z',
totalAmount: '48.00',
currency: 'USD',
customerName: 'Sam Lee',
shipTo: 'Austin, Texas, United States',
lineItems: [{ title: 'Wool Beanie', quantity: 2, sku: 'WB-BLK' }],
};
// A fake AiClient capturing the params passed to chat.completions.create and
// models.list. This is how we assert request shaping without a network.
function fakeClient(reply: string, models: string[] = ['zen5', 'zen5-mini']): {
client: AiClient;
calls: { params: any; options: any }[];
modelCalls: number;
} {
const calls: { params: any; options: any }[] = [];
let modelCalls = 0;
const client = {
chat: {
completions: {
create: async (params: any, options: any) => {
calls.push({ params, options });
return {
id: 'x',
object: 'chat.completion',
created: 0,
model: params.model,
choices: [{ index: 0, message: { role: 'assistant', content: reply }, finish_reason: 'stop' }],
};
},
},
},
models: {
list: async () => {
modelCalls++;
return models.map((id) => ({ id, object: 'model' }));
},
},
} as unknown as AiClient;
return { client, calls, get modelCalls() { return modelCalls; } } as any;
}
describe('stripHtml', () => {
it('drops tags and decodes basic entities', () => {
expect(stripHtml('<p>A <strong>warm</strong> &amp; cozy hat.</p>')).toBe('A warm & cozy hat.');
});
});
describe('buildProductContext', () => {
it('renders structured attributes + the stripped current description', () => {
const ctx = buildProductContext(PRODUCT);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('Title: Wool Beanie');
expect(ctx.text).toContain('Type: Hats');
expect(ctx.text).toContain('Tags: winter, wool');
expect(ctx.text).toContain('Option Color: Black, Grey');
expect(ctx.text).toContain('Variants: Black — SKU WB-BLK — $24.00');
expect(ctx.text).toContain('Current description: A warm wool beanie.');
// No HTML leaked into the context.
expect(ctx.text).not.toContain('<strong>');
});
it('truncates to the budget and flags it', () => {
const big: ProductInfo = { ...PRODUCT, descriptionHtml: 'x'.repeat(50) };
const ctx = buildProductContext(big, 40);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBe(40);
});
});
describe('buildOrderContext', () => {
it('renders the order fields', () => {
const ctx = buildOrderContext(ORDER);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('Order: #1002');
expect(ctx.text).toContain('Customer: Sam Lee');
expect(ctx.text).toContain('Payment: PAID');
expect(ctx.text).toContain('Total: 48.00 USD');
expect(ctx.text).toContain('Items: 2× Wool Beanie (SKU WB-BLK)');
expect(ctx.text).toContain('Internal note: Customer asked to expedite.');
});
});
describe('prompt assembly', () => {
it('buildProductMessages: system grounds, product is fenced as data, task last', () => {
const msgs = buildProductMessages('Write a description.', PRODUCT);
expect(msgs[0]).toEqual<ChatMessage>({ role: 'system', content: SYSTEM_PROMPT });
expect(msgs[1].role).toBe('user');
expect(msgs[1].content).toContain('---- product ----');
expect(msgs[1].content).toContain('---- end product ----');
expect(msgs[1].content).toContain('---- task ----\nWrite a description.');
});
it('buildOrderMessages: fences the order and carries the task', () => {
const msgs = buildOrderMessages('Draft a reply.', ORDER);
expect(msgs[1].content).toContain('---- order ----');
expect(msgs[1].content).toContain('Draft a reply.');
});
it('prepends an honest truncation note when the context was cut', () => {
const big: ProductInfo = { ...PRODUCT, descriptionHtml: 'x'.repeat(50000) };
const msgs = buildProductMessages('Rewrite.', big);
expect(msgs[1].content).toContain('truncated to fit');
});
});
describe('runChat / ask (request shaping over @hanzo/ai)', () => {
it('sends model + messages, stream:false, and returns the assistant content', async () => {
const f = fakeClient('Generated copy.');
const out = await runChat([{ role: 'user', content: 'hi' }], { client: f.client, model: 'zen5-mini', temperature: 0.4 });
expect(out).toBe('Generated copy.');
expect(f.calls).toHaveLength(1);
expect(f.calls[0].params.model).toBe('zen5-mini');
expect(f.calls[0].params.stream).toBe(false);
expect(f.calls[0].params.temperature).toBe(0.4);
expect(f.calls[0].params.messages).toEqual([{ role: 'user', content: 'hi' }]);
});
it('defaults the model to zen5 when none is given', async () => {
const f = fakeClient('x');
await runChat([{ role: 'user', content: 'hi' }], { client: f.client });
expect(f.calls[0].params.model).toBe('zen5');
});
it('askProduct builds product messages and calls the model', async () => {
const f = fakeClient('A description.');
const out = await askProduct('Write a description.', PRODUCT, { client: f.client });
expect(out).toBe('A description.');
expect(f.calls[0].params.messages[1].content).toContain('Wool Beanie');
});
it('askOrder builds order messages and calls the model', async () => {
const f = fakeClient('A reply.');
const out = await askOrder('Draft a reply.', ORDER, { client: f.client });
expect(out).toBe('A reply.');
expect(f.calls[0].params.messages[1].content).toContain('#1002');
});
it('throws when the model returns empty content', async () => {
const f = fakeClient('');
await expect(runChat([{ role: 'user', content: 'x' }], { client: f.client })).rejects.toThrow(/no content/);
});
it('forwards the abort signal in options', async () => {
const f = fakeClient('x');
const controller = new AbortController();
await runChat([{ role: 'user', content: 'x' }], { client: f.client, signal: controller.signal });
expect(f.calls[0].options.signal).toBe(controller.signal);
});
});
describe('listModels', () => {
it('returns the model ids from the client', async () => {
const f = fakeClient('x', ['zen5', 'zen5-mini', 'zen5-coder']);
expect(await listModels({ client: f.client })).toEqual(['zen5', 'zen5-mini', 'zen5-coder']);
});
});
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import { createHmac } from 'node:crypto';
import {
authorizeUrl,
tokenExchange,
parseTokenResponse,
callbackMessage,
computeCallbackHmac,
constantTimeEqualHex,
verifyCallbackHmac,
parseCallback,
} from '../src/server/oauth';
const SECRET = 'shpss_app_secret_abc';
// The reference computation Shopify performs for the OAuth callback:
// hex(HMAC-SHA256(secret, sorted-message)).
function refHex(secret: string, message: string): string {
return createHmac('sha256', secret).update(message, 'utf8').digest('hex');
}
describe('authorizeUrl', () => {
it('builds the shop authorize URL with comma-joined scopes and state', () => {
const url = authorizeUrl({
shop: 'my-store.myshopify.com',
apiKey: 'apikey',
scopes: 'read_products,write_products,read_orders',
redirectUri: 'https://shopify.hanzo.ai/oauth/callback',
state: 'nonce-1',
});
expect(url.startsWith('https://my-store.myshopify.com/admin/oauth/authorize?')).toBe(true);
const q = new URL(url).searchParams;
expect(q.get('client_id')).toBe('apikey');
expect(q.get('scope')).toBe('read_products,write_products,read_orders');
expect(q.get('redirect_uri')).toBe('https://shopify.hanzo.ai/oauth/callback');
expect(q.get('state')).toBe('nonce-1');
// Offline mode is the default — no per-user grant option.
expect(url).not.toContain('grant_options');
});
});
describe('tokenExchange', () => {
it('posts client_id + client_secret + code as JSON to the shop token endpoint', () => {
const req = tokenExchange({ shop: 'my-store.myshopify.com', apiKey: 'apikey', apiSecret: SECRET, code: 'the-code' });
expect(req.url).toBe('https://my-store.myshopify.com/admin/oauth/access_token');
expect(req.headers['Content-Type']).toBe('application/json');
const body = JSON.parse(req.body);
expect(body).toEqual({ client_id: 'apikey', client_secret: SECRET, code: 'the-code' });
});
it('keeps the secret in the body only, never the URL', () => {
const req = tokenExchange({ shop: 's.myshopify.com', apiKey: 'k', apiSecret: SECRET, code: 'c' });
expect(req.url).not.toContain(SECRET);
});
});
describe('parseTokenResponse', () => {
it('returns access_token + scope', () => {
expect(parseTokenResponse({ access_token: 'shpat_x', scope: 'read_products' })).toEqual({
access_token: 'shpat_x',
scope: 'read_products',
});
});
it('throws on an error payload', () => {
expect(() => parseTokenResponse({ error: 'invalid_request', error_description: 'bad code' })).toThrow(/bad code/);
});
it('throws when access_token is missing', () => {
expect(() => parseTokenResponse({ scope: 'x' })).toThrow(/access_token/);
expect(() => parseTokenResponse(null)).toThrow();
});
});
describe('callbackMessage', () => {
it('drops hmac + signature, sorts remaining params, joins key=value', () => {
const msg = callbackMessage({
code: 'c',
shop: 'my-store.myshopify.com',
state: 'nonce',
timestamp: '1700000000',
hmac: 'sig-to-drop',
signature: 'legacy-to-drop',
});
expect(msg).toBe('code=c&shop=my-store.myshopify.com&state=nonce&timestamp=1700000000');
expect(msg).not.toContain('hmac');
expect(msg).not.toContain('signature');
});
it('URL-encodes values to match Shopify canonical form (URLSearchParams.toString)', () => {
// `host` is base64url with `=` padding and `state` can carry a `/` — both
// encode. A raw `key=value` join would produce the wrong message and fail to
// validate a real callback; the encoded form matches Shopify's ProcessedQuery.
const params = { host: 'abc==', shop: 's.myshopify.com', state: 'a/b c' };
const msg = callbackMessage(params);
const expected = new URLSearchParams([
['host', 'abc=='],
['shop', 's.myshopify.com'],
['state', 'a/b c'],
]).toString();
expect(msg).toBe(expected);
expect(msg).toContain('host=abc%3D%3D');
expect(msg).toContain('state=a%2Fb+c');
});
});
describe('computeCallbackHmac + constantTimeEqualHex', () => {
it('matches the Shopify hex reference', () => {
const msg = 'code=c&shop=s.myshopify.com&state=n&timestamp=1';
expect(computeCallbackHmac(SECRET, msg)).toBe(refHex(SECRET, msg));
});
it('constant-time hex compare: true for equal, false for different / different length', () => {
const a = refHex(SECRET, 'x');
expect(constantTimeEqualHex(a, a)).toBe(true);
expect(constantTimeEqualHex(a, refHex(SECRET, 'y'))).toBe(false);
expect(constantTimeEqualHex(a, a.slice(0, -2))).toBe(false);
expect(constantTimeEqualHex('', a)).toBe(false);
});
});
describe('verifyCallbackHmac (OAuth trust gate)', () => {
function signedParams(secret: string, params: Record<string, string>): Record<string, string> {
const message = callbackMessage(params);
return { ...params, hmac: refHex(secret, message) };
}
const params = { code: 'c', shop: 'my-store.myshopify.com', state: 'nonce', timestamp: '1700000000' };
it('accepts a correctly signed callback', () => {
expect(verifyCallbackHmac(SECRET, signedParams(SECRET, params))).toBe(true);
});
it('rejects a tampered param (shop swapped after signing)', () => {
const signed = signedParams(SECRET, params);
signed.shop = 'attacker.myshopify.com';
expect(verifyCallbackHmac(SECRET, signed)).toBe(false);
});
it('rejects a wrong secret', () => {
expect(verifyCallbackHmac('other-secret', signedParams(SECRET, params))).toBe(false);
});
it('rejects a missing hmac param', () => {
expect(verifyCallbackHmac(SECRET, params)).toBe(false);
});
it('rejects an empty secret', () => {
expect(verifyCallbackHmac('', signedParams(SECRET, params))).toBe(false);
});
it('validates against a Shopify-canonical signature over encodable params', () => {
// Independently reproduce Shopify's signing: sorted params → URLSearchParams
// → toString() → hex HMAC. If our callbackMessage matched only itself (raw
// join) this would fail, because `host`/`state` encode.
const raw = { code: 'the code', host: 'sub==', shop: 's.myshopify.com', state: 'x/y' };
const canonical = new URLSearchParams([
['code', 'the code'],
['host', 'sub=='],
['shop', 's.myshopify.com'],
['state', 'x/y'],
]).toString();
const signed = { ...raw, hmac: refHex(SECRET, canonical) };
expect(verifyCallbackHmac(SECRET, signed)).toBe(true);
});
});
describe('parseCallback', () => {
it('returns shop/code/state for a valid callback', () => {
expect(parseCallback({ shop: 'my-store.myshopify.com', code: 'c', state: 'n' })).toEqual({
shop: 'my-store.myshopify.com',
code: 'c',
state: 'n',
});
});
it('throws on an invalid shop domain (defense in depth)', () => {
expect(() => parseCallback({ shop: 'evil.com', code: 'c' })).toThrow(/shop/);
});
it('throws on a missing code', () => {
expect(() => parseCallback({ shop: 'my-store.myshopify.com' })).toThrow(/code/);
});
it('defaults state to empty when absent', () => {
expect(parseCallback({ shop: 'my-store.myshopify.com', code: 'c' }).state).toBe('');
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { authenticatedFetch } from '../src/app/session-fetch';
// A base fetch that records the request it was given and returns a canned 200.
function recordingFetch(): { fetch: typeof fetch; last: () => { url: any; init: any } } {
let url: any;
let init: any;
const f = (async (u: any, i: any) => {
url = u;
init = i;
return new Response('{}', { status: 200 });
}) as unknown as typeof fetch;
return { fetch: f, last: () => ({ url, init }) };
}
describe('authenticatedFetch', () => {
it('adds a fresh session token as a Bearer header on each request', async () => {
const rec = recordingFetch();
const f = authenticatedFetch(rec.fetch, async () => 'jwt-token');
await f('/v1/product?shop=s.myshopify.com&id=1');
const headers = new Headers(rec.last().init.headers);
expect(headers.get('Authorization')).toBe('Bearer jwt-token');
});
it('merges (does not clobber) caller headers', async () => {
const rec = recordingFetch();
const f = authenticatedFetch(rec.fetch, async () => 'jwt');
await f('/v1/product/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const headers = new Headers(rec.last().init.headers);
expect(headers.get('Content-Type')).toBe('application/json');
expect(headers.get('Authorization')).toBe('Bearer jwt');
expect(rec.last().init.method).toBe('POST');
});
it('fetches a NEW token per request (tokens expire ~1 min, never cached)', async () => {
const rec = recordingFetch();
let n = 0;
const f = authenticatedFetch(rec.fetch, async () => `t${++n}`);
await f('/a');
await f('/b');
expect(n).toBe(2);
});
it('sends the request without the header (server will 401) when token retrieval fails', async () => {
const rec = recordingFetch();
const f = authenticatedFetch(rec.fetch, async () => {
throw new Error('app bridge not ready');
});
const resp = await f('/x');
expect(resp.status).toBe(200); // the request still went out
const headers = new Headers(rec.last().init.headers);
expect(headers.get('Authorization')).toBeNull();
});
it('omits the header when the token is empty', async () => {
const rec = recordingFetch();
const f = authenticatedFetch(rec.fetch, async () => '');
await f('/x');
const headers = new Headers(rec.last().init.headers);
expect(headers.get('Authorization')).toBeNull();
});
});
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import {
graphql,
getProduct,
getOrder,
updateProduct,
buildProductInput,
parseProduct,
parseOrder,
parseProductUpdate,
graphqlErrors,
PRODUCT_QUERY,
ORDER_QUERY,
PRODUCT_UPDATE_MUTATION,
} from '../src/server/shopify-api';
import { ADMIN_API_VERSION } from '../src/config';
const SHOP = 'my-store.myshopify.com';
const TOKEN = 'shpat_offline_token';
describe('graphql request shaping', () => {
it('posts to the versioned graphql endpoint with the access-token header (not Bearer)', () => {
const req = graphql(SHOP, TOKEN, 'query { x }', { a: 1 });
expect(req.url).toBe(`https://${SHOP}/admin/api/${ADMIN_API_VERSION}/graphql.json`);
expect(req.headers['X-Shopify-Access-Token']).toBe(TOKEN);
expect(req.headers.Authorization).toBeUndefined();
expect(JSON.parse(req.body)).toEqual({ query: 'query { x }', variables: { a: 1 } });
});
});
describe('getProduct / getOrder', () => {
it('getProduct sends the product query with the GID variable', () => {
const req = getProduct(SHOP, TOKEN, 'gid://shopify/Product/1');
const b = JSON.parse(req.body);
expect(b.query).toBe(PRODUCT_QUERY);
expect(b.variables).toEqual({ id: 'gid://shopify/Product/1' });
});
it('getOrder sends the order query with the GID variable', () => {
const req = getOrder(SHOP, TOKEN, 'gid://shopify/Order/2');
const b = JSON.parse(req.body);
expect(b.query).toBe(ORDER_QUERY);
expect(b.variables).toEqual({ id: 'gid://shopify/Order/2' });
});
});
describe('buildProductInput (write-back)', () => {
it('includes only the fields that are set', () => {
expect(buildProductInput({ id: 'gid://shopify/Product/1', descriptionHtml: '<p>hi</p>' })).toEqual({
id: 'gid://shopify/Product/1',
descriptionHtml: '<p>hi</p>',
});
});
it('nests seo only when a title or description is present', () => {
expect(buildProductInput({ id: 'p1', seoTitle: 'T' })).toEqual({ id: 'p1', seo: { title: 'T' } });
expect(buildProductInput({ id: 'p1', seoDescription: 'D' })).toEqual({ id: 'p1', seo: { description: 'D' } });
expect(buildProductInput({ id: 'p1', seoTitle: 'T', seoDescription: 'D' })).toEqual({
id: 'p1',
seo: { title: 'T', description: 'D' },
});
});
it('a description-only write does not clobber seo, and vice versa', () => {
const descOnly = buildProductInput({ id: 'p1', descriptionHtml: '<p>x</p>' });
expect(descOnly.seo).toBeUndefined();
const seoOnly = buildProductInput({ id: 'p1', seoTitle: 'T' });
expect(seoOnly.descriptionHtml).toBeUndefined();
});
it('throws without an id', () => {
expect(() => buildProductInput({ id: '' })).toThrow(/id/);
});
});
describe('updateProduct mutation', () => {
it('sends the productUpdate mutation with the built input', () => {
const req = updateProduct(SHOP, TOKEN, { id: 'gid://shopify/Product/1', descriptionHtml: '<p>new</p>' });
const b = JSON.parse(req.body);
expect(b.query).toBe(PRODUCT_UPDATE_MUTATION);
expect(b.query).toContain('productUpdate');
expect(b.query).toContain('userErrors');
expect(b.variables).toEqual({ input: { id: 'gid://shopify/Product/1', descriptionHtml: '<p>new</p>' } });
});
});
describe('graphqlErrors', () => {
it('joins top-level error messages, or returns empty', () => {
expect(graphqlErrors({ errors: [{ message: 'boom' }, { message: 'bad' }] })).toBe('boom; bad');
expect(graphqlErrors({ data: {} })).toBe('');
});
});
describe('parseProduct', () => {
const ok = {
data: {
product: {
id: 'gid://shopify/Product/1',
title: 'Wool Beanie',
handle: 'wool-beanie',
descriptionHtml: '<p>Warm.</p>',
description: 'Warm.',
productType: 'Hats',
vendor: 'Acme',
tags: ['winter', 'wool'],
status: 'ACTIVE',
seo: { title: 'Wool Beanie', description: 'A warm wool beanie' },
options: [{ name: 'Color', values: ['Black', 'Grey'] }],
variants: { nodes: [{ title: 'Black', sku: 'WB-BLK', price: '24.00' }] },
},
},
};
it('normalizes the product fields', () => {
const p = parseProduct(ok);
expect(p.title).toBe('Wool Beanie');
expect(p.tags).toEqual(['winter', 'wool']);
expect(p.seoTitle).toBe('Wool Beanie');
expect(p.options[0]).toEqual({ name: 'Color', values: ['Black', 'Grey'] });
expect(p.variants[0]).toEqual({ title: 'Black', sku: 'WB-BLK', price: '24.00' });
});
it('throws on a graphql error', () => {
expect(() => parseProduct({ errors: [{ message: 'nope' }] })).toThrow(/nope/);
});
it('throws on a null product (bad id)', () => {
expect(() => parseProduct({ data: { product: null } })).toThrow(/not found/);
});
});
describe('parseOrder', () => {
const ok = {
data: {
order: {
id: 'gid://shopify/Order/2',
name: '#1002',
note: 'Customer asked to expedite.',
email: 'buyer@example.com',
displayFinancialStatus: 'PAID',
displayFulfillmentStatus: 'UNFULFILLED',
createdAt: '2026-01-02T10:00:00Z',
totalPriceSet: { shopMoney: { amount: '48.00', currencyCode: 'USD' } },
customer: { firstName: 'Sam', lastName: 'Lee', email: 'buyer@example.com' },
shippingAddress: { city: 'Austin', province: 'Texas', country: 'United States' },
lineItems: { nodes: [{ title: 'Wool Beanie', quantity: 2, sku: 'WB-BLK' }] },
},
},
};
it('normalizes the order, joining customer name + ship-to', () => {
const o = parseOrder(ok);
expect(o.name).toBe('#1002');
expect(o.financialStatus).toBe('PAID');
expect(o.customerName).toBe('Sam Lee');
expect(o.shipTo).toBe('Austin, Texas, United States');
expect(o.totalAmount).toBe('48.00');
expect(o.currency).toBe('USD');
expect(o.lineItems[0]).toEqual({ title: 'Wool Beanie', quantity: 2, sku: 'WB-BLK' });
});
it('throws on a null order', () => {
expect(() => parseOrder({ data: { order: null } })).toThrow(/not found/);
});
});
describe('parseProductUpdate', () => {
it('returns the updated product on success', () => {
const res = parseProductUpdate({
data: { productUpdate: { product: { id: 'gid://shopify/Product/1', title: 'Wool Beanie' }, userErrors: [] } },
});
expect(res).toEqual({ id: 'gid://shopify/Product/1', title: 'Wool Beanie' });
});
it('throws userErrors (a validation failure Shopify returns inside a 200)', () => {
expect(() =>
parseProductUpdate({
data: { productUpdate: { product: null, userErrors: [{ field: ['descriptionHtml'], message: 'too long' }] } },
}),
).toThrow(/descriptionHtml: too long/);
});
it('throws on a top-level graphql error', () => {
expect(() => parseProductUpdate({ errors: [{ message: 'denied' }] })).toThrow(/denied/);
});
});
+98
View File
@@ -0,0 +1,98 @@
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');
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -16,6 +16,7 @@ packages:
- 'packages/outlook'
- 'packages/pdf'
- 'packages/salesforce'
- 'packages/shopify'
- 'packages/slack'
- 'packages/teams'
- 'packages/tools'