@catandbox/schrodinger-shopify-adapter 0.1.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/README.md +83 -0
- package/dist/client/client.d.ts +118 -0
- package/dist/client/client.js +298 -0
- package/dist/client/components/CountdownText.d.ts +5 -0
- package/dist/client/components/CountdownText.js +26 -0
- package/dist/client/components/ErrorBanner.d.ts +4 -0
- package/dist/client/components/ErrorBanner.js +8 -0
- package/dist/client/components/StatusBadge.d.ts +5 -0
- package/dist/client/components/StatusBadge.js +21 -0
- package/dist/client/components/SupportNewTicketForm.d.ts +11 -0
- package/dist/client/components/SupportNewTicketForm.js +76 -0
- package/dist/client/components/SupportTicketDetail.d.ts +9 -0
- package/dist/client/components/SupportTicketDetail.js +139 -0
- package/dist/client/components/SupportTicketList.d.ts +9 -0
- package/dist/client/components/SupportTicketList.js +104 -0
- package/dist/client/editor/RichTextEditor.d.ts +10 -0
- package/dist/client/editor/RichTextEditor.js +11 -0
- package/dist/client/hooks/types.d.ts +14 -0
- package/dist/client/hooks/types.js +1 -0
- package/dist/client/hooks/useCategories.d.ts +3 -0
- package/dist/client/hooks/useCategories.js +37 -0
- package/dist/client/hooks/useCreateTicket.d.ts +16 -0
- package/dist/client/hooks/useCreateTicket.js +30 -0
- package/dist/client/hooks/useRatings.d.ts +12 -0
- package/dist/client/hooks/useRatings.js +51 -0
- package/dist/client/hooks/useReply.d.ts +13 -0
- package/dist/client/hooks/useReply.js +31 -0
- package/dist/client/hooks/useSupportClient.d.ts +10 -0
- package/dist/client/hooks/useSupportClient.js +12 -0
- package/dist/client/hooks/useTicket.d.ts +3 -0
- package/dist/client/hooks/useTicket.js +36 -0
- package/dist/client/hooks/useTickets.d.ts +15 -0
- package/dist/client/hooks/useTickets.js +76 -0
- package/dist/client/hooks/useUploadManager.d.ts +28 -0
- package/dist/client/hooks/useUploadManager.js +210 -0
- package/dist/client/index.d.ts +18 -0
- package/dist/client/index.js +17 -0
- package/dist/client/uploads/UploadManagerView.d.ts +10 -0
- package/dist/client/uploads/UploadManagerView.js +26 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +3 -0
- 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 -0
- package/dist/signer.js +51 -0
- package/package.json +51 -0
|
@@ -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 {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export class ShopifyAuthError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
constructor(code, message, status = 401) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ShopifyAuthError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function extractShopifySessionToken(request) {
|
|
12
|
+
const authHeader = request.headers.get("Authorization");
|
|
13
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
14
|
+
return authHeader.slice("Bearer ".length).trim();
|
|
15
|
+
}
|
|
16
|
+
const fallbackHeader = request.headers.get("X-Shopify-Session-Token") ??
|
|
17
|
+
request.headers.get("x-shopify-session-token");
|
|
18
|
+
return fallbackHeader?.trim() || null;
|
|
19
|
+
}
|
|
20
|
+
export async function createPrincipalContext(request, options) {
|
|
21
|
+
const token = extractShopifySessionToken(request);
|
|
22
|
+
if (!token) {
|
|
23
|
+
throw new ShopifyAuthError("MISSING_SHOPIFY_SESSION_TOKEN", "Missing Shopify session token");
|
|
24
|
+
}
|
|
25
|
+
const payload = await verifyShopifySessionToken(token, options);
|
|
26
|
+
const shopDomain = inferShopDomain(payload);
|
|
27
|
+
const principalExternalId = inferPrincipalExternalId(payload);
|
|
28
|
+
const displayName = asOptionalString(payload.user_name) ?? null;
|
|
29
|
+
const email = asOptionalString(payload.user_email) ?? asOptionalString(payload.email) ?? null;
|
|
30
|
+
return {
|
|
31
|
+
tenantExternalId: shopDomain,
|
|
32
|
+
principalExternalId,
|
|
33
|
+
displayName,
|
|
34
|
+
email,
|
|
35
|
+
shopDomain,
|
|
36
|
+
tokenPayload: payload
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export async function verifyShopifySessionToken(token, options) {
|
|
40
|
+
const [headerPart, payloadPart, signaturePart] = token.split(".");
|
|
41
|
+
if (!headerPart || !payloadPart || !signaturePart) {
|
|
42
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Invalid JWT format");
|
|
43
|
+
}
|
|
44
|
+
const headerJson = decodeBase64UrlToText(headerPart);
|
|
45
|
+
const header = parseJson(headerJson, "Invalid JWT header");
|
|
46
|
+
if (header.alg !== "HS256") {
|
|
47
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Unsupported JWT alg");
|
|
48
|
+
}
|
|
49
|
+
const expectedSignature = await hmacSha256Base64Url(options.shopifyApiSecret, `${headerPart}.${payloadPart}`);
|
|
50
|
+
if (!timingSafeEqual(expectedSignature, signaturePart)) {
|
|
51
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "JWT signature mismatch");
|
|
52
|
+
}
|
|
53
|
+
const payloadJson = decodeBase64UrlToText(payloadPart);
|
|
54
|
+
const payload = parseJson(payloadJson, "Invalid JWT payload");
|
|
55
|
+
validateTemporalClaims(payload, options);
|
|
56
|
+
validateAudience(payload, options.shopifyApiKey);
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
export async function verifyShopifyWebhookHmac(input) {
|
|
60
|
+
if (!input.hmacHeader) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const expected = await hmacSha256Base64(input.shopifyApiSecret, input.rawBody);
|
|
64
|
+
return timingSafeEqual(expected, input.hmacHeader.trim());
|
|
65
|
+
}
|
|
66
|
+
function validateTemporalClaims(payload, options) {
|
|
67
|
+
const now = options.now ? options.now() : Math.floor(Date.now() / 1000);
|
|
68
|
+
const skew = options.clockSkewSeconds ?? 10;
|
|
69
|
+
if (typeof payload.exp !== "number") {
|
|
70
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Missing exp claim");
|
|
71
|
+
}
|
|
72
|
+
if (payload.exp + skew < now) {
|
|
73
|
+
throw new ShopifyAuthError("EXPIRED_SHOPIFY_SESSION_TOKEN", "Shopify session token is expired");
|
|
74
|
+
}
|
|
75
|
+
if (typeof payload.nbf === "number" && payload.nbf - skew > now) {
|
|
76
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Shopify session token is not active yet");
|
|
77
|
+
}
|
|
78
|
+
if (typeof payload.iat === "number" && payload.iat - skew > now) {
|
|
79
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Shopify session token iat is in the future");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function validateAudience(payload, apiKey) {
|
|
83
|
+
const aud = payload.aud;
|
|
84
|
+
if (!aud) {
|
|
85
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Missing aud claim");
|
|
86
|
+
}
|
|
87
|
+
if (typeof aud === "string") {
|
|
88
|
+
if (aud !== apiKey) {
|
|
89
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Shopify session token audience mismatch");
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(aud) && aud.includes(apiKey)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Shopify session token audience mismatch");
|
|
97
|
+
}
|
|
98
|
+
function inferShopDomain(payload) {
|
|
99
|
+
const destination = asOptionalString(payload.dest);
|
|
100
|
+
if (destination) {
|
|
101
|
+
try {
|
|
102
|
+
const url = new URL(destination);
|
|
103
|
+
return normalizeShopDomain(url.hostname);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return normalizeShopDomain(destination);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const issuer = asOptionalString(payload.iss);
|
|
110
|
+
if (issuer) {
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(issuer);
|
|
113
|
+
return normalizeShopDomain(url.hostname);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// continue
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Unable to infer Shopify shop domain");
|
|
120
|
+
}
|
|
121
|
+
function inferPrincipalExternalId(payload) {
|
|
122
|
+
const candidate = asOptionalString(payload.sub) ??
|
|
123
|
+
asOptionalString(payload.user_email) ??
|
|
124
|
+
asOptionalString(payload.email);
|
|
125
|
+
if (!candidate) {
|
|
126
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Unable to infer principal identifier from Shopify session token");
|
|
127
|
+
}
|
|
128
|
+
return candidate;
|
|
129
|
+
}
|
|
130
|
+
function normalizeShopDomain(domainLike) {
|
|
131
|
+
const normalized = domainLike
|
|
132
|
+
.trim()
|
|
133
|
+
.toLowerCase()
|
|
134
|
+
.replace(/^https?:\/\//, "")
|
|
135
|
+
.replace(/\/.*$/, "");
|
|
136
|
+
if (!normalized || !normalized.includes(".")) {
|
|
137
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", "Invalid Shopify shop domain in token");
|
|
138
|
+
}
|
|
139
|
+
return normalized;
|
|
140
|
+
}
|
|
141
|
+
function parseJson(value, message) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(value);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new ShopifyAuthError("INVALID_SHOPIFY_SESSION_TOKEN", message, 401);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function decodeBase64UrlToText(input) {
|
|
150
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
151
|
+
const padded = `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
|
|
152
|
+
return atob(padded);
|
|
153
|
+
}
|
|
154
|
+
async function hmacSha256Base64Url(secret, value) {
|
|
155
|
+
const base64 = await hmacSha256Base64(secret, value);
|
|
156
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
157
|
+
}
|
|
158
|
+
async function hmacSha256Base64(secret, value) {
|
|
159
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
160
|
+
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value));
|
|
161
|
+
let binary = "";
|
|
162
|
+
for (const byte of new Uint8Array(signature)) {
|
|
163
|
+
binary += String.fromCharCode(byte);
|
|
164
|
+
}
|
|
165
|
+
return btoa(binary);
|
|
166
|
+
}
|
|
167
|
+
function timingSafeEqual(left, right) {
|
|
168
|
+
if (left.length !== right.length) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
let mismatch = 0;
|
|
172
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
173
|
+
mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
174
|
+
}
|
|
175
|
+
return mismatch === 0;
|
|
176
|
+
}
|
|
177
|
+
function asOptionalString(value) {
|
|
178
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
179
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SignedHeaders } from "../signer";
|
|
2
|
+
import type { AdapterEnvironment } from "./types";
|
|
3
|
+
interface SignSchrodingerRequestInput {
|
|
4
|
+
env: Pick<AdapterEnvironment, "schAppId" | "schKeyId" | "schSecret">;
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
query?: URLSearchParams | string;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
timestamp?: number;
|
|
10
|
+
nonce?: string;
|
|
11
|
+
}
|
|
12
|
+
interface SignedSchrodingerRequest {
|
|
13
|
+
headers: SignedHeaders;
|
|
14
|
+
rawBody: string;
|
|
15
|
+
queryString: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function signSchrodingerRequest(input: SignSchrodingerRequestInput): Promise<SignedSchrodingerRequest>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { signRequest } from "../signer";
|
|
2
|
+
export async function signSchrodingerRequest(input) {
|
|
3
|
+
const queryString = typeof input.query === "string" ? input.query : input.query ? input.query.toString() : "";
|
|
4
|
+
const rawBody = input.body === undefined
|
|
5
|
+
? ""
|
|
6
|
+
: typeof input.body === "string"
|
|
7
|
+
? input.body
|
|
8
|
+
: JSON.stringify(input.body);
|
|
9
|
+
const headers = await signRequest({
|
|
10
|
+
appId: input.env.schAppId,
|
|
11
|
+
keyId: input.env.schKeyId,
|
|
12
|
+
keySecret: input.env.schSecret,
|
|
13
|
+
timestamp: input.timestamp ?? Math.floor(Date.now() / 1000),
|
|
14
|
+
nonce: input.nonce ?? crypto.randomUUID(),
|
|
15
|
+
method: input.method,
|
|
16
|
+
path: input.path,
|
|
17
|
+
queryString,
|
|
18
|
+
rawBody
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
headers,
|
|
22
|
+
rawBody,
|
|
23
|
+
queryString
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface ShopifySessionVerificationOptions {
|
|
2
|
+
shopifyApiKey: string;
|
|
3
|
+
shopifyApiSecret: string;
|
|
4
|
+
clockSkewSeconds?: number;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
}
|
|
7
|
+
export interface PrincipalContext {
|
|
8
|
+
tenantExternalId: string;
|
|
9
|
+
principalExternalId: string;
|
|
10
|
+
displayName: string | null;
|
|
11
|
+
email: string | null;
|
|
12
|
+
shopDomain: string;
|
|
13
|
+
tokenPayload: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface AdapterEnvironment {
|
|
16
|
+
schApiBaseUrl: string;
|
|
17
|
+
schAppId: string;
|
|
18
|
+
schKeyId: string;
|
|
19
|
+
schSecret: string;
|
|
20
|
+
shopifyApiKey: string;
|
|
21
|
+
shopifyApiSecret: string;
|
|
22
|
+
schAdminApiToken?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ProxyHandlerOptions extends AdapterEnvironment {
|
|
25
|
+
basePath?: string;
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
requestIdFactory?: () => string;
|
|
28
|
+
principalContextHeaderName?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface PrefillLengthCaps {
|
|
31
|
+
title: number;
|
|
32
|
+
category: number;
|
|
33
|
+
description: number;
|
|
34
|
+
}
|
|
35
|
+
export interface PrefillState {
|
|
36
|
+
title: string;
|
|
37
|
+
categoryId: string | null;
|
|
38
|
+
description: string;
|
|
39
|
+
errors: {
|
|
40
|
+
title?: string;
|
|
41
|
+
category?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
};
|
|
44
|
+
isValid: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface PrefillParseOptions {
|
|
47
|
+
categories: Array<{
|
|
48
|
+
id: string;
|
|
49
|
+
}>;
|
|
50
|
+
caps?: Partial<PrefillLengthCaps>;
|
|
51
|
+
}
|
|
52
|
+
export interface WebhookForwardingOptions extends AdapterEnvironment {
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
requestIdFactory?: () => string;
|
|
55
|
+
disablePortalAccess?: (input: {
|
|
56
|
+
shopDomain: string;
|
|
57
|
+
payload: unknown;
|
|
58
|
+
topic: string;
|
|
59
|
+
}) => Promise<void> | void;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|