@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.
Files changed (54) hide show
  1. package/README.md +83 -0
  2. package/dist/client/client.d.ts +118 -0
  3. package/dist/client/client.js +298 -0
  4. package/dist/client/components/CountdownText.d.ts +5 -0
  5. package/dist/client/components/CountdownText.js +26 -0
  6. package/dist/client/components/ErrorBanner.d.ts +4 -0
  7. package/dist/client/components/ErrorBanner.js +8 -0
  8. package/dist/client/components/StatusBadge.d.ts +5 -0
  9. package/dist/client/components/StatusBadge.js +21 -0
  10. package/dist/client/components/SupportNewTicketForm.d.ts +11 -0
  11. package/dist/client/components/SupportNewTicketForm.js +76 -0
  12. package/dist/client/components/SupportTicketDetail.d.ts +9 -0
  13. package/dist/client/components/SupportTicketDetail.js +139 -0
  14. package/dist/client/components/SupportTicketList.d.ts +9 -0
  15. package/dist/client/components/SupportTicketList.js +104 -0
  16. package/dist/client/editor/RichTextEditor.d.ts +10 -0
  17. package/dist/client/editor/RichTextEditor.js +11 -0
  18. package/dist/client/hooks/types.d.ts +14 -0
  19. package/dist/client/hooks/types.js +1 -0
  20. package/dist/client/hooks/useCategories.d.ts +3 -0
  21. package/dist/client/hooks/useCategories.js +37 -0
  22. package/dist/client/hooks/useCreateTicket.d.ts +16 -0
  23. package/dist/client/hooks/useCreateTicket.js +30 -0
  24. package/dist/client/hooks/useRatings.d.ts +12 -0
  25. package/dist/client/hooks/useRatings.js +51 -0
  26. package/dist/client/hooks/useReply.d.ts +13 -0
  27. package/dist/client/hooks/useReply.js +31 -0
  28. package/dist/client/hooks/useSupportClient.d.ts +10 -0
  29. package/dist/client/hooks/useSupportClient.js +12 -0
  30. package/dist/client/hooks/useTicket.d.ts +3 -0
  31. package/dist/client/hooks/useTicket.js +36 -0
  32. package/dist/client/hooks/useTickets.d.ts +15 -0
  33. package/dist/client/hooks/useTickets.js +76 -0
  34. package/dist/client/hooks/useUploadManager.d.ts +28 -0
  35. package/dist/client/hooks/useUploadManager.js +210 -0
  36. package/dist/client/index.d.ts +18 -0
  37. package/dist/client/index.js +17 -0
  38. package/dist/client/uploads/UploadManagerView.d.ts +10 -0
  39. package/dist/client/uploads/UploadManagerView.js +26 -0
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.js +3 -0
  42. package/dist/server/index.d.ts +4 -0
  43. package/dist/server/index.js +3 -0
  44. package/dist/server/routes.d.ts +15 -0
  45. package/dist/server/routes.js +353 -0
  46. package/dist/server/shopifyAuth.d.ts +28 -0
  47. package/dist/server/shopifyAuth.js +179 -0
  48. package/dist/server/signing.d.ts +18 -0
  49. package/dist/server/signing.js +25 -0
  50. package/dist/server/types.d.ts +60 -0
  51. package/dist/server/types.js +1 -0
  52. package/dist/signer.d.ts +29 -0
  53. package/dist/signer.js +51 -0
  54. 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 {};