create-shopify-firebase-app 1.3.0 → 2.0.1

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 (44) hide show
  1. package/lib/index.js +704 -456
  2. package/package.json +2 -2
  3. package/templates/js/functions/package.json +24 -0
  4. package/templates/js/functions/src/admin-api.js +292 -0
  5. package/templates/js/functions/src/auth.js +147 -0
  6. package/templates/js/functions/src/config.js +14 -0
  7. package/templates/js/functions/src/firebase.js +12 -0
  8. package/templates/js/functions/src/index.js +93 -0
  9. package/templates/js/functions/src/proxy.js +60 -0
  10. package/templates/js/functions/src/verify-token.js +39 -0
  11. package/templates/js/functions/src/webhooks.js +111 -0
  12. package/templates/{firebase.json → shared/firebase.json} +1 -0
  13. package/templates/shopify.app.toml +1 -1
  14. package/templates/ts/functions/src/admin-api.ts +290 -0
  15. package/templates/web/css/app.css +1287 -47
  16. package/templates/web/index.html +84 -49
  17. package/templates/web/js/app.js +177 -0
  18. package/templates/web/js/pages/home.js +90 -0
  19. package/templates/web/js/pages/polaris-demo.js +190 -0
  20. package/templates/web/js/pages/products.js +319 -0
  21. package/templates/web/js/pages/settings.js +241 -0
  22. package/templates/web/polaris.html +1149 -0
  23. package/templates/web/products.html +86 -0
  24. package/templates/web/settings.html +40 -0
  25. package/templates/functions/src/admin-api.ts +0 -125
  26. package/templates/web/js/bridge.js +0 -98
  27. /package/templates/{env.example → shared/env.example} +0 -0
  28. /package/templates/{extensions → shared/extensions}/theme-block/assets/app-block.css +0 -0
  29. /package/templates/{extensions → shared/extensions}/theme-block/assets/app-block.js +0 -0
  30. /package/templates/{extensions → shared/extensions}/theme-block/blocks/app-block.liquid +0 -0
  31. /package/templates/{extensions → shared/extensions}/theme-block/locales/en.default.json +0 -0
  32. /package/templates/{extensions → shared/extensions}/theme-block/shopify.extension.toml +0 -0
  33. /package/templates/{firestore.indexes.json → shared/firestore.indexes.json} +0 -0
  34. /package/templates/{firestore.rules → shared/firestore.rules} +0 -0
  35. /package/templates/{gitignore → shared/gitignore} +0 -0
  36. /package/templates/{functions → ts/functions}/package.json +0 -0
  37. /package/templates/{functions → ts/functions}/src/auth.ts +0 -0
  38. /package/templates/{functions → ts/functions}/src/config.ts +0 -0
  39. /package/templates/{functions → ts/functions}/src/firebase.ts +0 -0
  40. /package/templates/{functions → ts/functions}/src/index.ts +0 -0
  41. /package/templates/{functions → ts/functions}/src/proxy.ts +0 -0
  42. /package/templates/{functions → ts/functions}/src/verify-token.ts +0 -0
  43. /package/templates/{functions → ts/functions}/src/webhooks.ts +0 -0
  44. /package/templates/{functions → ts/functions}/tsconfig.json +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-shopify-firebase-app",
