mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
Add packages/shopify (@hanzo/shopify): a Shopify embedded admin app that puts
Hanzo AI over the store as system of record.
- Product content: generate/rewrite descriptions + SEO title/meta from product
attributes, then write back via the productUpdate mutation (only changed fields).
- Orders/support: summarize an order, draft a customer reply, extract issues.
- Ask: freeform question over a product or order, grounded in its data.
Server (src/server, holds the only secrets):
- oauth.ts Shopify OAuth request shaping + OAuth-callback HMAC verify (hex over
Shopify-canonical URLSearchParams form) + state (CSRF) check.
- shopify-api.ts Admin GraphQL wrappers (getProduct/getOrder/updateProduct) —
pure request shaping + parsing + productUpdate userErrors.
- hanzo.ts thin over the PUBLISHED @hanzo/ai createAiClient; product/order
context assembly + prompt building. No transport reimplemented.
- actions.ts product + order AI action catalogs, one prompt each over ask.
- webhooks.ts webhook HMAC verify (base64 over RAW body) + topic routing
(orders/create, app/uninstalled, the 3 GDPR topics).
- server.ts OAuth + /v1/* proxy the frontend calls + /webhooks. HMAC verified
before any param/body is trusted; shop validated to *.myshopify.com.
Frontend (src/app): Polaris + App Bridge React panel — api.ts (proxy client,
no secrets), context.ts (launch context → GID), session-fetch.ts (App Bridge
session-token fetch), App/Assistant/main. App Bridge loaded from Shopify CDN;
public API key stamped into the app, secret never bundled.
Plus shopify.app.toml, an admin-link extension, build.js (esbuild: app bundle +
Node server), tsconfig, vitest config, README. 108 vitest tests (pure), tsc
--noEmit clean, build green. Model calls via @hanzo/ai to api.hanzo.ai/v1 only.
Register packages/shopify in pnpm-workspace.yaml.
67 lines
2.5 KiB
TypeScript
67 lines
2.5 KiB
TypeScript
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();
|
|
});
|
|
});
|