Merge remote-tracking branch 'origin/feat/shopify'
This commit is contained in:
@@ -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.
|
||||
@@ -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 }}"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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. 2–4 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: 140–160 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);
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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);
|
||||
}
|
||||
@@ -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 ?? '' };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 ?? '') };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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> & 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']);
|
||||
});
|
||||
});
|
||||
@@ -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×tamp=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×tamp=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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -16,6 +16,7 @@ packages:
|
||||
- 'packages/outlook'
|
||||
- 'packages/pdf'
|
||||
- 'packages/salesforce'
|
||||
- 'packages/shopify'
|
||||
- 'packages/slack'
|
||||
- 'packages/teams'
|
||||
- 'packages/tools'
|
||||
|
||||
Reference in New Issue
Block a user