@catandbox/schrodinger-web-adapter 0.1.27 → 0.1.32
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/README.md +174 -0
- package/dist/client/file-upload.d.ts +1 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +1 -2
- package/dist/client/new-ticket-form.d.ts +1 -1
- package/dist/client/portal.d.ts +1 -1
- package/dist/client/portal.js +1 -1
- package/dist/client/status-badge.js +2 -2
- package/dist/client/ticket-detail.d.ts +1 -1
- package/dist/client/ticket-detail.js +94 -98
- package/dist/client/ticket-list.d.ts +1 -1
- package/dist/client/ticket-list.js +176 -6
- package/dist/server/index.d.ts +4 -6
- package/dist/server/index.js +3 -1
- package/dist/server/routes.d.ts +15 -0
- package/dist/server/routes.js +353 -0
- package/dist/server/shopifyAuth.d.ts +28 -0
- package/dist/server/shopifyAuth.js +179 -0
- package/dist/server/signing.d.ts +18 -0
- package/dist/server/signing.js +25 -0
- package/dist/server/types.d.ts +60 -0
- package/dist/server/types.js +1 -0
- package/dist/signer.d.ts +29 -2
- package/dist/signer.js +51 -1
- package/package.json +2 -3
|
@@ -1,5 +1,169 @@
|
|
|
1
1
|
import { renderStatusBadge } from "./status-badge";
|
|
2
2
|
import { setInnerHtml } from "./dom-utils";
|
|
3
|
+
function loadThree() {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
if (typeof THREE !== "undefined") {
|
|
6
|
+
resolve();
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const s = document.createElement("script");
|
|
10
|
+
s.src = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js";
|
|
11
|
+
s.onload = () => resolve();
|
|
12
|
+
s.onerror = reject;
|
|
13
|
+
document.head.appendChild(s);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function initHydrogenLogo(el) {
|
|
17
|
+
// Math helpers
|
|
18
|
+
function factorial(n) {
|
|
19
|
+
let r = 1;
|
|
20
|
+
for (let i = 2; i <= n; i++)
|
|
21
|
+
r *= i;
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
function assocLaguerre(p, q, x) {
|
|
25
|
+
if (p === 0)
|
|
26
|
+
return 1;
|
|
27
|
+
if (p === 1)
|
|
28
|
+
return 1 + q - x;
|
|
29
|
+
let lm2 = 1, lm1 = 1 + q - x, val = 0;
|
|
30
|
+
for (let k = 2; k <= p; k++) {
|
|
31
|
+
val = ((2 * k - 1 + q - x) * lm1 - (k - 1 + q) * lm2) / k;
|
|
32
|
+
lm2 = lm1;
|
|
33
|
+
lm1 = val;
|
|
34
|
+
}
|
|
35
|
+
return val;
|
|
36
|
+
}
|
|
37
|
+
function assocLegendre(l, m, x) {
|
|
38
|
+
let pmm = 1.0;
|
|
39
|
+
if (m > 0) {
|
|
40
|
+
const s = Math.sqrt((1 - x) * (1 + x));
|
|
41
|
+
let f = 1;
|
|
42
|
+
for (let i = 1; i <= m; i++) {
|
|
43
|
+
pmm *= -f * s;
|
|
44
|
+
f += 2;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (l === m)
|
|
48
|
+
return pmm;
|
|
49
|
+
let pmmp1 = x * (2 * m + 1) * pmm;
|
|
50
|
+
if (l === m + 1)
|
|
51
|
+
return pmmp1;
|
|
52
|
+
let pll = 0;
|
|
53
|
+
for (let ll = m + 2; ll <= l; ll++) {
|
|
54
|
+
pll = (x * (2 * ll - 1) * pmmp1 - (ll + m - 1) * pmm) / (ll - m);
|
|
55
|
+
pmm = pmmp1;
|
|
56
|
+
pmmp1 = pll;
|
|
57
|
+
}
|
|
58
|
+
return pll;
|
|
59
|
+
}
|
|
60
|
+
function sphericalHarmonicReal(l, m, theta, phi) {
|
|
61
|
+
const absM = Math.abs(m);
|
|
62
|
+
const norm = Math.sqrt(((2 * l + 1) / (4 * Math.PI)) * (factorial(l - absM) / factorial(l + absM)));
|
|
63
|
+
const plm = assocLegendre(l, absM, Math.cos(theta));
|
|
64
|
+
if (m > 0)
|
|
65
|
+
return norm * plm * Math.cos(m * phi) * Math.SQRT2;
|
|
66
|
+
if (m < 0)
|
|
67
|
+
return norm * plm * Math.sin(absM * phi) * Math.SQRT2;
|
|
68
|
+
return norm * plm;
|
|
69
|
+
}
|
|
70
|
+
function radialWaveFunction(n, l, r) {
|
|
71
|
+
const rho = (2 * r) / n;
|
|
72
|
+
const norm = Math.sqrt(Math.pow(2 / n, 3) * factorial(n - l - 1) / (2 * n * Math.pow(factorial(n + l), 3)));
|
|
73
|
+
return norm * Math.exp(-rho / 2) * Math.pow(rho, l) * assocLaguerre(n - l - 1, 2 * l + 1, rho);
|
|
74
|
+
}
|
|
75
|
+
function psiProb(n, l, m, r, theta, phi) {
|
|
76
|
+
const R = radialWaveFunction(n, l, r);
|
|
77
|
+
const Y = sphericalHarmonicReal(l, m, theta, phi);
|
|
78
|
+
return R * R * Y * Y;
|
|
79
|
+
}
|
|
80
|
+
// Generate points via rejection sampling
|
|
81
|
+
const n = 4, l = 3, m = 1, maxR = 45, numPoints = 80000;
|
|
82
|
+
let maxProb = 0;
|
|
83
|
+
for (let i = 0; i < 5000; i++) {
|
|
84
|
+
const r = Math.random() * maxR;
|
|
85
|
+
const theta = Math.acos(2 * Math.random() - 1);
|
|
86
|
+
const phi = Math.random() * 2 * Math.PI;
|
|
87
|
+
const p = psiProb(n, l, m, r, theta, phi) * r * r;
|
|
88
|
+
if (p > maxProb)
|
|
89
|
+
maxProb = p;
|
|
90
|
+
}
|
|
91
|
+
const pts = [], cols = [], szs = [];
|
|
92
|
+
let accepted = 0;
|
|
93
|
+
const sc = 0.7;
|
|
94
|
+
for (let i = 0; i < numPoints * 30 && accepted < numPoints; i++) {
|
|
95
|
+
const r = Math.random() * maxR;
|
|
96
|
+
const theta = Math.acos(2 * Math.random() - 1);
|
|
97
|
+
const phi = Math.random() * 2 * Math.PI;
|
|
98
|
+
const prob = psiProb(n, l, m, r, theta, phi) * r * r;
|
|
99
|
+
const thr = prob / maxProb;
|
|
100
|
+
if (Math.random() < thr) {
|
|
101
|
+
pts.push(r * Math.sin(theta) * Math.cos(phi) * sc, r * Math.sin(theta) * Math.sin(phi) * sc, r * Math.cos(theta) * sc);
|
|
102
|
+
const t = r / maxR;
|
|
103
|
+
cols.push(0.3 + 0.6 * t, 0.5 * (1 - t) + 0.2 * t, 0.95 - 0.2 * t);
|
|
104
|
+
szs.push(0.08 + 0.15 * thr);
|
|
105
|
+
accepted++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const scene = new THREE.Scene();
|
|
109
|
+
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
|
110
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
111
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
112
|
+
renderer.setSize(80, 80);
|
|
113
|
+
renderer.setClearColor(0x08090d, 1);
|
|
114
|
+
el.appendChild(renderer.domElement);
|
|
115
|
+
const geometry = new THREE.BufferGeometry();
|
|
116
|
+
geometry.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
|
|
117
|
+
geometry.setAttribute("color", new THREE.Float32BufferAttribute(cols, 3));
|
|
118
|
+
geometry.setAttribute("size", new THREE.Float32BufferAttribute(szs, 1));
|
|
119
|
+
const material = new THREE.ShaderMaterial({
|
|
120
|
+
vertexShader: `
|
|
121
|
+
attribute float size;
|
|
122
|
+
varying vec3 vColor;
|
|
123
|
+
void main() {
|
|
124
|
+
vColor = color;
|
|
125
|
+
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
|
126
|
+
gl_PointSize = size * (200.0 / -mv.z);
|
|
127
|
+
gl_Position = projectionMatrix * mv;
|
|
128
|
+
}
|
|
129
|
+
`,
|
|
130
|
+
fragmentShader: `
|
|
131
|
+
varying vec3 vColor;
|
|
132
|
+
void main() {
|
|
133
|
+
float d = length(gl_PointCoord - vec2(0.5));
|
|
134
|
+
if (d > 0.5) discard;
|
|
135
|
+
float a = smoothstep(0.5, 0.1, d) * 0.7;
|
|
136
|
+
gl_FragColor = vec4(vColor, a);
|
|
137
|
+
}
|
|
138
|
+
`,
|
|
139
|
+
vertexColors: true,
|
|
140
|
+
transparent: true,
|
|
141
|
+
depthWrite: false,
|
|
142
|
+
blending: THREE.AdditiveBlending,
|
|
143
|
+
});
|
|
144
|
+
scene.add(new THREE.Points(geometry, material));
|
|
145
|
+
const nucGeo = new THREE.SphereGeometry(0.25, 12, 12);
|
|
146
|
+
scene.add(new THREE.Mesh(nucGeo, new THREE.MeshBasicMaterial({ color: 0xffffff })));
|
|
147
|
+
let theta = 0;
|
|
148
|
+
const phi = 0.85, radius = 42;
|
|
149
|
+
function updateCam() {
|
|
150
|
+
camera.position.set(radius * Math.sin(phi) * Math.sin(theta), radius * Math.cos(phi), radius * Math.sin(phi) * Math.cos(theta));
|
|
151
|
+
camera.lookAt(0, 0, 0);
|
|
152
|
+
}
|
|
153
|
+
let rafId;
|
|
154
|
+
function animate() {
|
|
155
|
+
rafId = requestAnimationFrame(animate);
|
|
156
|
+
theta += 0.003;
|
|
157
|
+
updateCam();
|
|
158
|
+
renderer.render(scene, camera);
|
|
159
|
+
}
|
|
160
|
+
updateCam();
|
|
161
|
+
animate();
|
|
162
|
+
return () => {
|
|
163
|
+
cancelAnimationFrame(rafId);
|
|
164
|
+
renderer.dispose();
|
|
165
|
+
};
|
|
166
|
+
}
|
|
3
167
|
function formatDate(timestamp) {
|
|
4
168
|
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
|
5
169
|
month: "short",
|
|
@@ -35,10 +199,11 @@ const ORDER_OPTIONS = [
|
|
|
35
199
|
{ value: "oldest", label: "Oldest first" }
|
|
36
200
|
];
|
|
37
201
|
export async function renderTicketList(container, client, emitter) {
|
|
38
|
-
// Load tickets and
|
|
202
|
+
// Load tickets, categories and Three.js in parallel
|
|
39
203
|
const [result, portalConfig] = await Promise.all([
|
|
40
204
|
client.listTickets({}).catch(() => ({ items: [] })),
|
|
41
|
-
client.getPortalConfig().catch(() => ({ categories: [], aliases: [] }))
|
|
205
|
+
client.getPortalConfig().catch(() => ({ categories: [], aliases: [] })),
|
|
206
|
+
loadThree().catch(() => { })
|
|
42
207
|
]);
|
|
43
208
|
const allTickets = result.items;
|
|
44
209
|
const categories = portalConfig.categories;
|
|
@@ -57,10 +222,10 @@ export async function renderTicketList(container, client, emitter) {
|
|
|
57
222
|
<s-box padding="large">
|
|
58
223
|
<s-stack gap="large">
|
|
59
224
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
60
|
-
<
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
</
|
|
225
|
+
<div style="display:flex; align-items:center; gap:12px;">
|
|
226
|
+
<div id="sch-logo-el" style="width:80px; height:80px; border-radius:8px; overflow:hidden; flex-shrink:0; background:#08090d;"></div>
|
|
227
|
+
<span style="font-size:18px; font-weight:700; color:#0f172a;">Schrödinger helpdesk by Cat and box</span>
|
|
228
|
+
</div>
|
|
64
229
|
<s-button variant="primary" id="sch-new-ticket-btn">
|
|
65
230
|
<span style="display:inline-flex; align-items:center; gap:4px;">
|
|
66
231
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor"><path d="M10 3a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H4a1 1 0 1 1 0-2h5V4a1 1 0 0 1 1-1z"/></svg>
|
|
@@ -96,6 +261,11 @@ export async function renderTicketList(container, client, emitter) {
|
|
|
96
261
|
container
|
|
97
262
|
.querySelector("#sch-new-ticket-btn")
|
|
98
263
|
?.addEventListener("click", () => emitter.emit("ticket:create", undefined));
|
|
264
|
+
// Init hydrogen orbital logo
|
|
265
|
+
const logoEl = container.querySelector("#sch-logo-el");
|
|
266
|
+
if (logoEl && typeof THREE !== "undefined") {
|
|
267
|
+
initHydrogenLogo(logoEl);
|
|
268
|
+
}
|
|
99
269
|
function getSelectValue(id) {
|
|
100
270
|
const el = container.querySelector(`#${id}`);
|
|
101
271
|
return el?.value ?? "";
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export type { AdapterEnvironment, PrincipalContext, ProxyHandlerOptions, ShopifySessionVerificationOptions, WebhookForwardingOptions } from "@catandbox/schrodinger-shopify-adapter/server";
|
|
6
|
-
export { ShopifyAuthError, createPrincipalContext, extractShopifySessionToken, verifyShopifySessionToken, verifyShopifyWebhookHmac, signSchrodingerRequest, createShopifyProxyHandler, createShopifyWebhookHandlers, handleAppUninstalledWebhook, handleCustomersDataRequestWebhook, handleCustomersRedactWebhook, handleShopRedactWebhook, parsePrefillRoute } from "@catandbox/schrodinger-shopify-adapter/server";
|
|
1
|
+
export type { AdapterEnvironment, PrefillLengthCaps, PrefillParseOptions, PrefillState, PrincipalContext, ProxyHandlerOptions, ShopifySessionVerificationOptions, WebhookForwardingOptions } from "./types";
|
|
2
|
+
export { ShopifyAuthError, createPrincipalContext, extractShopifySessionToken, verifyShopifySessionToken, verifyShopifyWebhookHmac } from "./shopifyAuth";
|
|
3
|
+
export { signSchrodingerRequest } from "./signing";
|
|
4
|
+
export { createShopifyProxyHandler, createShopifyWebhookHandlers, handleAppUninstalledWebhook, handleCustomersDataRequestWebhook, handleCustomersRedactWebhook, handleShopRedactWebhook, parsePrefillRoute } from "./routes";
|
package/dist/server/index.js
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
export { ShopifyAuthError, createPrincipalContext, extractShopifySessionToken, verifyShopifySessionToken, verifyShopifyWebhookHmac
|
|
1
|
+
export { ShopifyAuthError, createPrincipalContext, extractShopifySessionToken, verifyShopifySessionToken, verifyShopifyWebhookHmac } from "./shopifyAuth";
|
|
2
|
+
export { signSchrodingerRequest } from "./signing";
|
|
3
|
+
export { createShopifyProxyHandler, createShopifyWebhookHandlers, handleAppUninstalledWebhook, handleCustomersDataRequestWebhook, handleCustomersRedactWebhook, handleShopRedactWebhook, parsePrefillRoute } from "./routes";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PrefillParseOptions, PrefillState, ProxyHandlerOptions, WebhookForwardingOptions } from "./types";
|
|
2
|
+
export declare function createShopifyProxyHandler(options: ProxyHandlerOptions): (request: Request) => Promise<Response>;
|
|
3
|
+
export declare function parsePrefillRoute(input: string | URL, options: PrefillParseOptions): PrefillState;
|
|
4
|
+
interface ShopifyWebhookHandlers {
|
|
5
|
+
handleCustomersDataRequestWebhook: (request: Request) => Promise<Response>;
|
|
6
|
+
handleCustomersRedactWebhook: (request: Request) => Promise<Response>;
|
|
7
|
+
handleShopRedactWebhook: (request: Request) => Promise<Response>;
|
|
8
|
+
handleAppUninstalledWebhook: (request: Request) => Promise<Response>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createShopifyWebhookHandlers(options: WebhookForwardingOptions): ShopifyWebhookHandlers;
|
|
11
|
+
export declare function handleCustomersDataRequestWebhook(request: Request, options: WebhookForwardingOptions): Promise<Response>;
|
|
12
|
+
export declare function handleCustomersRedactWebhook(request: Request, options: WebhookForwardingOptions): Promise<Response>;
|
|
13
|
+
export declare function handleShopRedactWebhook(request: Request, options: WebhookForwardingOptions): Promise<Response>;
|
|
14
|
+
export declare function handleAppUninstalledWebhook(request: Request, options: WebhookForwardingOptions): Promise<Response>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { signSchrodingerRequest } from "./signing";
|
|
2
|
+
import { ShopifyAuthError, createPrincipalContext, verifyShopifyWebhookHmac } from "./shopifyAuth";
|
|
3
|
+
const DEFAULT_BASE_PATH = "/support/api";
|
|
4
|
+
const DEFAULT_PREFILL_CAPS = {
|
|
5
|
+
title: 120,
|
|
6
|
+
category: 80,
|
|
7
|
+
description: 10_000
|
|
8
|
+
};
|
|
9
|
+
export function createShopifyProxyHandler(options) {
|
|
10
|
+
const basePath = normalizePathPrefix(options.basePath ?? DEFAULT_BASE_PATH);
|
|
11
|
+
return async (request) => {
|
|
12
|
+
const requestId = request.headers.get("X-Request-Id") ?? options.requestIdFactory?.() ?? crypto.randomUUID();
|
|
13
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(request.url);
|
|
16
|
+
const relativePath = toRelativePath(url.pathname, basePath);
|
|
17
|
+
if (relativePath === null) {
|
|
18
|
+
return jsonError(404, "NOT_FOUND", "Route not found", requestId);
|
|
19
|
+
}
|
|
20
|
+
const principal = await createPrincipalContext(request, {
|
|
21
|
+
shopifyApiKey: options.shopifyApiKey,
|
|
22
|
+
shopifyApiSecret: options.shopifyApiSecret
|
|
23
|
+
});
|
|
24
|
+
const resolution = await resolveProxyRequest(request, relativePath, url.searchParams, principal);
|
|
25
|
+
const upstreamUrl = new URL(`${trimTrailingSlash(options.schApiBaseUrl)}${resolution.upstreamPath}`);
|
|
26
|
+
resolution.query.forEach((value, key) => {
|
|
27
|
+
upstreamUrl.searchParams.set(key, value);
|
|
28
|
+
});
|
|
29
|
+
const signed = await signSchrodingerRequest({
|
|
30
|
+
env: {
|
|
31
|
+
schAppId: options.schAppId,
|
|
32
|
+
schKeyId: options.schKeyId,
|
|
33
|
+
schSecret: options.schSecret
|
|
34
|
+
},
|
|
35
|
+
method: request.method,
|
|
36
|
+
path: resolution.upstreamPath,
|
|
37
|
+
query: upstreamUrl.searchParams,
|
|
38
|
+
body: resolution.body
|
|
39
|
+
});
|
|
40
|
+
const upstreamHeaders = new Headers(signed.headers);
|
|
41
|
+
upstreamHeaders.set("X-Request-Id", requestId);
|
|
42
|
+
upstreamHeaders.set(options.principalContextHeaderName ?? "X-Sch-Principal-Context", JSON.stringify({
|
|
43
|
+
shopDomain: principal.shopDomain,
|
|
44
|
+
tenantExternalId: principal.tenantExternalId,
|
|
45
|
+
principalExternalId: principal.principalExternalId,
|
|
46
|
+
displayName: principal.displayName,
|
|
47
|
+
email: principal.email
|
|
48
|
+
}));
|
|
49
|
+
if (signed.rawBody) {
|
|
50
|
+
upstreamHeaders.set("content-type", "application/json");
|
|
51
|
+
}
|
|
52
|
+
const upstreamResponse = await fetchImpl(upstreamUrl, {
|
|
53
|
+
method: request.method,
|
|
54
|
+
headers: upstreamHeaders,
|
|
55
|
+
body: signed.rawBody || null
|
|
56
|
+
});
|
|
57
|
+
return await copyResponse(upstreamResponse, requestId);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof ShopifyAuthError) {
|
|
61
|
+
return jsonError(error.status, error.code, error.message, requestId);
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof ProxyRouteError) {
|
|
64
|
+
return jsonError(error.status, error.code, error.message, requestId);
|
|
65
|
+
}
|
|
66
|
+
return jsonError(500, "INTERNAL_ERROR", "Unexpected proxy error", requestId);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function parsePrefillRoute(input, options) {
|
|
71
|
+
const url = typeof input === "string" ? new URL(input, "https://local.invalid") : input;
|
|
72
|
+
const caps = { ...DEFAULT_PREFILL_CAPS, ...(options.caps ?? {}) };
|
|
73
|
+
const errors = {};
|
|
74
|
+
const title = (url.searchParams.get("title") ?? "").trim();
|
|
75
|
+
const categoryRaw = (url.searchParams.get("category") ?? "").trim();
|
|
76
|
+
const description = (url.searchParams.get("description") ?? "").trim();
|
|
77
|
+
const boundedTitle = enforceLength("title", title, caps.title, errors);
|
|
78
|
+
const boundedCategory = enforceLength("category", categoryRaw, caps.category, errors);
|
|
79
|
+
const boundedDescription = enforceLength("description", description, caps.description, errors);
|
|
80
|
+
const categoryId = boundedCategory
|
|
81
|
+
? options.categories.some((category) => category.id === boundedCategory)
|
|
82
|
+
? boundedCategory
|
|
83
|
+
: null
|
|
84
|
+
: null;
|
|
85
|
+
if (boundedCategory && !categoryId) {
|
|
86
|
+
errors.category = "Prefill category is not available for this integration";
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
title: boundedTitle,
|
|
90
|
+
categoryId,
|
|
91
|
+
description: boundedDescription,
|
|
92
|
+
errors,
|
|
93
|
+
isValid: Object.keys(errors).length === 0
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function createShopifyWebhookHandlers(options) {
|
|
97
|
+
return {
|
|
98
|
+
handleCustomersDataRequestWebhook: (request) => forwardWebhook(request, "customers/data_request", "/admin/gdpr/data-request", options),
|
|
99
|
+
handleCustomersRedactWebhook: (request) => forwardWebhook(request, "customers/redact", "/admin/gdpr/customer-redact", options),
|
|
100
|
+
handleShopRedactWebhook: (request) => forwardWebhook(request, "shop/redact", "/admin/gdpr/shop-redact", options),
|
|
101
|
+
handleAppUninstalledWebhook: (request) => forwardWebhook(request, "app/uninstalled", "/admin/gdpr/shop-redact", options, true)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export async function handleCustomersDataRequestWebhook(request, options) {
|
|
105
|
+
return forwardWebhook(request, "customers/data_request", "/admin/gdpr/data-request", options);
|
|
106
|
+
}
|
|
107
|
+
export async function handleCustomersRedactWebhook(request, options) {
|
|
108
|
+
return forwardWebhook(request, "customers/redact", "/admin/gdpr/customer-redact", options);
|
|
109
|
+
}
|
|
110
|
+
export async function handleShopRedactWebhook(request, options) {
|
|
111
|
+
return forwardWebhook(request, "shop/redact", "/admin/gdpr/shop-redact", options);
|
|
112
|
+
}
|
|
113
|
+
export async function handleAppUninstalledWebhook(request, options) {
|
|
114
|
+
return forwardWebhook(request, "app/uninstalled", "/admin/gdpr/shop-redact", options, true);
|
|
115
|
+
}
|
|
116
|
+
async function resolveProxyRequest(request, relativePath, requestQuery, principal) {
|
|
117
|
+
if (request.method === "GET" && relativePath === "/categories") {
|
|
118
|
+
return {
|
|
119
|
+
upstreamPath: "/v1/categories",
|
|
120
|
+
query: new URLSearchParams(requestQuery),
|
|
121
|
+
body: undefined
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (request.method === "GET" && relativePath === "/portal-config") {
|
|
125
|
+
return {
|
|
126
|
+
upstreamPath: "/v1/portal-config",
|
|
127
|
+
query: new URLSearchParams(requestQuery),
|
|
128
|
+
body: undefined
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (request.method === "GET" && relativePath === "/tickets") {
|
|
132
|
+
const query = new URLSearchParams(requestQuery);
|
|
133
|
+
query.set("tenantExternalId", principal.tenantExternalId);
|
|
134
|
+
return {
|
|
135
|
+
upstreamPath: "/v1/tickets",
|
|
136
|
+
query,
|
|
137
|
+
body: undefined
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const ticketMatch = relativePath.match(/^\/tickets\/([^/]+)$/);
|
|
141
|
+
if (request.method === "GET" && ticketMatch) {
|
|
142
|
+
const query = new URLSearchParams(requestQuery);
|
|
143
|
+
query.set("tenantExternalId", principal.tenantExternalId);
|
|
144
|
+
return {
|
|
145
|
+
upstreamPath: `/v1/tickets/${encodeURIComponent(decodeURIComponent(ticketMatch[1] ?? ""))}`,
|
|
146
|
+
query,
|
|
147
|
+
body: undefined
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (request.method === "POST" && relativePath === "/tickets") {
|
|
151
|
+
const payload = await parseOptionalJsonBody(request);
|
|
152
|
+
return {
|
|
153
|
+
upstreamPath: "/v1/tickets",
|
|
154
|
+
query: new URLSearchParams(requestQuery),
|
|
155
|
+
body: {
|
|
156
|
+
...payload,
|
|
157
|
+
tenantExternalId: principal.tenantExternalId,
|
|
158
|
+
principalExternalId: principal.principalExternalId
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const messageMatch = relativePath.match(/^\/tickets\/([^/]+)\/messages$/);
|
|
163
|
+
if (request.method === "POST" && messageMatch) {
|
|
164
|
+
const payload = await parseOptionalJsonBody(request);
|
|
165
|
+
return {
|
|
166
|
+
upstreamPath: `/v1/tickets/${encodeURIComponent(decodeURIComponent(messageMatch[1] ?? ""))}/messages`,
|
|
167
|
+
query: new URLSearchParams(requestQuery),
|
|
168
|
+
body: {
|
|
169
|
+
...payload,
|
|
170
|
+
tenantExternalId: principal.tenantExternalId,
|
|
171
|
+
principalExternalId: principal.principalExternalId
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const actionMatch = relativePath.match(/^\/tickets\/([^/]+)\/(close|archive|reopen)$/);
|
|
176
|
+
if (request.method === "POST" && actionMatch) {
|
|
177
|
+
return {
|
|
178
|
+
upstreamPath: `/v1/tickets/${encodeURIComponent(decodeURIComponent(actionMatch[1] ?? ""))}/${actionMatch[2]}`,
|
|
179
|
+
query: new URLSearchParams(requestQuery),
|
|
180
|
+
body: {
|
|
181
|
+
tenantExternalId: principal.tenantExternalId
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const ticketRatingMatch = relativePath.match(/^\/tickets\/([^/]+)\/ratings$/);
|
|
186
|
+
if (request.method === "POST" && ticketRatingMatch) {
|
|
187
|
+
const payload = await parseOptionalJsonBody(request);
|
|
188
|
+
return {
|
|
189
|
+
upstreamPath: `/v1/tickets/${encodeURIComponent(decodeURIComponent(ticketRatingMatch[1] ?? ""))}/ratings`,
|
|
190
|
+
query: new URLSearchParams(requestQuery),
|
|
191
|
+
body: {
|
|
192
|
+
...payload,
|
|
193
|
+
tenantExternalId: principal.tenantExternalId,
|
|
194
|
+
principalExternalId: principal.principalExternalId
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const messageRatingMatch = relativePath.match(/^\/messages\/([^/]+)\/ratings$/);
|
|
199
|
+
if (request.method === "POST" && messageRatingMatch) {
|
|
200
|
+
const payload = await parseOptionalJsonBody(request);
|
|
201
|
+
return {
|
|
202
|
+
upstreamPath: `/v1/messages/${encodeURIComponent(decodeURIComponent(messageRatingMatch[1] ?? ""))}/ratings`,
|
|
203
|
+
query: new URLSearchParams(requestQuery),
|
|
204
|
+
body: {
|
|
205
|
+
...payload,
|
|
206
|
+
tenantExternalId: principal.tenantExternalId,
|
|
207
|
+
principalExternalId: principal.principalExternalId
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (request.method === "POST" && relativePath === "/uploads/init") {
|
|
212
|
+
const payload = await parseOptionalJsonBody(request);
|
|
213
|
+
return {
|
|
214
|
+
upstreamPath: "/v1/uploads/init",
|
|
215
|
+
query: new URLSearchParams(requestQuery),
|
|
216
|
+
body: {
|
|
217
|
+
...payload,
|
|
218
|
+
tenantExternalId: principal.tenantExternalId,
|
|
219
|
+
principalExternalId: principal.principalExternalId
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (request.method === "POST" && relativePath === "/uploads/complete") {
|
|
224
|
+
const payload = await parseOptionalJsonBody(request);
|
|
225
|
+
return {
|
|
226
|
+
upstreamPath: "/v1/uploads/complete",
|
|
227
|
+
query: new URLSearchParams(requestQuery),
|
|
228
|
+
body: {
|
|
229
|
+
...payload,
|
|
230
|
+
tenantExternalId: principal.tenantExternalId,
|
|
231
|
+
principalExternalId: principal.principalExternalId
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
throw new ProxyRouteError("NOT_FOUND", "Adapter route not found", 404);
|
|
236
|
+
}
|
|
237
|
+
async function forwardWebhook(request, topic, adminPath, options, disablePortalAccess = false) {
|
|
238
|
+
const requestId = request.headers.get("X-Request-Id") ?? options.requestIdFactory?.() ?? crypto.randomUUID();
|
|
239
|
+
const rawBody = await request.clone().text();
|
|
240
|
+
const hmacHeader = request.headers.get("X-Shopify-Hmac-Sha256") ?? request.headers.get("x-shopify-hmac-sha256");
|
|
241
|
+
const validHmac = await verifyShopifyWebhookHmac({
|
|
242
|
+
rawBody,
|
|
243
|
+
hmacHeader,
|
|
244
|
+
shopifyApiSecret: options.shopifyApiSecret
|
|
245
|
+
});
|
|
246
|
+
if (!validHmac) {
|
|
247
|
+
return jsonError(401, "INVALID_WEBHOOK_SIGNATURE", "Shopify webhook HMAC validation failed", requestId);
|
|
248
|
+
}
|
|
249
|
+
if (!options.schAdminApiToken) {
|
|
250
|
+
return jsonError(500, "MISSING_SCH_ADMIN_API_TOKEN", "schAdminApiToken is required for GDPR webhook forwarding", requestId);
|
|
251
|
+
}
|
|
252
|
+
const payload = parseWebhookPayload(rawBody, requestId);
|
|
253
|
+
if (payload instanceof Response) {
|
|
254
|
+
return payload;
|
|
255
|
+
}
|
|
256
|
+
if (disablePortalAccess) {
|
|
257
|
+
const shopDomain = inferWebhookShopDomain(payload);
|
|
258
|
+
if (!shopDomain) {
|
|
259
|
+
return jsonError(422, "INVALID_WEBHOOK_PAYLOAD", "Missing shop domain in uninstall webhook", requestId);
|
|
260
|
+
}
|
|
261
|
+
await options.disablePortalAccess?.({ shopDomain, payload, topic });
|
|
262
|
+
}
|
|
263
|
+
const url = new URL(`${trimTrailingSlash(options.schApiBaseUrl)}${adminPath}`);
|
|
264
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
265
|
+
const upstream = await fetchImpl(url, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: {
|
|
268
|
+
Authorization: `Bearer ${options.schAdminApiToken}`,
|
|
269
|
+
"content-type": "application/json",
|
|
270
|
+
"X-Request-Id": requestId
|
|
271
|
+
},
|
|
272
|
+
body: rawBody
|
|
273
|
+
});
|
|
274
|
+
return await copyResponse(upstream, requestId);
|
|
275
|
+
}
|
|
276
|
+
async function parseOptionalJsonBody(request) {
|
|
277
|
+
const raw = await request.clone().text();
|
|
278
|
+
if (!raw.trim()) {
|
|
279
|
+
return {};
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(raw);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
throw new ProxyRouteError("INVALID_JSON", "Request body must be valid JSON", 400);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function parseWebhookPayload(rawBody, requestId) {
|
|
289
|
+
try {
|
|
290
|
+
return JSON.parse(rawBody);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return jsonError(400, "INVALID_JSON", "Webhook payload must be valid JSON", requestId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function inferWebhookShopDomain(payload) {
|
|
297
|
+
const candidate = payload.shop_domain ?? payload.myshopify_domain ?? payload.shopDomain;
|
|
298
|
+
return typeof candidate === "string" && candidate.trim() ? candidate.trim().toLowerCase() : null;
|
|
299
|
+
}
|
|
300
|
+
function normalizePathPrefix(path) {
|
|
301
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
302
|
+
return withSlash.replace(/\/+$/, "");
|
|
303
|
+
}
|
|
304
|
+
function toRelativePath(pathname, basePath) {
|
|
305
|
+
if (pathname === basePath) {
|
|
306
|
+
return "/";
|
|
307
|
+
}
|
|
308
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
309
|
+
return pathname.slice(basePath.length);
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function trimTrailingSlash(value) {
|
|
314
|
+
return value.replace(/\/+$/, "");
|
|
315
|
+
}
|
|
316
|
+
async function copyResponse(response, requestId) {
|
|
317
|
+
const headers = new Headers(response.headers);
|
|
318
|
+
if (!headers.get("X-Request-Id")) {
|
|
319
|
+
headers.set("X-Request-Id", requestId);
|
|
320
|
+
}
|
|
321
|
+
const body = await response.arrayBuffer();
|
|
322
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers });
|
|
323
|
+
}
|
|
324
|
+
function jsonError(status, code, message, requestId) {
|
|
325
|
+
return new Response(JSON.stringify({
|
|
326
|
+
error: code,
|
|
327
|
+
message,
|
|
328
|
+
requestId
|
|
329
|
+
}), {
|
|
330
|
+
status,
|
|
331
|
+
headers: {
|
|
332
|
+
"content-type": "application/json; charset=utf-8",
|
|
333
|
+
"X-Request-Id": requestId
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
function enforceLength(field, value, maxLength, errors) {
|
|
338
|
+
if (value.length <= maxLength) {
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
341
|
+
errors[field] = `${field} exceeds ${maxLength} characters`;
|
|
342
|
+
return value.slice(0, maxLength);
|
|
343
|
+
}
|
|
344
|
+
class ProxyRouteError extends Error {
|
|
345
|
+
code;
|
|
346
|
+
status;
|
|
347
|
+
constructor(code, message, status) {
|
|
348
|
+
super(message);
|
|
349
|
+
this.name = "ProxyRouteError";
|
|
350
|
+
this.code = code;
|
|
351
|
+
this.status = status;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PrincipalContext, ShopifySessionVerificationOptions } from "./types";
|
|
2
|
+
interface ShopifySessionClaims {
|
|
3
|
+
aud?: string | string[];
|
|
4
|
+
dest?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
exp?: number;
|
|
7
|
+
iat?: number;
|
|
8
|
+
iss?: string;
|
|
9
|
+
nbf?: number;
|
|
10
|
+
sub?: string;
|
|
11
|
+
user_email?: string;
|
|
12
|
+
user_name?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export declare class ShopifyAuthError extends Error {
|
|
16
|
+
readonly code: string;
|
|
17
|
+
readonly status: number;
|
|
18
|
+
constructor(code: string, message: string, status?: number);
|
|
19
|
+
}
|
|
20
|
+
export declare function extractShopifySessionToken(request: Request): string | null;
|
|
21
|
+
export declare function createPrincipalContext(request: Request, options: ShopifySessionVerificationOptions): Promise<PrincipalContext>;
|
|
22
|
+
export declare function verifyShopifySessionToken(token: string, options: ShopifySessionVerificationOptions): Promise<ShopifySessionClaims>;
|
|
23
|
+
export declare function verifyShopifyWebhookHmac(input: {
|
|
24
|
+
rawBody: string;
|
|
25
|
+
hmacHeader: string | null;
|
|
26
|
+
shopifyApiSecret: string;
|
|
27
|
+
}): Promise<boolean>;
|
|
28
|
+
export {};
|