@hanzo/shopify 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -0
- package/README.md +172 -0
- package/dist/app.css +2 -0
- package/dist/app.css.map +7 -0
- package/dist/app.js +56 -0
- package/dist/app.js.map +7 -0
- package/dist/index.html +22 -0
- package/dist/server.js +56 -0
- package/dist/server.js.map +7 -0
- package/package.json +66 -0
- package/src/app/App.tsx +37 -0
- package/src/app/Assistant.tsx +330 -0
- package/src/app/api.ts +126 -0
- package/src/app/context.ts +54 -0
- package/src/app/index.html +21 -0
- package/src/app/main.tsx +51 -0
- package/src/app/session-fetch.ts +31 -0
- package/src/config.ts +122 -0
- package/src/server/actions.ts +122 -0
- package/src/server/hanzo.ts +222 -0
- package/src/server/oauth.ts +165 -0
- package/src/server/server.ts +345 -0
- package/src/server/shopify-api.ts +278 -0
- package/src/server/webhooks.ts +90 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Shopify config — the api.hanzo.ai model gateway (default model + endpoints),
|
|
2
|
+
// the Shopify Admin API version + OAuth scopes, and the server-side secret set
|
|
3
|
+
// (Shopify API key/secret + webhook HMAC). Endpoints and the bearer choice
|
|
4
|
+
// mirror @hanzo/docusign and @hanzo/notion so the productivity suite stays DRY;
|
|
5
|
+
// the commerce-specific pieces (product-context assembly, the AI actions) live
|
|
6
|
+
// in hanzo.ts / actions.ts, not here.
|
|
7
|
+
|
|
8
|
+
// ---- Hanzo model gateway --------------------------------------------------
|
|
9
|
+
|
|
10
|
+
// Where the Hanzo model gateway lives. `@hanzo/ai` (createAiClient) defaults
|
|
11
|
+
// here too. /v1 only, never an /api/ prefix (api.hanzo.ai IS the api host).
|
|
12
|
+
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
|
|
13
|
+
|
|
14
|
+
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
|
|
15
|
+
// the gateway routes it.
|
|
16
|
+
export const DEFAULT_MODEL = 'zen5';
|
|
17
|
+
|
|
18
|
+
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
|
|
19
|
+
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
|
|
20
|
+
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
|
|
21
|
+
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-shopify';
|
|
22
|
+
|
|
23
|
+
// chatCompletionsURL / modelsURL — the model gateway endpoints. The client
|
|
24
|
+
// (createAiClient) builds these itself; these exist for tests + honest docs.
|
|
25
|
+
export function chatCompletionsURL(): string {
|
|
26
|
+
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
|
|
27
|
+
}
|
|
28
|
+
export function modelsURL(): string {
|
|
29
|
+
return `${HANZO_API_BASE_URL}/v1/models`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- Shopify Admin API ----------------------------------------------------
|
|
33
|
+
//
|
|
34
|
+
// Shopify Admin API is versioned by calendar quarter (YYYY-MM). We pin ONE
|
|
35
|
+
// version and move it forward deliberately — never a speculative future one.
|
|
36
|
+
// The GraphQL Admin API is the primary surface (productUpdate, product/order
|
|
37
|
+
// reads); REST is legacy. Every call is against the per-shop host
|
|
38
|
+
// `https://{shop}/admin/api/{version}/graphql.json`.
|
|
39
|
+
export const ADMIN_API_VERSION = '2025-01';
|
|
40
|
+
|
|
41
|
+
// The OAuth scopes the app requests. read/write products for content write-back,
|
|
42
|
+
// read_orders for order insight + support drafting. No scope we do not use.
|
|
43
|
+
export const OAUTH_SCOPES = ['read_products', 'write_products', 'read_orders'] as const;
|
|
44
|
+
|
|
45
|
+
// Product description text budget. A product body_html plus its metafields and
|
|
46
|
+
// variants can run long; this caps the characters of product context we attach
|
|
47
|
+
// to any one request so it fits comfortably in a model window alongside the
|
|
48
|
+
// reply. Honest truncation, never silent drop.
|
|
49
|
+
export const PRODUCT_CHAR_BUDGET = 20_000;
|
|
50
|
+
|
|
51
|
+
// A Shopify shop domain looks like `my-store.myshopify.com`. isShopDomain is the
|
|
52
|
+
// boundary guard: OAuth install/callback and webhook handlers validate the shop
|
|
53
|
+
// parameter against this before it is ever placed in a URL, so an attacker
|
|
54
|
+
// cannot point the OAuth dance or an API call at an arbitrary host. Shopify's
|
|
55
|
+
// rule: hostname ending in `.myshopify.com`, only [a-z0-9-] in the store slug.
|
|
56
|
+
export function isShopDomain(shop: string | undefined | null): shop is string {
|
|
57
|
+
return typeof shop === 'string' && /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(shop);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// adminGraphqlUrl builds the GraphQL Admin API endpoint for a shop. Pure — the
|
|
61
|
+
// request wrappers in shopify-api.ts take this string. Callers MUST have passed
|
|
62
|
+
// `shop` through isShopDomain first (server.ts does).
|
|
63
|
+
export function adminGraphqlUrl(shop: string, version: string = ADMIN_API_VERSION): string {
|
|
64
|
+
return `https://${shop}/admin/api/${version}/graphql.json`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- Server-side configuration (Shopify OAuth + webhook HMAC) --------------
|
|
68
|
+
//
|
|
69
|
+
// These are read from the environment by src/server/server.ts. They NEVER reach
|
|
70
|
+
// the browser bundle: the API key (client id) is public, but the API secret is
|
|
71
|
+
// server-only — it signs the OAuth exchange AND is the HMAC key Shopify uses to
|
|
72
|
+
// sign both the OAuth callback and every webhook. readServerConfig throws on a
|
|
73
|
+
// missing secret so a server that can neither complete OAuth nor verify a
|
|
74
|
+
// webhook refuses to start.
|
|
75
|
+
|
|
76
|
+
export interface ServerConfig {
|
|
77
|
+
/** Shopify API key / OAuth client id (public — appears in the authorize URL). */
|
|
78
|
+
shopifyApiKey: string;
|
|
79
|
+
/**
|
|
80
|
+
* Shopify API secret (SERVER ONLY). Two jobs: the OAuth token-exchange
|
|
81
|
+
* `client_secret`, AND the HMAC-SHA256 key for the OAuth callback signature
|
|
82
|
+
* and every webhook signature. One secret, both trust gates.
|
|
83
|
+
*/
|
|
84
|
+
shopifyApiSecret: string;
|
|
85
|
+
/** OAuth redirect registered on the app (e.g. https://shopify.hanzo.ai/oauth/callback). */
|
|
86
|
+
shopifyRedirectUri: string;
|
|
87
|
+
/** The scopes string sent to /admin/oauth/authorize (space is not used — comma). */
|
|
88
|
+
scopes: string;
|
|
89
|
+
/**
|
|
90
|
+
* Hanzo API key the server uses to run AI on behalf of a shop when no user
|
|
91
|
+
* bearer is in the loop (the webhook path). Optional: without it the server
|
|
92
|
+
* still does OAuth + Admin API + verifies webhooks, but the webhook cannot
|
|
93
|
+
* run a model call (it logs and skips), which readServerConfig reports.
|
|
94
|
+
*/
|
|
95
|
+
hanzoApiKey: string;
|
|
96
|
+
/** Listen port. */
|
|
97
|
+
port: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// readServerConfig fails fast (throws) if a required Shopify secret is missing.
|
|
101
|
+
// hanzoApiKey is the one optional field (see above). Pure given an env map, so
|
|
102
|
+
// it is unit-tested without touching process.env.
|
|
103
|
+
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
|
|
104
|
+
const shopifyApiKey = env.SHOPIFY_API_KEY;
|
|
105
|
+
const shopifyApiSecret = env.SHOPIFY_API_SECRET;
|
|
106
|
+
const shopifyRedirectUri = env.SHOPIFY_REDIRECT_URI;
|
|
107
|
+
const missing: string[] = [];
|
|
108
|
+
if (!shopifyApiKey) missing.push('SHOPIFY_API_KEY');
|
|
109
|
+
if (!shopifyApiSecret) missing.push('SHOPIFY_API_SECRET');
|
|
110
|
+
if (!shopifyRedirectUri) missing.push('SHOPIFY_REDIRECT_URI');
|
|
111
|
+
if (missing.length > 0) {
|
|
112
|
+
throw new Error(`Missing required environment: ${missing.join(', ')}`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
shopifyApiKey: shopifyApiKey!,
|
|
116
|
+
shopifyApiSecret: shopifyApiSecret!,
|
|
117
|
+
shopifyRedirectUri: shopifyRedirectUri!,
|
|
118
|
+
scopes: env.SHOPIFY_SCOPES || OAUTH_SCOPES.join(','),
|
|
119
|
+
hanzoApiKey: env.HANZO_API_KEY || '',
|
|
120
|
+
port: Number(env.PORT) || 8791,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The AI actions over a product or an order — each a prompt template applied to
|
|
2
|
+
// the assembled context via the single `askProduct` / `askOrder` primitives in
|
|
3
|
+
// hanzo.ts. There is exactly ONE code path to the model per subject: an action
|
|
4
|
+
// is (id → prompt + subject), and the panel, the picker, and the webhook all
|
|
5
|
+
// resolve an id here and call the matching ask. No action speaks to the gateway
|
|
6
|
+
// directly.
|
|
7
|
+
|
|
8
|
+
import { askOrder, askProduct, type AskOptions } from './hanzo.js';
|
|
9
|
+
import type { ProductInfo, OrderInfo } from './shopify-api.js';
|
|
10
|
+
|
|
11
|
+
// A product action's prompt is written to produce paste-ready content (a
|
|
12
|
+
// description, an SEO title, a meta description). Prompts are specific and
|
|
13
|
+
// output-shaped so results drop straight into the Shopify field.
|
|
14
|
+
export const PRODUCT_ACTIONS = {
|
|
15
|
+
writeDescription: {
|
|
16
|
+
label: 'Write description',
|
|
17
|
+
prompt:
|
|
18
|
+
'Write a compelling product description for this product. 2–4 short ' +
|
|
19
|
+
'paragraphs (or a short intro plus a bullet list of key features/benefits). ' +
|
|
20
|
+
'Lead with the benefit, ground every claim in the product data above, and ' +
|
|
21
|
+
'match a confident, on-brand retail tone. Return only the description text, ' +
|
|
22
|
+
'ready to paste.',
|
|
23
|
+
},
|
|
24
|
+
rewriteDescription: {
|
|
25
|
+
label: 'Rewrite description',
|
|
26
|
+
prompt:
|
|
27
|
+
'Rewrite the current product description above to be clearer, more ' +
|
|
28
|
+
'persuasive, and better structured, WITHOUT adding any fact not present in ' +
|
|
29
|
+
'the product data. Keep it roughly the same length. Return only the rewritten ' +
|
|
30
|
+
'description, ready to paste.',
|
|
31
|
+
},
|
|
32
|
+
seoTitle: {
|
|
33
|
+
label: 'SEO title',
|
|
34
|
+
prompt:
|
|
35
|
+
'Write an SEO page title for this product: at most 60 characters, ' +
|
|
36
|
+
'front-loading the most important keyword, natural (not keyword-stuffed), and ' +
|
|
37
|
+
'accurate to the product. Return only the title text on a single line.',
|
|
38
|
+
},
|
|
39
|
+
seoDescription: {
|
|
40
|
+
label: 'SEO meta description',
|
|
41
|
+
prompt:
|
|
42
|
+
'Write an SEO meta description for this product: 140–160 characters, one or ' +
|
|
43
|
+
'two sentences, benefit-led, and accurate to the product data. Return only ' +
|
|
44
|
+
'the meta description text on a single line.',
|
|
45
|
+
},
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
// An order action's prompt produces a summary, a customer reply, or an issue
|
|
49
|
+
// extraction — the support/order-insight surface.
|
|
50
|
+
export const ORDER_ACTIONS = {
|
|
51
|
+
summarize: {
|
|
52
|
+
label: 'Summarize order',
|
|
53
|
+
prompt:
|
|
54
|
+
'Summarize this order for a support agent at a glance: who ordered, what and ' +
|
|
55
|
+
'how much, the payment and fulfillment status, where it ships, and anything ' +
|
|
56
|
+
'notable in the note. Short bullets. No preamble.',
|
|
57
|
+
},
|
|
58
|
+
draftReply: {
|
|
59
|
+
label: 'Draft customer reply',
|
|
60
|
+
prompt:
|
|
61
|
+
'Draft a warm, professional reply to the customer about this order. Address ' +
|
|
62
|
+
'them by name if known, reference the order by its name/number, and speak ' +
|
|
63
|
+
'only to what the order data supports (status, items, shipping). Do not ' +
|
|
64
|
+
'promise dates, refunds, or policies that are not in the data. Return only ' +
|
|
65
|
+
'the email body, ready to send.',
|
|
66
|
+
},
|
|
67
|
+
extractIssues: {
|
|
68
|
+
label: 'Extract issues',
|
|
69
|
+
prompt:
|
|
70
|
+
'Read this order (especially the internal note) and extract any customer ' +
|
|
71
|
+
'issues, requests, or risks as a short prioritized list. For each: a one-line ' +
|
|
72
|
+
'description and a suggested next action. If nothing stands out, say so. Do ' +
|
|
73
|
+
'not invent issues that are not supported by the data.',
|
|
74
|
+
},
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
// A product / order action id from the respective catalog.
|
|
78
|
+
export type ProductActionId = keyof typeof PRODUCT_ACTIONS;
|
|
79
|
+
export type OrderActionId = keyof typeof ORDER_ACTIONS;
|
|
80
|
+
|
|
81
|
+
// isProductActionId / isOrderActionId narrow an arbitrary string to a known id.
|
|
82
|
+
// Boundary guards — the panel and the server validate an inbound id here before
|
|
83
|
+
// running it.
|
|
84
|
+
export function isProductActionId(id: string): id is ProductActionId {
|
|
85
|
+
return Object.prototype.hasOwnProperty.call(PRODUCT_ACTIONS, id);
|
|
86
|
+
}
|
|
87
|
+
export function isOrderActionId(id: string): id is OrderActionId {
|
|
88
|
+
return Object.prototype.hasOwnProperty.call(ORDER_ACTIONS, id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// productActionPrompt / orderActionPrompt resolve an id to its prompt. Each
|
|
92
|
+
// throws on an unknown id (a boundary error surfaced to the caller) rather than
|
|
93
|
+
// silently running a default.
|
|
94
|
+
export function productActionPrompt(id: string): string {
|
|
95
|
+
if (!isProductActionId(id)) throw new Error(`Unknown product action: ${id}`);
|
|
96
|
+
return PRODUCT_ACTIONS[id].prompt;
|
|
97
|
+
}
|
|
98
|
+
export function orderActionPrompt(id: string): string {
|
|
99
|
+
if (!isOrderActionId(id)) throw new Error(`Unknown order action: ${id}`);
|
|
100
|
+
return ORDER_ACTIONS[id].prompt;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// productActionList / orderActionList are the ordered catalogs for building the
|
|
104
|
+
// UI, derived from the action maps so the panel and the catalog can never drift.
|
|
105
|
+
export function productActionList(): Array<{ id: ProductActionId; label: string }> {
|
|
106
|
+
return (Object.keys(PRODUCT_ACTIONS) as ProductActionId[]).map((id) => ({ id, label: PRODUCT_ACTIONS[id].label }));
|
|
107
|
+
}
|
|
108
|
+
export function orderActionList(): Array<{ id: OrderActionId; label: string }> {
|
|
109
|
+
return (Object.keys(ORDER_ACTIONS) as OrderActionId[]).map((id) => ({ id, label: ORDER_ACTIONS[id].label }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// runProductAction / runOrderAction are the single entry points each surface
|
|
113
|
+
// calls: resolve the action's prompt and run it over the assembled context via
|
|
114
|
+
// the matching ask. One code path from an id to the model per subject. Async so
|
|
115
|
+
// an unknown id surfaces as a rejected promise (not a synchronous throw), giving
|
|
116
|
+
// callers ONE way to handle failure: await/.catch.
|
|
117
|
+
export async function runProductAction(id: string, p: ProductInfo, opts: AskOptions = {}): Promise<string> {
|
|
118
|
+
return askProduct(productActionPrompt(id), p, opts);
|
|
119
|
+
}
|
|
120
|
+
export async function runOrderAction(id: string, o: OrderInfo, opts: AskOptions = {}): Promise<string> {
|
|
121
|
+
return askOrder(orderActionPrompt(id), o, opts);
|
|
122
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The Hanzo call and its request/response shaping over a PRODUCT or ORDER —
|
|
2
|
+
// pure, host-agnostic, and fully unit-testable (no Shopify SDK, no DOM). The
|
|
3
|
+
// Polaris panel and the webhook server are the thin glue that read a product/
|
|
4
|
+
// order and hand it here.
|
|
5
|
+
//
|
|
6
|
+
// This is a THIN wrapper over the PUBLISHED headless client `@hanzo/ai`
|
|
7
|
+
// (createAiClient) — we do NOT reimplement the transport. This module owns only
|
|
8
|
+
// the commerce-aware layer: product/order context assembly + truncation + prompt
|
|
9
|
+
// building, kept pure so it is tested and reused by both the panel backend and
|
|
10
|
+
// the (browser-less) webhook. The AI actions (their prompt templates) live in
|
|
11
|
+
// actions.ts, layered on `ask`.
|
|
12
|
+
|
|
13
|
+
import { createAiClient, type AiClient } from '@hanzo/ai';
|
|
14
|
+
import { DEFAULT_MODEL, HANZO_API_BASE_URL, PRODUCT_CHAR_BUDGET } from '../config.js';
|
|
15
|
+
import type { ProductInfo, OrderInfo } from './shopify-api.js';
|
|
16
|
+
|
|
17
|
+
// A message in the OpenAI-compatible schema — the shape @hanzo/ai's
|
|
18
|
+
// chat.completions.create takes. Kept as a local alias so the prompt-assembly
|
|
19
|
+
// functions (buildProductMessages/buildOrderMessages) have a precise, testable
|
|
20
|
+
// return type independent of the SDK's broader union.
|
|
21
|
+
export interface ChatMessage {
|
|
22
|
+
role: 'system' | 'user' | 'assistant';
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// SYSTEM_PROMPT grounds every answer in the store data provided. It forbids
|
|
27
|
+
// inventing facts about the product or order (the failure mode that makes a
|
|
28
|
+
// commerce assistant dangerous — fake specs, fake shipping promises), and keeps
|
|
29
|
+
// output ready to paste into a Shopify field. Merchant-facing, brand-neutral.
|
|
30
|
+
export const SYSTEM_PROMPT =
|
|
31
|
+
'You are Hanzo AI, an assistant for a Shopify merchant. You help write product ' +
|
|
32
|
+
'content (descriptions, SEO titles and meta descriptions) and handle orders and ' +
|
|
33
|
+
'support. Work ONLY from the product or order data provided below — never invent ' +
|
|
34
|
+
'specifications, materials, prices, dates, shipping promises, or policies that ' +
|
|
35
|
+
'are not supported by the data. Write in clear, natural, conversion-minded prose ' +
|
|
36
|
+
'for product content, and a warm, professional tone for customer replies. Return ' +
|
|
37
|
+
'only the requested content, ready to paste — no preamble, no meta commentary.';
|
|
38
|
+
|
|
39
|
+
// ---- Product context assembly ---------------------------------------------
|
|
40
|
+
//
|
|
41
|
+
// A product's attributes are assembled into a compact, labeled block the model
|
|
42
|
+
// reads as data (never as instructions). We include the existing description so
|
|
43
|
+
// "rewrite" has the current copy to improve, and cap the whole block at the
|
|
44
|
+
// budget — HTML descriptions can be long. Honest truncation, never silent drop.
|
|
45
|
+
|
|
46
|
+
// The result of rendering a product down to what we attach: the text and whether
|
|
47
|
+
// anything was truncated (so the prompt and UI can say so honestly).
|
|
48
|
+
export interface ProductContext {
|
|
49
|
+
text: string;
|
|
50
|
+
truncated: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// stripHtml reduces an HTML description to readable text for the context block
|
|
54
|
+
// (the model does not need the markup to understand the copy, and tags waste
|
|
55
|
+
// budget). Minimal + total: drop tags, collapse whitespace. Pure.
|
|
56
|
+
export function stripHtml(html: string): string {
|
|
57
|
+
return html
|
|
58
|
+
.replace(/<[^>]*>/g, ' ')
|
|
59
|
+
.replace(/ /g, ' ')
|
|
60
|
+
.replace(/&/g, '&')
|
|
61
|
+
.replace(/</g, '<')
|
|
62
|
+
.replace(/>/g, '>')
|
|
63
|
+
.replace(/\s+/g, ' ')
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// buildProductContext renders the product attributes into a labeled block,
|
|
68
|
+
// capped at `budget` characters. The current description is the last (and
|
|
69
|
+
// longest) field, so truncation trims it rather than the structured attributes
|
|
70
|
+
// the model most needs. Pure and total. `truncated` is true whenever the render
|
|
71
|
+
// was cut to fit.
|
|
72
|
+
export function buildProductContext(p: ProductInfo, budget: number = PRODUCT_CHAR_BUDGET): ProductContext {
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
lines.push(`Title: ${p.title}`);
|
|
75
|
+
if (p.productType) lines.push(`Type: ${p.productType}`);
|
|
76
|
+
if (p.vendor) lines.push(`Vendor: ${p.vendor}`);
|
|
77
|
+
if (p.tags.length) lines.push(`Tags: ${p.tags.join(', ')}`);
|
|
78
|
+
for (const o of p.options) {
|
|
79
|
+
if (o.name && o.values.length) lines.push(`Option ${o.name}: ${o.values.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
if (p.variants.length) {
|
|
82
|
+
const vs = p.variants
|
|
83
|
+
.map((v) => [v.title, v.sku && `SKU ${v.sku}`, v.price && `$${v.price}`].filter(Boolean).join(' — '))
|
|
84
|
+
.join('; ');
|
|
85
|
+
lines.push(`Variants: ${vs}`);
|
|
86
|
+
}
|
|
87
|
+
if (p.seoTitle) lines.push(`Current SEO title: ${p.seoTitle}`);
|
|
88
|
+
if (p.seoDescription) lines.push(`Current SEO description: ${p.seoDescription}`);
|
|
89
|
+
const currentDesc = stripHtml(p.descriptionHtml || p.description);
|
|
90
|
+
if (currentDesc) lines.push(`Current description: ${currentDesc}`);
|
|
91
|
+
|
|
92
|
+
const full = lines.join('\n');
|
|
93
|
+
if (full.length <= budget) return { text: full, truncated: false };
|
|
94
|
+
return { text: full.slice(0, budget), truncated: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- Order context assembly -----------------------------------------------
|
|
98
|
+
|
|
99
|
+
export interface OrderContext {
|
|
100
|
+
text: string;
|
|
101
|
+
truncated: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// buildOrderContext renders an order into a labeled block. Orders are small; the
|
|
105
|
+
// budget only bites on a huge line-item list or a long note, in which case we
|
|
106
|
+
// truncate the tail honestly. Pure and total.
|
|
107
|
+
export function buildOrderContext(o: OrderInfo, budget: number = PRODUCT_CHAR_BUDGET): OrderContext {
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
lines.push(`Order: ${o.name}`);
|
|
110
|
+
if (o.createdAt) lines.push(`Placed: ${o.createdAt}`);
|
|
111
|
+
if (o.customerName) lines.push(`Customer: ${o.customerName}`);
|
|
112
|
+
if (o.email) lines.push(`Email: ${o.email}`);
|
|
113
|
+
if (o.financialStatus) lines.push(`Payment: ${o.financialStatus}`);
|
|
114
|
+
if (o.fulfillmentStatus) lines.push(`Fulfillment: ${o.fulfillmentStatus}`);
|
|
115
|
+
if (o.totalAmount) lines.push(`Total: ${o.totalAmount} ${o.currency}`.trim());
|
|
116
|
+
if (o.shipTo) lines.push(`Ship to: ${o.shipTo}`);
|
|
117
|
+
if (o.lineItems.length) {
|
|
118
|
+
const items = o.lineItems
|
|
119
|
+
.map((li) => `${li.quantity}× ${li.title}${li.sku ? ` (SKU ${li.sku})` : ''}`)
|
|
120
|
+
.join('; ');
|
|
121
|
+
lines.push(`Items: ${items}`);
|
|
122
|
+
}
|
|
123
|
+
if (o.note) lines.push(`Internal note: ${o.note}`);
|
|
124
|
+
|
|
125
|
+
const full = lines.join('\n');
|
|
126
|
+
if (full.length <= budget) return { text: full, truncated: false };
|
|
127
|
+
return { text: full.slice(0, budget), truncated: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- Prompt assembly ------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
// contextNote is the one honest sentence prepended when the render was cut, so
|
|
133
|
+
// the model does not answer as though it saw everything.
|
|
134
|
+
function contextNote(truncated: boolean, kind: 'product' | 'order'): string {
|
|
135
|
+
return truncated
|
|
136
|
+
? `Note: the ${kind} data below was truncated to fit — answer only from what is shown.`
|
|
137
|
+
: '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// buildMessages fences the store data as DATA (never instructions) and rides the
|
|
141
|
+
// honest note inside the user turn so it is never lost. Shared by product and
|
|
142
|
+
// order paths — one assembly, one shape. Pure.
|
|
143
|
+
export function buildMessages(task: string, contextText: string, note: string, fence: string): ChatMessage[] {
|
|
144
|
+
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
|
|
145
|
+
const notePrefix = note ? `${note}\n\n` : '';
|
|
146
|
+
const user: ChatMessage = {
|
|
147
|
+
role: 'user',
|
|
148
|
+
content:
|
|
149
|
+
`${notePrefix}---- ${fence} ----\n${contextText}\n---- end ${fence} ----\n\n` +
|
|
150
|
+
`---- task ----\n${task}`,
|
|
151
|
+
};
|
|
152
|
+
return [system, user];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// buildProductMessages / buildOrderMessages are the two public assemblers the
|
|
156
|
+
// actions + freeform ask use. Each windows its context and builds the message
|
|
157
|
+
// list. Pure — the whole prompt is asserted in a test.
|
|
158
|
+
export function buildProductMessages(task: string, p: ProductInfo): ChatMessage[] {
|
|
159
|
+
const ctx = buildProductContext(p);
|
|
160
|
+
return buildMessages(task, ctx.text, contextNote(ctx.truncated, 'product'), 'product');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function buildOrderMessages(task: string, o: OrderInfo): ChatMessage[] {
|
|
164
|
+
const ctx = buildOrderContext(o);
|
|
165
|
+
return buildMessages(task, ctx.text, contextNote(ctx.truncated, 'order'), 'order');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- The single call path -------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export interface AskOptions {
|
|
171
|
+
model?: string;
|
|
172
|
+
temperature?: number;
|
|
173
|
+
token?: string;
|
|
174
|
+
baseURL?: string;
|
|
175
|
+
/** Injected client (tests). Defaults to a real createAiClient. */
|
|
176
|
+
client?: AiClient;
|
|
177
|
+
signal?: AbortSignal;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// client resolves an AiClient: the injected one (tests) or a real published
|
|
181
|
+
// @hanzo/ai client pointed at the gateway with the caller's bearer. One place
|
|
182
|
+
// constructs it so the token/baseURL wiring is identical for every surface.
|
|
183
|
+
function client(opts: AskOptions): AiClient {
|
|
184
|
+
return opts.client ?? createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// runChat is the single path from a built message list to the model. Every
|
|
188
|
+
// Shopify surface (the actions, a freeform question, the webhook auto-draft)
|
|
189
|
+
// funnels here. Non-streaming: the panel renders the final text and the webhook
|
|
190
|
+
// stores a finished draft. token may be empty (the gateway serves anonymous/
|
|
191
|
+
// limited models).
|
|
192
|
+
export async function runChat(messages: ChatMessage[], opts: AskOptions = {}): Promise<string> {
|
|
193
|
+
const res = await client(opts).chat.completions.create({
|
|
194
|
+
model: opts.model ?? DEFAULT_MODEL,
|
|
195
|
+
messages,
|
|
196
|
+
temperature: opts.temperature,
|
|
197
|
+
stream: false,
|
|
198
|
+
}, { signal: opts.signal });
|
|
199
|
+
const content = res.choices?.[0]?.message?.content;
|
|
200
|
+
if (typeof content !== 'string' || content === '') {
|
|
201
|
+
throw new Error('Hanzo API returned no content');
|
|
202
|
+
}
|
|
203
|
+
return content;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// askProduct / askOrder run ONE completion over a product/order with a freeform
|
|
207
|
+
// task. The actions in actions.ts resolve their prompt and call these.
|
|
208
|
+
export async function askProduct(task: string, p: ProductInfo, opts: AskOptions = {}): Promise<string> {
|
|
209
|
+
return runChat(buildProductMessages(task, p), opts);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function askOrder(task: string, o: OrderInfo, opts: AskOptions = {}): Promise<string> {
|
|
213
|
+
return runChat(buildOrderMessages(task, o), opts);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// listModels returns the model ids the caller may route to, from /v1/models via
|
|
217
|
+
// the headless client. Org-scoped by the bearer; an empty token lists public
|
|
218
|
+
// models.
|
|
219
|
+
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
|
|
220
|
+
const models = await client(opts).models.list({ signal: opts.signal });
|
|
221
|
+
return models.map((m) => m.id);
|
|
222
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Shopify OAuth (Authorization Code Grant) — pure request shaping + the OAuth
|
|
2
|
+
// callback HMAC verification. The API secret is held by the server (server.ts)
|
|
3
|
+
// and never reaches the browser; these functions build the exact URL/body of
|
|
4
|
+
// each OAuth call and verify Shopify's signature on the redirect, so the wire
|
|
5
|
+
// shape and the trust gate are unit-testable without a network round-trip.
|
|
6
|
+
//
|
|
7
|
+
// Shopify's flow (shopify.dev/docs/apps/auth/oauth):
|
|
8
|
+
// 1. redirect the merchant to https://{shop}/admin/oauth/authorize?…
|
|
9
|
+
// 2. Shopify redirects back to our redirect_uri with ?code&hmac&shop&state&…
|
|
10
|
+
// — we VERIFY the `hmac` (HMAC-SHA256 over the sorted remaining params,
|
|
11
|
+
// hex) before trusting anything, then check `state` (CSRF).
|
|
12
|
+
// 3. POST https://{shop}/admin/oauth/access_token with the code + secret to
|
|
13
|
+
// get the offline access token, stored server-side per shop.
|
|
14
|
+
|
|
15
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { isShopDomain } from '../config.js';
|
|
17
|
+
|
|
18
|
+
// authorizeUrl is where the install flow sends the merchant's browser to grant
|
|
19
|
+
// the app. `scope` is comma-joined (Shopify's convention, NOT space), `state`
|
|
20
|
+
// is the CSRF nonce the server generates and re-checks on callback. Offline
|
|
21
|
+
// access mode is the default (no `grant_options[]=per-user`), which is what a
|
|
22
|
+
// background webhook needs — a long-lived token, not a per-user online one.
|
|
23
|
+
export function authorizeUrl(args: {
|
|
24
|
+
shop: string;
|
|
25
|
+
apiKey: string;
|
|
26
|
+
scopes: string;
|
|
27
|
+
redirectUri: string;
|
|
28
|
+
state: string;
|
|
29
|
+
}): string {
|
|
30
|
+
const q = new URLSearchParams({
|
|
31
|
+
client_id: args.apiKey,
|
|
32
|
+
scope: args.scopes,
|
|
33
|
+
redirect_uri: args.redirectUri,
|
|
34
|
+
state: args.state,
|
|
35
|
+
});
|
|
36
|
+
return `https://${args.shop}/admin/oauth/authorize?${q.toString()}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A prepared token-exchange request: the pieces a single fetch needs. Pure
|
|
40
|
+
// output so a test asserts the URL + JSON body without opening a socket. The
|
|
41
|
+
// secret rides ONLY in the body, over TLS to Shopify's own host.
|
|
42
|
+
export interface PreparedRequest {
|
|
43
|
+
url: string;
|
|
44
|
+
headers: Record<string, string>;
|
|
45
|
+
body: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// tokenExchange builds the code→token request against the shop's
|
|
49
|
+
// /admin/oauth/access_token. Shopify takes a JSON body of client_id +
|
|
50
|
+
// client_secret + code and returns { access_token, scope }.
|
|
51
|
+
export function tokenExchange(args: {
|
|
52
|
+
shop: string;
|
|
53
|
+
apiKey: string;
|
|
54
|
+
apiSecret: string;
|
|
55
|
+
code: string;
|
|
56
|
+
}): PreparedRequest {
|
|
57
|
+
return {
|
|
58
|
+
url: `https://${args.shop}/admin/oauth/access_token`,
|
|
59
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
client_id: args.apiKey,
|
|
62
|
+
client_secret: args.apiSecret,
|
|
63
|
+
code: args.code,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The token response we care about. Shopify returns access_token (the offline
|
|
69
|
+
// token) + the granted scope. parseTokenResponse validates the one field we
|
|
70
|
+
// must have and surfaces Shopify's own error otherwise.
|
|
71
|
+
export interface ShopifyTokenSet {
|
|
72
|
+
access_token: string;
|
|
73
|
+
scope: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseTokenResponse(data: any): ShopifyTokenSet {
|
|
77
|
+
if (!data || typeof data !== 'object') throw new Error('empty token response');
|
|
78
|
+
if (data.error || data.errors) {
|
|
79
|
+
const reason = data.error_description || data.error || JSON.stringify(data.errors);
|
|
80
|
+
throw new Error(`Shopify OAuth error: ${reason}`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
|
83
|
+
throw new Error('Shopify OAuth response missing access_token');
|
|
84
|
+
}
|
|
85
|
+
return { access_token: data.access_token, scope: String(data.scope ?? '') };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- OAuth callback HMAC verification (the trust gate) --------------------
|
|
89
|
+
//
|
|
90
|
+
// Shopify signs the OAuth redirect: the `hmac` query param is
|
|
91
|
+
// hex(HMAC-SHA256(apiSecret, message)), where `message` is the OTHER query
|
|
92
|
+
// params (everything except `hmac` and the legacy `signature`) sorted by key
|
|
93
|
+
// and joined `key=value&key=value` — with the ORIGINAL, still-URL-encoded
|
|
94
|
+
// values. We must reconstruct that exact string. A callback that fails here is
|
|
95
|
+
// discarded before any token exchange. Docs:
|
|
96
|
+
// shopify.dev/docs/apps/auth/oauth/getting-started#step-4-confirm-installation
|
|
97
|
+
|
|
98
|
+
// callbackMessage builds the signed message from a param map: drop hmac +
|
|
99
|
+
// signature, sort the rest by key, then re-encode as an
|
|
100
|
+
// application/x-www-form-urlencoded query string. This EXACTLY matches Shopify's
|
|
101
|
+
// canonical form (stringifyQueryForAdmin → ProcessedQuery.stringify, which is a
|
|
102
|
+
// URLSearchParams.toString()): keys/values are percent-encoded, spaces become
|
|
103
|
+
// `+`. Building the raw `key=value` join instead would fail to validate any
|
|
104
|
+
// callback whose values contain encodable characters (e.g. `host`, `state`), so
|
|
105
|
+
// we mirror URLSearchParams here. `params` are the decoded values (as
|
|
106
|
+
// URLSearchParams.forEach yields them); URLSearchParams re-encodes them the same
|
|
107
|
+
// way Shopify did when it signed. Pure.
|
|
108
|
+
export function callbackMessage(params: Record<string, string>): string {
|
|
109
|
+
const sorted = new URLSearchParams();
|
|
110
|
+
for (const key of Object.keys(params).filter((k) => k !== 'hmac' && k !== 'signature').sort()) {
|
|
111
|
+
sorted.append(key, params[key]);
|
|
112
|
+
}
|
|
113
|
+
return sorted.toString();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// computeCallbackHmac is hex(HMAC-SHA256(apiSecret, message)). Both verification
|
|
117
|
+
// and any test fixture are built from it, so the value we compare is produced
|
|
118
|
+
// exactly as Shopify produces it.
|
|
119
|
+
export function computeCallbackHmac(apiSecret: string, message: string): string {
|
|
120
|
+
return createHmac('sha256', apiSecret).update(message, 'utf8').digest('hex');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// constantTimeEqualHex compares two hex digests without leaking length/position
|
|
124
|
+
// via timing. Different-length inputs are unequal and short-circuit safely (we
|
|
125
|
+
// never feed mismatched buffers to timingSafeEqual, which would throw).
|
|
126
|
+
export function constantTimeEqualHex(a: string, b: string): boolean {
|
|
127
|
+
const ba = Buffer.from(a, 'hex');
|
|
128
|
+
const bb = Buffer.from(b, 'hex');
|
|
129
|
+
if (ba.length === 0 || ba.length !== bb.length) return false;
|
|
130
|
+
return timingSafeEqual(ba, bb);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// verifyCallbackHmac is the OAuth trust gate: reconstruct the signed message
|
|
134
|
+
// from the callback params, recompute the hex HMAC with the app secret, and
|
|
135
|
+
// constant-time compare against the provided `hmac` param. Returns false on any
|
|
136
|
+
// missing input rather than throwing, so a malformed callback is a clean reject.
|
|
137
|
+
export function verifyCallbackHmac(
|
|
138
|
+
apiSecret: string,
|
|
139
|
+
params: Record<string, string>,
|
|
140
|
+
): boolean {
|
|
141
|
+
const provided = params.hmac;
|
|
142
|
+
if (!apiSecret || !provided) return false;
|
|
143
|
+
const expected = computeCallbackHmac(apiSecret, callbackMessage(params));
|
|
144
|
+
return constantTimeEqualHex(provided, expected);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Callback parameter validation ----------------------------------------
|
|
148
|
+
|
|
149
|
+
// A validated OAuth callback: the shop, the code to exchange, and the state to
|
|
150
|
+
// re-check. parseCallback pulls these from the query map AFTER the HMAC passed;
|
|
151
|
+
// it re-validates the shop domain (defense in depth — never build a URL against
|
|
152
|
+
// an unverified host) and throws on a missing code.
|
|
153
|
+
export interface OAuthCallback {
|
|
154
|
+
shop: string;
|
|
155
|
+
code: string;
|
|
156
|
+
state: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseCallback(params: Record<string, string>): OAuthCallback {
|
|
160
|
+
const shop = params.shop;
|
|
161
|
+
if (!isShopDomain(shop)) throw new Error('invalid or missing shop domain');
|
|
162
|
+
const code = params.code;
|
|
163
|
+
if (!code) throw new Error('missing code');
|
|
164
|
+
return { shop, code, state: params.state ?? '' };
|
|
165
|
+
}
|