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.
- package/lib/index.js +704 -456
- package/package.json +2 -2
- package/templates/js/functions/package.json +24 -0
- package/templates/js/functions/src/admin-api.js +292 -0
- package/templates/js/functions/src/auth.js +147 -0
- package/templates/js/functions/src/config.js +14 -0
- package/templates/js/functions/src/firebase.js +12 -0
- package/templates/js/functions/src/index.js +93 -0
- package/templates/js/functions/src/proxy.js +60 -0
- package/templates/js/functions/src/verify-token.js +39 -0
- package/templates/js/functions/src/webhooks.js +111 -0
- package/templates/{firebase.json → shared/firebase.json} +1 -0
- package/templates/shopify.app.toml +1 -1
- package/templates/ts/functions/src/admin-api.ts +290 -0
- package/templates/web/css/app.css +1287 -47
- package/templates/web/index.html +84 -49
- package/templates/web/js/app.js +177 -0
- package/templates/web/js/pages/home.js +90 -0
- package/templates/web/js/pages/polaris-demo.js +190 -0
- package/templates/web/js/pages/products.js +319 -0
- package/templates/web/js/pages/settings.js +241 -0
- package/templates/web/polaris.html +1149 -0
- package/templates/web/products.html +86 -0
- package/templates/web/settings.html +40 -0
- package/templates/functions/src/admin-api.ts +0 -125
- package/templates/web/js/bridge.js +0 -98
- /package/templates/{env.example → shared/env.example} +0 -0
- /package/templates/{extensions → shared/extensions}/theme-block/assets/app-block.css +0 -0
- /package/templates/{extensions → shared/extensions}/theme-block/assets/app-block.js +0 -0
- /package/templates/{extensions → shared/extensions}/theme-block/blocks/app-block.liquid +0 -0
- /package/templates/{extensions → shared/extensions}/theme-block/locales/en.default.json +0 -0
- /package/templates/{extensions → shared/extensions}/theme-block/shopify.extension.toml +0 -0
- /package/templates/{firestore.indexes.json → shared/firestore.indexes.json} +0 -0
- /package/templates/{firestore.rules → shared/firestore.rules} +0 -0
- /package/templates/{gitignore → shared/gitignore} +0 -0
- /package/templates/{functions → ts/functions}/package.json +0 -0
- /package/templates/{functions → ts/functions}/src/auth.ts +0 -0
- /package/templates/{functions → ts/functions}/src/config.ts +0 -0
- /package/templates/{functions → ts/functions}/src/firebase.ts +0 -0
- /package/templates/{functions → ts/functions}/src/index.ts +0 -0
- /package/templates/{functions → ts/functions}/src/proxy.ts +0 -0
- /package/templates/{functions → ts/functions}/src/verify-token.ts +0 -0
- /package/templates/{functions → ts/functions}/src/webhooks.ts +0 -0
- /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": "
|
|
4
|
-
"description": "Create Shopify apps powered by Firebase —
|
|
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 };
|