@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
|
@@ -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 {};
|
package/dist/signer.d.ts
CHANGED
|
@@ -1,2 +1,29 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
1
|
+
export interface SignRequestInput {
|
|
2
|
+
appId: string;
|
|
3
|
+
keyId: string;
|
|
4
|
+
keySecret: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
nonce: string;
|
|
7
|
+
method: string;
|
|
8
|
+
path: string;
|
|
9
|
+
queryString?: string;
|
|
10
|
+
rawBody?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface SignedHeaders {
|
|
13
|
+
"X-Sch-AppId": string;
|
|
14
|
+
"X-Sch-KeyId": string;
|
|
15
|
+
"X-Sch-Timestamp": string;
|
|
16
|
+
"X-Sch-Nonce": string;
|
|
17
|
+
"Content-SHA256": string;
|
|
18
|
+
"X-Sch-Signature": string;
|
|
19
|
+
}
|
|
20
|
+
export declare function sha256Hex(rawBody: string): Promise<string>;
|
|
21
|
+
export declare function buildCanonicalString(input: {
|
|
22
|
+
timestamp: number;
|
|
23
|
+
nonce: string;
|
|
24
|
+
method: string;
|
|
25
|
+
path: string;
|
|
26
|
+
queryString: string;
|
|
27
|
+
contentSha256: string;
|
|
28
|
+
}): string;
|
|
29
|
+
export declare function signRequest(input: SignRequestInput): Promise<SignedHeaders>;
|
package/dist/signer.js
CHANGED
|
@@ -1 +1,51 @@
|
|
|
1
|
-
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
function toHex(data) {
|
|
3
|
+
return Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
4
|
+
}
|
|
5
|
+
function toBase64Url(data) {
|
|
6
|
+
let binary = "";
|
|
7
|
+
for (const byte of new Uint8Array(data)) {
|
|
8
|
+
binary += String.fromCharCode(byte);
|
|
9
|
+
}
|
|
10
|
+
const base64 = btoa(binary);
|
|
11
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
12
|
+
}
|
|
13
|
+
export async function sha256Hex(rawBody) {
|
|
14
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(rawBody));
|
|
15
|
+
return toHex(digest);
|
|
16
|
+
}
|
|
17
|
+
export function buildCanonicalString(input) {
|
|
18
|
+
return [
|
|
19
|
+
"v1",
|
|
20
|
+
String(input.timestamp),
|
|
21
|
+
input.nonce,
|
|
22
|
+
input.method.toUpperCase(),
|
|
23
|
+
input.path,
|
|
24
|
+
input.queryString,
|
|
25
|
+
input.contentSha256,
|
|
26
|
+
""
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
export async function signRequest(input) {
|
|
30
|
+
const queryString = input.queryString ?? "";
|
|
31
|
+
const rawBody = input.rawBody ?? "";
|
|
32
|
+
const contentSha256 = await sha256Hex(rawBody);
|
|
33
|
+
const canonical = buildCanonicalString({
|
|
34
|
+
timestamp: input.timestamp,
|
|
35
|
+
nonce: input.nonce,
|
|
36
|
+
method: input.method,
|
|
37
|
+
path: input.path,
|
|
38
|
+
queryString,
|
|
39
|
+
contentSha256
|
|
40
|
+
});
|
|
41
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(input.keySecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
42
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(canonical));
|
|
43
|
+
return {
|
|
44
|
+
"X-Sch-AppId": input.appId,
|
|
45
|
+
"X-Sch-KeyId": input.keyId,
|
|
46
|
+
"X-Sch-Timestamp": String(input.timestamp),
|
|
47
|
+
"X-Sch-Nonce": input.nonce,
|
|
48
|
+
"Content-SHA256": contentSha256,
|
|
49
|
+
"X-Sch-Signature": toBase64Url(signature)
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catandbox/schrodinger-web-adapter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
"test": "vitest run"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@catandbox/schrodinger-contracts": "^0.1.0"
|
|
38
|
-
"@catandbox/schrodinger-shopify-adapter": "^0.1.0"
|
|
37
|
+
"@catandbox/schrodinger-contracts": "^0.1.0"
|
|
39
38
|
}
|
|
40
39
|
}
|