@porulle/adapter-shopify 0.65.0 → 0.66.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/src/oauth.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Err, Ok } from "@porulle/core";
3
+ import type { ChannelConnectorError, Result } from "@porulle/core";
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * Shopify's OAuth for a standalone (non-embedded) app: the authorization code grant, asking for an
8
+ * EXPIRING offline token. Shopify requires expiring offline tokens of every new public app calling the
9
+ * GraphQL Admin API: a one-hour access token and a 90-day refresh token that rotates on every refresh.
10
+ * https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens
11
+ */
12
+
13
+ /**
14
+ * The scopes the connector's live paths need, and nothing else.
15
+ *
16
+ * - `read_products`, `write_products`: the catalogue import, and the Loom metafield write-back.
17
+ * - `read_inventory`: variant stock and the inventory item behind a stock webhook.
18
+ * - `write_orders`: a paid platform order is created in the store (`orderCreate`). A write scope
19
+ * includes its read scope, which `orders/fulfilled` and `orders/cancelled` need.
20
+ */
21
+ export const REQUIRED_SCOPES = ["read_products", "write_products", "read_inventory", "write_orders"] as const;
22
+
23
+ /** Refresh this long before Shopify's stated expiry, so a call never starts on a token about to lapse. */
24
+ export const ACCESS_TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
25
+
26
+ /** What the store row holds. Read back with {@link parseShopifyCredentials}; never cast. */
27
+ export interface ShopifyCredentials {
28
+ accessToken: string;
29
+ /** Absent for a non-expiring token (an admin-created custom app); such a token is never refreshed. */
30
+ refreshToken?: string;
31
+ accessTokenExpiresAt?: string;
32
+ refreshTokenExpiresAt?: string;
33
+ grantedScopes: string[];
34
+ }
35
+
36
+ const credentialsSchema = z.object({
37
+ accessToken: z.string().min(1),
38
+ refreshToken: z.string().min(1).optional(),
39
+ accessTokenExpiresAt: z.string().optional(),
40
+ refreshTokenExpiresAt: z.string().optional(),
41
+ grantedScopes: z.array(z.string()).default([]),
42
+ });
43
+
44
+ export function parseShopifyCredentials(value: Record<string, unknown>): ShopifyCredentials | undefined {
45
+ const parsed = credentialsSchema.safeParse(value);
46
+ if (!parsed.success) return undefined;
47
+ const { refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt, ...rest } = parsed.data;
48
+ return {
49
+ ...rest,
50
+ ...(refreshToken !== undefined ? { refreshToken } : {}),
51
+ ...(accessTokenExpiresAt !== undefined ? { accessTokenExpiresAt } : {}),
52
+ ...(refreshTokenExpiresAt !== undefined ? { refreshTokenExpiresAt } : {}),
53
+ };
54
+ }
55
+
56
+ const tokenResponseSchema = z.object({
57
+ access_token: z.string().min(1),
58
+ scope: z.string().default(""),
59
+ expires_in: z.number().optional(),
60
+ refresh_token: z.string().min(1).optional(),
61
+ refresh_token_expires_in: z.number().optional(),
62
+ });
63
+
64
+ /** Anchored at both ends: without `$`, `shop.myshopify.com.attacker.example` would pass. */
65
+ export function validShopDomain(value: string): boolean {
66
+ return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i.test(value);
67
+ }
68
+
69
+ /** A merchant types "acme", "acme.myshopify.com" or "https://acme.myshopify.com/admin"; all mean one shop. */
70
+ export function normalizeShopDomain(input: string): string | undefined {
71
+ const trimmed = input.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
72
+ const domain = trimmed.includes(".") ? trimmed : `${trimmed}.myshopify.com`;
73
+ return validShopDomain(domain) ? domain : undefined;
74
+ }
75
+
76
+ function oauthError(code: string, message: string, retriable = false): Result<never, ChannelConnectorError> {
77
+ return Err({ code, message, retriable });
78
+ }
79
+
80
+ export function buildAuthorizeUrl(params: { origin: string; clientId: string; scopes: readonly string[]; redirectUri: string; state: string }): string {
81
+ const url = new URL(`${params.origin}/admin/oauth/authorize`);
82
+ url.searchParams.set("client_id", params.clientId);
83
+ url.searchParams.set("scope", params.scopes.join(","));
84
+ url.searchParams.set("redirect_uri", params.redirectUri);
85
+ url.searchParams.set("state", params.state);
86
+ return url.toString();
87
+ }
88
+
89
+ /** Remove `hmac`, sort the rest, HMAC-SHA256 with the client secret, compare in constant time. */
90
+ export function validCallbackHmac(searchParams: URLSearchParams, secret: string): boolean {
91
+ const provided = searchParams.get("hmac");
92
+ if (!provided || !/^[a-f0-9]+$/i.test(provided)) return false;
93
+ const message = [...searchParams.entries()]
94
+ .filter(([key]) => key !== "hmac")
95
+ .sort(([left], [right]) => left.localeCompare(right))
96
+ .map(([key, value]) => `${key}=${value}`)
97
+ .join("&");
98
+ const expected = createHmac("sha256", secret).update(message).digest();
99
+ const actual = Buffer.from(provided, "hex");
100
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
101
+ }
102
+
103
+ /** A write scope grants its read scope, so `read_x` is satisfied by a granted `write_x`. */
104
+ export function missingScopes(granted: readonly string[], required: readonly string[]): string[] {
105
+ const have = new Set(granted);
106
+ return required.filter((scope) => !have.has(scope) && !(scope.startsWith("read_") && have.has(`write_${scope.slice(5)}`)));
107
+ }
108
+
109
+ function credentialsFrom(body: z.infer<typeof tokenResponseSchema>, now: number): ShopifyCredentials {
110
+ return {
111
+ accessToken: body.access_token,
112
+ grantedScopes: body.scope.split(",").map((scope) => scope.trim()).filter(Boolean),
113
+ ...(body.refresh_token !== undefined ? { refreshToken: body.refresh_token } : {}),
114
+ ...(body.expires_in !== undefined ? { accessTokenExpiresAt: new Date(now + body.expires_in * 1000).toISOString() } : {}),
115
+ ...(body.refresh_token_expires_in !== undefined ? { refreshTokenExpiresAt: new Date(now + body.refresh_token_expires_in * 1000).toISOString() } : {}),
116
+ };
117
+ }
118
+
119
+ async function postTokenEndpoint(fetchImpl: typeof fetch, origin: string, form: Record<string, string>): Promise<Result<z.infer<typeof tokenResponseSchema>, ChannelConnectorError>> {
120
+ let response: Response;
121
+ try {
122
+ response = await fetchImpl(`${origin}/admin/oauth/access_token`, {
123
+ method: "POST",
124
+ redirect: "manual",
125
+ headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
126
+ body: new URLSearchParams(form).toString(),
127
+ });
128
+ } catch (error) {
129
+ return oauthError("SHOPIFY_TOKEN_REQUEST_FAILED", error instanceof Error ? error.message : "Shopify token request failed.", true);
130
+ }
131
+ if (response.status === 400 || response.status === 401) {
132
+ return oauthError("SHOPIFY_REAUTHORIZATION_REQUIRED", `Shopify refused the grant (${response.status}); the store must be reconnected.`);
133
+ }
134
+ if (!response.ok) return oauthError("SHOPIFY_TOKEN_REQUEST_FAILED", `Shopify token request failed (${response.status}).`, response.status >= 500);
135
+ const parsed = tokenResponseSchema.safeParse(await response.json().catch(() => undefined));
136
+ if (!parsed.success) return oauthError("SHOPIFY_TOKEN_INVALID", "Shopify's token response did not carry an access token.");
137
+ return Ok(parsed.data);
138
+ }
139
+
140
+ /** Exchange the callback's code for an expiring offline token, after checking everything Shopify signed. */
141
+ export async function exchangeCallback(params: {
142
+ fetchImpl: typeof fetch;
143
+ origin: string;
144
+ clientId: string;
145
+ clientSecret: string;
146
+ callbackUrl: URL;
147
+ expectedShop: string;
148
+ requiredScopes: readonly string[];
149
+ now: number;
150
+ }): Promise<Result<ShopifyCredentials, ChannelConnectorError>> {
151
+ const search = params.callbackUrl.searchParams;
152
+ const shop = search.get("shop")?.toLowerCase();
153
+ if (!shop || !validShopDomain(shop) || shop !== params.expectedShop) {
154
+ return oauthError("SHOPIFY_INVALID_STORE_DOMAIN", "The callback names a different shop than the one that started the connection.");
155
+ }
156
+ if (!validCallbackHmac(search, params.clientSecret)) return oauthError("SHOPIFY_INVALID_OAUTH_HMAC", "Shopify OAuth callback HMAC is invalid.");
157
+ const timestamp = Number(search.get("timestamp"));
158
+ if (!Number.isInteger(timestamp) || Math.abs(Math.floor(params.now / 1000) - timestamp) > 300) {
159
+ return oauthError("SHOPIFY_STALE_OAUTH_CALLBACK", "Shopify OAuth callback timestamp is stale.");
160
+ }
161
+ const code = search.get("code");
162
+ if (!code) return oauthError("SHOPIFY_OAUTH_CODE_REQUIRED", "Shopify OAuth callback code is required.");
163
+ const token = await postTokenEndpoint(params.fetchImpl, params.origin, { client_id: params.clientId, client_secret: params.clientSecret, code, expiring: "1" });
164
+ if (!token.ok) return token;
165
+ const credentials = credentialsFrom(token.value, params.now);
166
+ // Merchants can untick scopes on the grant screen; a token missing one fails later in a way that
167
+ // looks like a product problem, so it is refused here, where the cause is still nameable.
168
+ const missing = missingScopes(credentials.grantedScopes, params.requiredScopes);
169
+ if (missing.length > 0) return oauthError("SHOPIFY_SCOPES_NOT_GRANTED", `The store did not grant: ${missing.join(", ")}.`);
170
+ return Ok(credentials);
171
+ }
172
+
173
+ /**
174
+ * New credentials when the access token is within the refresh margin of expiry, `null` when the
175
+ * current ones are good. A refresh token Shopify refuses means the merchant must reconnect.
176
+ */
177
+ export async function refreshIfExpiring(params: {
178
+ fetchImpl: typeof fetch;
179
+ origin: string;
180
+ clientId: string;
181
+ clientSecret: string;
182
+ credentials: ShopifyCredentials;
183
+ now: number;
184
+ }): Promise<Result<ShopifyCredentials | null, ChannelConnectorError>> {
185
+ const { credentials } = params;
186
+ if (credentials.accessTokenExpiresAt === undefined) return Ok(null);
187
+ const expiresAt = Date.parse(credentials.accessTokenExpiresAt);
188
+ if (Number.isFinite(expiresAt) && expiresAt - params.now > ACCESS_TOKEN_REFRESH_MARGIN_MS) return Ok(null);
189
+ if (credentials.refreshToken === undefined) return oauthError("SHOPIFY_REAUTHORIZATION_REQUIRED", "The store's access token expired and no refresh token is held; the store must be reconnected.");
190
+ const token = await postTokenEndpoint(params.fetchImpl, params.origin, {
191
+ client_id: params.clientId,
192
+ client_secret: params.clientSecret,
193
+ grant_type: "refresh_token",
194
+ refresh_token: credentials.refreshToken,
195
+ });
196
+ if (!token.ok) return token;
197
+ const refreshed = credentialsFrom(token.value, params.now);
198
+ // A refresh answer may omit the scope list; the grant did not change, so keep the one we hold.
199
+ return Ok(refreshed.grantedScopes.length > 0 ? refreshed : { ...refreshed, grantedScopes: credentials.grantedScopes });
200
+ }
@@ -1,18 +0,0 @@
1
- import type { ChannelConnectorError, ChannelPushCatalogItem, ChannelPushCatalogResult, ChannelStore, Result } from "@porulle/core";
2
- export declare const PUSH_CATALOG_SCOPE = "write_products";
3
- export declare const PORULLE_METAFIELD_NAMESPACE = "porulle";
4
- export declare const SHOPIFY_NATIVE_PRODUCT_FIELDS: Set<string>;
5
- export declare const SHOPIFY_NATIVE_VARIANT_FIELDS: Set<string>;
6
- export interface PushCatalogDeps {
7
- fetchImpl: typeof fetch;
8
- apiBase: (store: ChannelStore) => string;
9
- credentials: (store: ChannelStore) => string | undefined;
10
- sleep?: (ms: number) => Promise<void>;
11
- }
12
- export declare function shopifyGrantedScopes(store: ChannelStore): string[];
13
- export declare function shopifyPushCatalogEnabled(store: ChannelStore): boolean;
14
- export declare function shopifyWriteProductsScopeMissingError(reauthorizeUrl?: string): ChannelConnectorError;
15
- export declare function pushCatalog(deps: PushCatalogDeps, store: ChannelStore, items: ChannelPushCatalogItem[], opts?: {
16
- dryRun?: boolean;
17
- reauthorizeUrl?: string;
18
- }): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>>;