3
- "version": "1.3.0",
4
- "description": "Create Shopify apps powered by Firebase — serverless, lightweight, zero-framework. The official alternative to Remix for Shopify + Firebase developers.",
3
+ "version": "2.0.1",
4
+ "description": "Create Shopify apps powered by Firebase — multi-page dashboard, App Bridge, Polaris components, TypeScript or JavaScript. Deploy for free.",
5
5
  "keywords": [
6
6
  "shopify",
7
7
  "firebase",
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "shopify-firebase-functions",
3
+ "private": true,
4
+ "main": "src/index.js",
5
+ "engines": {
6
+ "node": "20"
7
+ },
8
+ "scripts": {
9
+ "serve": "firebase emulators:start --only functions,firestore",
10
+ "deploy": "firebase deploy --only functions",
11
+ "deploy:auth": "firebase deploy --only functions:auth",
12
+ "deploy:api": "firebase deploy --only functions:api",
13
+ "deploy:webhooks": "firebase deploy --only functions:webhooks",
14
+ "deploy:proxy": "firebase deploy --only functions:proxy",
15
+ "deploy:all": "firebase deploy"
16
+ },
17
+ "dependencies": {
18
+ "cors": "^2.8.5",
19
+ "express": "^4.21.0",
20
+ "firebase-admin": "^13.0.0",
21
+ "firebase-functions": "^6.3.0",
22
+ "jsonwebtoken": "^9.0.2"
23
+ }
24
+ }
@@ -0,0 +1,292 @@
1
+ const { Router } = require("express");
2
+ const { verifySessionToken } = require("./verify-token");
3
+ const { getAccessToken } = require("./auth");
4
+ const { db } = require("./firebase");
5
+
6
+ const adminApiRouter = Router();
7
+
8
+ // All admin routes require session token verification
9
+ adminApiRouter.use(verifySessionToken);
10
+
11
+ // Shopify API version — update when Shopify releases new versions
12
+ // Docs: https://shopify.dev/docs/api/usage/versioning
13
+ const API_VERSION = "2026-01";
14
+
15
+ // Default app settings — returned when no settings are saved yet
16
+ const DEFAULT_SETTINGS = {
17
+ greeting: "Welcome to our app!",
18
+ theme: "auto",
19
+ notifications: true,
20
+ customCss: "",
21
+ };
22
+
23
+ // ─── Get shop info ───────────────────────────────────────────────────────
24
+ adminApiRouter.get("/shop", async (req, res) => {
25
+ const shop = req.shopDomain;
26
+ const accessToken = await getAccessToken(shop);
27
+
28
+ if (!accessToken) {
29
+ res.status(401).json({ error: "Shop not authenticated" });
30
+ return;
31
+ }
32
+
33
+ try {
34
+ const response = await fetch(
35
+ `https://${shop}/admin/api/${API_VERSION}/graphql.json`,
36
+ {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ "X-Shopify-Access-Token": accessToken,
41
+ },
42
+ body: JSON.stringify({
43
+ query: `{
44
+ shop {
45
+ name
46
+ email
47
+ myshopifyDomain
48
+ url
49
+ primaryDomain { url host }
50
+ plan { displayName partnerDevelopment shopifyPlus }
51
+ currencyCode
52
+ ianaTimezone
53
+ billingAddress { country countryCodeV2 }
54
+ productCount: productsCount { count }
55
+ }
56
+ }`,
57
+ }),
58
+ },
59
+ );
60
+
61
+ const data = await response.json();
62
+ res.json({ shop: data.data?.shop });
63
+ } catch (err) {
64
+ console.error("Shop info error:", err);
65
+ res.status(500).json({ error: err.message });
66
+ }
67
+ });
68
+
69
+ // ─── Search products ────────────────────────────────────────────────────
70
+ // NOTE: This route MUST be defined before /products/:id so Express
71
+ // doesn't match "search" as a product ID.
72
+ adminApiRouter.get("/products/search", async (req, res) => {
73
+ const shop = req.shopDomain;
74
+ const query = req.query.q || "";
75
+ const accessToken = await getAccessToken(shop);
76
+
77
+ if (!accessToken) {
78
+ res.status(401).json({ error: "Shop not authenticated" });
79
+ return;
80
+ }
81
+
82
+ try {
83
+ const response = await fetch(
84
+ `https://${shop}/admin/api/${API_VERSION}/graphql.json`,
85
+ {
86
+ method: "POST",
87
+ headers: {
88
+ "Content-Type": "application/json",
89
+ "X-Shopify-Access-Token": accessToken,
90
+ },
91
+ body: JSON.stringify({
92
+ query: `
93
+ query SearchProducts($query: String!) {
94
+ products(first: 10, query: $query) {
95
+ edges {
96
+ node {
97
+ id
98
+ title
99
+ handle
100
+ status
101
+ featuredImage { url }
102
+ variants(first: 1) {
103
+ edges { node { id price } }
104
+ }
105
+ priceRangeV2 {
106
+ minVariantPrice { amount currencyCode }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ `,
113
+ variables: { query },
114
+ }),
115
+ },
116
+ );
117
+
118
+ const data = await response.json();
119
+ const products = (data.data?.products?.edges || []).map((edge) => ({
120
+ id: edge.node.id,
121
+ title: edge.node.title,
122
+ handle: edge.node.handle,
123
+ status: edge.node.status,
124
+ image: edge.node.featuredImage?.url || null,
125
+ variantId: edge.node.variants.edges[0]?.node.id || null,
126
+ price: edge.node.priceRangeV2?.minVariantPrice?.amount,
127
+ currency: edge.node.priceRangeV2?.minVariantPrice?.currencyCode,
128
+ }));
129
+
130
+ res.json({ products });
131
+ } catch (err) {
132
+ console.error("Product search error:", err);
133
+ res.status(500).json({ error: err.message });
134
+ }
135
+ });
136
+
137
+ // ─── Get product detail ─────────────────────────────────────────────────
138
+ adminApiRouter.get("/products/:id", async (req, res) => {
139
+ const shop = req.shopDomain;
140
+ const accessToken = await getAccessToken(shop);
141
+
142
+ if (!accessToken) {
143
+ res.status(401).json({ error: "Shop not authenticated" });
144
+ return;
145
+ }
146
+
147
+ const productId = req.params.id;
148
+ // Support both raw numeric IDs and full GID format
149
+ const gid = productId.startsWith("gid://")
150
+ ? productId
151
+ : `gid://shopify/Product/${productId}`;
152
+
153
+ try {
154
+ const response = await fetch(
155
+ `https://${shop}/admin/api/${API_VERSION}/graphql.json`,
156
+ {
157
+ method: "POST",
158
+ headers: {
159
+ "Content-Type": "application/json",
160
+ "X-Shopify-Access-Token": accessToken,
161
+ },
162
+ body: JSON.stringify({
163
+ query: `
164
+ query GetProduct($id: ID!) {
165
+ product(id: $id) {
166
+ id
167
+ title
168
+ handle
169
+ description
170
+ status
171
+ vendor
172
+ productType
173
+ tags
174
+ totalInventory
175
+ priceRangeV2 {
176
+ maxVariantPrice { amount currencyCode }
177
+ minVariantPrice { amount currencyCode }
178
+ }
179
+ featuredImage { url altText }
180
+ images(first: 5) {
181
+ edges { node { url altText } }
182
+ }
183
+ variants(first: 20) {
184
+ edges {
185
+ node {
186
+ id
187
+ title
188
+ price
189
+ sku
190
+ inventoryQuantity
191
+ selectedOptions { name value }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ `,
198
+ variables: { id: gid },
199
+ }),
200
+ },
201
+ );
202
+
203
+ const data = await response.json();
204
+ const product = data.data?.product;
205
+
206
+ if (!product) {
207
+ res.status(404).json({ error: "Product not found" });
208
+ return;
209
+ }
210
+
211
+ res.json({ product });
212
+ } catch (err) {
213
+ console.error("Product detail error:", err);
214
+ res.status(500).json({ error: err.message });
215
+ }
216
+ });
217
+
218
+ // ─── Get app settings ───────────────────────────────────────────────────
219
+ adminApiRouter.get("/settings", async (req, res) => {
220
+ const shop = req.shopDomain;
221
+
222
+ try {
223
+ const doc = await db.collection("appSettings").doc(shop).get();
224
+
225
+ if (!doc.exists) {
226
+ res.json({ settings: { ...DEFAULT_SETTINGS } });
227
+ return;
228
+ }
229
+
230
+ // Merge with defaults so new keys are always present
231
+ res.json({ settings: { ...DEFAULT_SETTINGS, ...doc.data() } });
232
+ } catch (err) {
233
+ console.error("Get settings error:", err);
234
+ res.status(500).json({ error: err.message });
235
+ }
236
+ });
237
+
238
+ // ─── Save app settings ──────────────────────────────────────────────────
239
+ adminApiRouter.post("/settings", async (req, res) => {
240
+ const shop = req.shopDomain;
241
+ const body = req.body;
242
+
243
+ if (!body || typeof body !== "object") {
244
+ res.status(400).json({ error: "Request body must be a JSON object" });
245
+ return;
246
+ }
247
+
248
+ // Only allow known setting keys
249
+ const allowedKeys = Object.keys(DEFAULT_SETTINGS);
250
+ const settings = {};
251
+
252
+ for (const key of allowedKeys) {
253
+ if (key in body) {
254
+ settings[key] = body[key];
255
+ }
256
+ }
257
+
258
+ if (Object.keys(settings).length === 0) {
259
+ res.status(400).json({
260
+ error: "No valid settings provided",
261
+ allowedKeys,
262
+ });
263
+ return;
264
+ }
265
+
266
+ try {
267
+ await db.collection("appSettings").doc(shop).set(settings, { merge: true });
268
+
269
+ // Return the full merged settings
270
+ const doc = await db.collection("appSettings").doc(shop).get();
271
+ res.json({ settings: { ...DEFAULT_SETTINGS, ...doc.data() } });
272
+ } catch (err) {
273
+ console.error("Save settings error:", err);
274
+ res.status(500).json({ error: err.message });
275
+ }
276
+ });
277
+
278
+ // ──────────────────────────────────────────────────────────────────────────
279
+ // HOW TO ADD A NEW ADMIN API ROUTE:
280
+ //
281
+ // adminApiRouter.post("/my-endpoint", async (req, res) => {
282
+ // const shop = req.shopDomain;
283
+ // const accessToken = await getAccessToken(shop);
284
+ // // Call Shopify Admin API, write to Firestore, etc.
285
+ // res.json({ success: true });
286
+ // });
287
+ //
288
+ // All routes are automatically protected by JWT session token verification.
289
+ // Deploy: firebase deploy --only functions:api
290
+ // ──────────────────────────────────────────────────────────────────────────
291
+
292
+ module.exports = { adminApiRouter };
@@ -0,0 +1,147 @@
1
+ const crypto = require("crypto");
2
+ const { getConfig } = require("./config");
3
+ const { db } = require("./firebase");
4
+
5
+ /**
6
+ * Standalone OAuth handler — no Express, no middleware overhead.
7
+ *
8
+ * Routes:
9
+ * GET /auth -> Start OAuth (redirect to Shopify consent screen)
10
+ * GET /auth/callback -> Handle callback (exchange code, store session)
11
+ */
12
+ async function authHandler(req, res) {
13
+ const urlPath = req.path;
14
+
15
+ if (req.method !== "GET") {
16
+ res.status(405).send("Method not allowed");
17
+ return;
18
+ }
19
+
20
+ if (urlPath === "/auth/callback") {
21
+ await handleCallback(req, res);
22
+ } else {
23
+ handleStart(req, res);
24
+ }
25
+ }
26
+
27
+ // ─── Step 1: Start OAuth ─────────────────────────────────────────────────
28
+ // Merchant clicks "Install" -> redirect to Shopify consent screen.
29
+ function handleStart(req, res) {
30
+ const { shop } = req.query;
31
+ if (!shop || typeof shop !== "string") {
32
+ res.status(400).send("Missing shop parameter");
33
+ return;
34
+ }
35
+
36
+ const config = getConfig();
37
+ const nonce = crypto.randomBytes(16).toString("hex");
38
+ const redirectUri = `${config.appUrl}/auth/callback`;
39
+
40
+ // Store nonce for CSRF protection
41
+ db.collection("authNonces").doc(nonce).set({
42
+ shop,
43
+ createdAt: new Date().toISOString(),
44
+ });
45
+
46
+ const authUrl =
47
+ `https://${shop}/admin/oauth/authorize` +
48
+ `?client_id=${config.apiKey}` +
49
+ `&scope=${config.scopes}` +
50
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
51
+ `&state=${nonce}`;
52
+
53
+ res.redirect(authUrl);
54
+ }
55
+
56
+ // ─── Step 2: OAuth Callback ──────────────────────────────────────────────
57
+ // Shopify redirects back with code + HMAC. Verify, exchange, store session.
58
+ async function handleCallback(req, res) {
59
+ const { shop, code, hmac, state } = req.query;
60
+
61
+ if (!shop || !code || !hmac) {
62
+ res.status(400).send("Missing required parameters");
63
+ return;
64
+ }
65
+
66
+ const config = getConfig();
67
+
68
+ // Verify HMAC (timing-safe comparison)
69
+ const queryParams = { ...req.query };
70
+ delete queryParams.hmac;
71
+ delete queryParams.signature;
72
+ const message = Object.keys(queryParams)
73
+ .sort()
74
+ .map((key) => `${key}=${queryParams[key]}`)
75
+ .join("&");
76
+ const generatedHmac = crypto
77
+ .createHmac("sha256", config.apiSecret)
78
+ .update(message)
79
+ .digest("hex");
80
+
81
+ const hmacBuffer = Buffer.from(hmac);
82
+ const generatedBuffer = Buffer.from(generatedHmac);
83
+ if (
84
+ hmacBuffer.length !== generatedBuffer.length ||
85
+ !crypto.timingSafeEqual(generatedBuffer, hmacBuffer)
86
+ ) {
87
+ res.status(403).send("HMAC verification failed");
88
+ return;
89
+ }
90
+
91
+ // Clean up nonce
92
+ if (state) {
93
+ const nonceDoc = await db.collection("authNonces").doc(state).get();
94
+ if (nonceDoc.exists) await nonceDoc.ref.delete();
95
+ }
96
+
97
+ // Exchange code for access token
98
+ try {
99
+ const tokenResponse = await fetch(
100
+ `https://${shop}/admin/oauth/access_token`,
101
+ {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ client_id: config.apiKey,
106
+ client_secret: config.apiSecret,
107
+ code,
108
+ }),
109
+ },
110
+ );
111
+
112
+ if (!tokenResponse.ok) {
113
+ const errorText = await tokenResponse.text();
114
+ console.error("Token exchange failed:", errorText);
115
+ res.status(500).send("Token exchange failed");
116
+ return;
117
+ }
118
+
119
+ const tokenData = await tokenResponse.json();
120
+
121
+ // Store session in Firestore
122
+ await db
123
+ .collection("shopSessions")
124
+ .doc(shop)
125
+ .set({
126
+ shop,
127
+ accessToken: tokenData.access_token,
128
+ scope: tokenData.scope,
129
+ installedAt: new Date().toISOString(),
130
+ });
131
+
132
+ console.log(`App installed for shop: ${shop}`);
133
+ res.redirect(`https://${shop}/admin/apps/${config.apiKey}`);
134
+ } catch (err) {
135
+ console.error("OAuth error:", err);
136
+ res.status(500).send("OAuth error");
137
+ }
138
+ }
139
+
140
+ // Helper: get stored access token for a shop
141
+ async function getAccessToken(shop) {
142
+ const doc = await db.collection("shopSessions").doc(shop).get();
143
+ if (!doc.exists) return null;
144
+ return doc.data()?.accessToken || null;
145
+ }
146
+
147
+ module.exports = { authHandler, getAccessToken };
@@ -0,0 +1,14 @@
1
+ // Centralized config from environment variables.
2
+ // Firebase Functions auto-loads .env files from the functions/ directory.
3
+ // Docs: https://firebase.google.com/docs/functions/config-env
4
+
5
+ function getConfig() {
6
+ return {
7
+ apiKey: process.env.SHOPIFY_API_KEY || "",
8
+ apiSecret: process.env.SHOPIFY_API_SECRET || "",
9
+ scopes: process.env.SCOPES || "read_products",
10
+ appUrl: process.env.APP_URL || "",
11
+ };
12
+ }
13
+
14
+ module.exports = { getConfig };
@@ -0,0 +1,12 @@
1
+ const admin = require("firebase-admin");
2
+
3
+ // Initialize Firebase Admin SDK once.
4
+ // Credentials are auto-detected on Firebase infrastructure.
5
+ // For local dev, use `firebase emulators:start`.
6
+ if (!admin.apps.length) {
7
+ admin.initializeApp();
8
+ }
9
+
10
+ const db = admin.firestore();
11
+
12
+ module.exports = { db };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Cloud Function exports — each function scales independently.
3
+ *
4
+ * Firebase v2 (gen 2) functions run on Cloud Run with per-function
5
+ * concurrency, memory, timeout, and min-instance settings.
6
+ *
7
+ * Architecture:
8
+ * auth — OAuth 2.0 install + callback (standalone, no Express)
9
+ * api — Admin dashboard API routes (Express + JWT middleware)
10
+ * webhooks — Webhook handlers (standalone, no Express — fast cold starts)
11
+ * proxy — Storefront App Proxy routes (Express + HMAC verification)
12
+ *
13
+ * Adding a new function:
14
+ * 1. Create a new file in src/ (e.g. src/cron.js)
15
+ * 2. Export a handler from it
16
+ * 3. Import and re-export here with desired options
17
+ * 4. Add a rewrite in firebase.json if it needs an HTTP endpoint
18
+ * 5. Run: firebase deploy --only functions:yourFunction
19
+ *
20
+ * Docs: https://firebase.google.com/docs/functions/http-events?gen=2nd
21
+ */
22
+
23
+ require("./firebase"); // Initialize Firebase Admin SDK — must be first
24
+
25
+ const { onRequest } = require("firebase-functions/v2/https");
26
+ const express = require("express");
27
+ const cors = require("cors");
28
+ const { authHandler } = require("./auth");
29
+ const { adminApiRouter } = require("./admin-api");
30
+ const { proxyRouter } = require("./proxy");
31
+ const { webhookHandler } = require("./webhooks");
32
+
33
+ // ─── auth: OAuth 2.0 install + callback ──────────────────────────────────
34
+ // Standalone handler (no Express overhead). Handles:
35
+ // GET /auth -> redirect to Shopify consent screen
36
+ // GET /auth/callback -> exchange code for access token
37
+ exports.auth = onRequest(
38
+ { memory: "256MiB", timeoutSeconds: 30, invoker: "public" },
39
+ authHandler,
40
+ );
41
+
42
+ // ─── api: Admin dashboard API ────────────────────────────────────────────
43
+ // Express app with JWT session token middleware on all routes.
44
+ // Add routes in src/admin-api.js.
45
+ const apiApp = express();
46
+ apiApp.use(cors({ origin: true }));
47
+ apiApp.use(express.json());
48
+ apiApp.use("/api", adminApiRouter);
49
+
50
+ exports.api = onRequest(
51
+ { memory: "256MiB", timeoutSeconds: 60, invoker: "public" },
52
+ apiApp,
53
+ );
54
+
55
+ // ─── webhooks: Shopify webhook handlers ──────────────────────────────────
56
+ // Standalone handler for maximum speed. Must respond 200 within 5 seconds.
57
+ // No Express, no CORS, no JSON parsing — just raw body HMAC verification.
58
+ exports.webhooks = onRequest(
59
+ { memory: "256MiB", timeoutSeconds: 10, invoker: "public" },
60
+ webhookHandler,
61
+ );
62
+
63
+ // ─── proxy: Storefront App Proxy routes ──────────────────────────────────
64
+ // Express app for storefront-facing endpoints.
65
+ // Add routes in src/proxy.js. Enable App Proxy in shopify.app.toml.
66
+ const proxyApp = express();
67
+ proxyApp.use(cors({ origin: true }));
68
+ proxyApp.use(express.json());
69
+ proxyApp.use("/proxy", proxyRouter);
70
+
71
+ exports.proxy = onRequest(
72
+ { memory: "256MiB", timeoutSeconds: 30, invoker: "public" },
73
+ proxyApp,
74
+ );
75
+
76
+ // ──────────────────────────────────────────────────────────────────────────
77
+ // HOW TO ADD A NEW FUNCTION:
78
+ //
79
+ // // 1. Create src/my-feature.js with your handler
80
+ // const { myFeatureHandler } = require("./my-feature");
81
+ //
82
+ // // 2. Export it here with desired options
83
+ // exports.myFeature = onRequest(
84
+ // { memory: "256MiB", timeoutSeconds: 60, invoker: "public" },
85
+ // myFeatureHandler,
86
+ // );
87
+ //
88
+ // // 3. Add rewrite in firebase.json:
89
+ // // { "source": "/my-feature/**", "run": { "serviceId": "myFeature", "region": "us-central1" } }
90
+ //
91
+ // // 4. Deploy only your function:
92
+ // // firebase deploy --only functions:myFeature
93
+ // ──────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,60 @@
1
+ const { Router } = require("express");
2
+ const crypto = require("crypto");
3
+ const { getConfig } = require("./config");
4
+
5
+ const proxyRouter = Router();
6
+
7
+ // ─── Verify Shopify App Proxy Signature ──────────────────────────────────
8
+ // Docs: https://shopify.dev/docs/apps/build/online-store/app-proxies
9
+ function verifyProxySignature(query) {
10
+ const config = getConfig();
11
+ const signature = query.signature;
12
+ if (!signature) return false;
13
+
14
+ const params = { ...query };
15
+ delete params.signature;
16
+
17
+ // App proxy concatenates without & (different from OAuth HMAC)
18
+ const message = Object.keys(params)
19
+ .sort()
20
+ .map((key) => `${key}=${params[key]}`)
21
+ .join("");
22
+
23
+ const generated = crypto
24
+ .createHmac("sha256", config.apiSecret)
25
+ .update(message)
26
+ .digest("hex");
27
+
28
+ const sigBuffer = Buffer.from(signature);
29
+ const genBuffer = Buffer.from(generated);
30
+ if (sigBuffer.length !== genBuffer.length) return false;
31
+ return crypto.timingSafeEqual(genBuffer, sigBuffer);
32
+ }
33
+
34
+ // ─── Example storefront endpoint ─────────────────────────────────────────
35
+ // Accessible at: https://your-store.myshopify.com/apps/{subpath}/hello
36
+ proxyRouter.get("/hello", (req, res) => {
37
+ if (!verifyProxySignature(req.query)) {
38
+ res.status(403).json({ error: "Invalid signature" });
39
+ return;
40
+ }
41
+
42
+ const shop = req.query.shop;
43
+ res.json({ message: `Hello from the app proxy! Shop: ${shop}` });
44
+ });
45
+
46
+ // ──────────────────────────────────────────────────────────────────────────
47
+ // HOW TO ADD A NEW PROXY ROUTE:
48
+ //
49
+ // proxyRouter.get("/my-route", (req, res) => {
50
+ // if (!verifyProxySignature(req.query)) {
51
+ // return res.status(403).json({ error: "Invalid signature" });
52
+ // }
53
+ // res.json({ hello: "storefront" });
54
+ // });
55
+ //
56
+ // Enable App Proxy in shopify.app.toml (uncomment the [app_proxy] section).
57
+ // Deploy: firebase deploy --only functions:proxy
58
+ // ──────────────────────────────────────────────────────────────────────────
59
+
60
+ module.exports = { proxyRouter };