1 // Shopify Store stores its connection_settings privately in `settings`
2 // (encrypted at rest, Admin tokens, refresh state, plus the public-facing
3 // Storefront fields `shop_domain` and `storefront_access_token`).
4 // `connection.public_settings` is intentionally empty for this connector;
5 // the OpenInt `public_settings` split was rolled back per OpenInt #1703.
6 //
7 // This helper fetches `items[0].settings` from
8 // `/api/v2/connection?include_secrets=true&...` using the standard Replit
9 // connector proxy auth (REPL_IDENTITY / WEB_REPL_RENEWAL on
10 // `X_REPLIT_TOKEN`) and extracts ONLY `shop_domain` and
11 // `storefront_access_token` for app code. Other private settings (Admin
12 // tokens, refresh state, transfer status, `shop_id`) are never read or
13 // cached, so unrelated secrets stay out of generated app surface area.
14 // Shopify treats the Storefront access token as a public buyer-facing
15 // credential, so it is safe to send to Shopify's Storefront API from
16 // server-side app code.
17 //
18 // The Storefront API version is pinned to a known-good Shopify release
19 // rather than read from the connection. Dev-store preview behavior
20 // (appending `channel=online_store` to checkout URLs for password-gated
21 // Vibe stores) is no longer auto-detected from the connection — pass
22 // `useDevStorePreview` to `shopifyStorefrontRequest` callers / cart
23 // helpers when the agent context indicates a dev/preview environment.
24 const STOREFRONT_API_VERSION = "2026-04";
25
26 type ShopifyConnectionSettings = {
27 shop_domain?: string;
28 storefront_access_token?: string;
29 };
30
31 type ShopifyConnectionResponse = {
32 items?: Array<{
33 settings?: ShopifyConnectionSettings;
34 }>;
35 };
36
37 type ShopifyStorefrontConfig = {
38 shopDomain: string;
39 storefrontAccessToken: string;
40 };
41
42 const CONFIG_CACHE_TTL_MS = 60_000;
43 const FETCH_TIMEOUT_MS = 10_000;
44
45 let cachedConfig:
46 | { value: ShopifyStorefrontConfig; expiresAt: number }
47 | undefined;
48
49 function getOpenIntConnectionConfig() {
50 const hostname = process.env.REPLIT_CONNECTORS_HOSTNAME;
51 const token = process.env.REPL_IDENTITY
52 ? `repl ${process.env.REPL_IDENTITY}`
53 : process.env.WEB_REPL_RENEWAL
54 ? `depl ${process.env.WEB_REPL_RENEWAL}`
55 : null;
56
57 if (!hostname || !token) {
58 throw new Error("Missing Replit connector environment variables");
59 }
60
61 const protocol = hostname.startsWith("localhost") ? "http" : "https";
62 const connectionUrl = new URL(`${protocol}://${hostname}/api/v2/connection`);
63 connectionUrl.searchParams.set("include_secrets", "true");
64 connectionUrl.searchParams.set("connector_names", "shopify-store");
65 connectionUrl.searchParams.set("refresh_policy", "none");
66
67 return {
68 connectionUrl: connectionUrl.toString(),
69 token,
70 };
71 }
72
73 export async function getShopifyStorefrontConfig(
74 options: { forceRefresh?: boolean } = {},
75 ): Promise<ShopifyStorefrontConfig> {
76 if (cachedConfig && !options.forceRefresh && Date.now() < cachedConfig.expiresAt) {
77 return cachedConfig.value;
78 }
79
80 const { connectionUrl, token } = getOpenIntConnectionConfig();
81 // `cache: "no-store"` opts out of framework-enhanced fetch caching
82 // (Next.js SSR / server actions, edge runtimes). This helper's own
83 // 60s TTL + the 401/403 forceRefresh path are the only intended cache
84 // boundaries; framework caching would defeat the retry by handing
85 // back a stale connection response after OpenInt remints the
86 // Storefront token (e.g. during Go Live).
87 const resp = await fetch(connectionUrl, {
88 headers: {
89 Accept: "application/json",
90 X_REPLIT_TOKEN: token,
91 },
92 cache: "no-store",
93 signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
94 });
95
96 if (!resp.ok) {
97 throw new Error(`Failed to fetch Shopify connection: ${resp.status}`);
98 }
99
100 const data = (await resp.json()) as ShopifyConnectionResponse;
101 const settings = data.items?.[0]?.settings;
102 if (!settings?.shop_domain || !settings.storefront_access_token) {
103 throw new Error(
104 "Shopify Store integration is missing Storefront settings. Recreate the integration after OpenInt provisions a Storefront token.",
105 );
106 }
107
108 // Extract only the Storefront-facing fields so unrelated private
109 // settings (Admin tokens, refresh state, transfer status, shop_id)
110 // never reach generated app code or the in-memory cache.
111 cachedConfig = {
112 value: {
113 shopDomain: settings.shop_domain,
114 storefrontAccessToken: settings.storefront_access_token,
115 },
116 expiresAt: Date.now() + CONFIG_CACHE_TTL_MS,
117 };
118 return cachedConfig.value;
119 }
120
121 export async function shopifyStorefrontRequest<T>(
122 query: string,
123 variables?: Record<string, unknown>,
124 options: { retryOnUnauthorized?: boolean } = {},
125 ): Promise<T> {
126 const config = await getShopifyStorefrontConfig();
127
128 const resp = await fetch(
129 `https://${config.shopDomain}/api/${STOREFRONT_API_VERSION}/graphql.json`,
130 {
131 method: "POST",
132 headers: {
133 "Content-Type": "application/json",
134 "X-Shopify-Storefront-Access-Token": config.storefrontAccessToken,
135 },
136 body: JSON.stringify({ query, variables }),
137 signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
138 },
139 );
140
141 if (
142 options.retryOnUnauthorized !== false &&
143 (resp.status === 401 || resp.status === 403)
144 ) {
145 cachedConfig = undefined;
146 await getShopifyStorefrontConfig({ forceRefresh: true });
147 return shopifyStorefrontRequest<T>(query, variables, {
148 retryOnUnauthorized: false,
149 });
150 }
151
152 const text = await resp.text();
153 const json = text ? safeJsonParse(text) : {};
154 if (!resp.ok || json.errors?.length) {
155 throw new Error(
156 `Shopify Storefront API error (${resp.status}): ${JSON.stringify(json.errors ?? json)}`,
157 );
158 }
159
160 return json.data as T;
161 }
162
163 function safeJsonParse(text: string) {
164 try {
165 return JSON.parse(text);
166 } catch {
167 return { errors: [{ message: text }] };
168 }
169 }