create-shopify-firebase-app 1.3.0 → 2.0.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.
Files changed (44) hide show
  1. package/lib/index.js +672 -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
@@ -0,0 +1,39 @@
1
+ const jwt = require("jsonwebtoken");
2
+ const { getConfig } = require("./config");
3
+
4
+ // Middleware: verify App Bridge session token.
5
+ // The embedded dashboard sends Authorization: Bearer <jwt> with every request.
6
+ // Docs: https://shopify.dev/docs/apps/build/authentication/session-tokens
7
+ function verifySessionToken(req, res, next) {
8
+ const authHeader = req.headers.authorization;
9
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
10
+ res.status(401).json({ error: "Missing Authorization header" });
11
+ return;
12
+ }
13
+
14
+ const token = authHeader.split(" ")[1];
15
+ const config = getConfig();
16
+
17
+ try {
18
+ const decoded = jwt.verify(token, config.apiSecret, {
19
+ algorithms: ["HS256"],
20
+ });
21
+
22
+ if (decoded.aud !== config.apiKey) {
23
+ res.status(403).json({ error: "Token audience mismatch" });
24
+ return;
25
+ }
26
+
27
+ // Extract shop from issuer URL
28
+ const issUrl = new URL(decoded.iss);
29
+ req.shopDomain = issUrl.hostname;
30
+ req.sessionToken = decoded;
31
+
32
+ next();
33
+ } catch (err) {
34
+ console.error("Session token verification failed:", err);
35
+ res.status(401).json({ error: "Invalid session token" });
36
+ }
37
+ }
38
+
39
+ module.exports = { verifySessionToken };
@@ -0,0 +1,111 @@
1
+ const crypto = require("crypto");
2
+ const { getConfig } = require("./config");
3
+ const { db } = require("./firebase");
4
+
5
+ // ─── Verify Shopify Webhook HMAC ─────────────────────────────────────────
6
+ function verifyWebhookHmac(rawBody, hmacHeader) {
7
+ const config = getConfig();
8
+ const hash = crypto
9
+ .createHmac("sha256", config.apiSecret)
10
+ .update(rawBody)
11
+ .digest("base64");
12
+
13
+ try {
14
+ return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hmacHeader));
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Standalone webhook handler — no Express, no middleware.
22
+ *
23
+ * Why standalone? Webhooks must respond 200 within 5 seconds.
24
+ * Skipping Express middleware means faster cold starts and less overhead.
25
+ * Uses req.rawBody (provided natively by Firebase v2) for HMAC verification.
26
+ */
27
+ async function webhookHandler(req, res) {
28
+ // Only accept POST
29
+ if (req.method !== "POST") {
30
+ res.status(405).send("Method not allowed");
31
+ return;
32
+ }
33
+
34
+ const hmac = req.headers["x-shopify-hmac-sha256"];
35
+ const topic = req.headers["x-shopify-topic"];
36
+ const shop = req.headers["x-shopify-shop-domain"];
37
+
38
+ // Verify HMAC using rawBody (Buffer, provided by Firebase v2)
39
+ if (req.rawBody && hmac && !verifyWebhookHmac(req.rawBody, hmac)) {
40
+ console.error("Webhook HMAC verification failed");
41
+ res.status(401).send("Unauthorized");
42
+ return;
43
+ }
44
+
45
+ console.log(`Webhook: ${topic} from ${shop}`);
46
+
47
+ // Parse body
48
+ let body = {};
49
+ try {
50
+ body = JSON.parse(req.rawBody?.toString("utf8") || "{}");
51
+ } catch {
52
+ // Non-JSON webhook payloads are rare but valid
53
+ }
54
+
55
+ switch (topic) {
56
+ // ── App lifecycle ──────────────────────────────────────────────────
57
+ case "app/uninstalled": {
58
+ await db.collection("shopSessions").doc(shop).delete();
59
+ console.log(`Session cleaned up for ${shop}`);
60
+ break;
61
+ }
62
+
63
+ // ── GDPR mandatory webhooks (required for App Store) ───────────────
64
+ case "customers/data_request": {
65
+ // Customer requested their data. Export within 30 days if you store any.
66
+ console.log(`Customer data request: ${shop}`);
67
+ // TODO: implement if you store customer data
68
+ break;
69
+ }
70
+
71
+ case "customers/redact": {
72
+ // Customer requested deletion. Delete within 30 days.
73
+ console.log(`Customer redact: ${shop}`);
74
+ // TODO: implement if you store customer data
75
+ break;
76
+ }
77
+
78
+ case "shop/redact": {
79
+ // 48h after uninstall. Delete ALL shop data.
80
+ console.log(`Shop redact: ${shop}`);
81
+ // TODO: delete all data for this shop from Firestore
82
+ break;
83
+ }
84
+
85
+ default:
86
+ console.log(`Unhandled webhook: ${topic}`);
87
+ }
88
+
89
+ // Always respond 200 quickly — do heavy work asynchronously
90
+ res.status(200).send("OK");
91
+ }
92
+
93
+ // ──────────────────────────────────────────────────────────────────────────
94
+ // HOW TO ADD A NEW WEBHOOK HANDLER:
95
+ //
96
+ // 1. Register the topic in shopify.app.toml:
97
+ // [[webhooks.subscriptions]]
98
+ // topics = [ "orders/create" ]
99
+ // uri = "{{APP_URL}}/webhooks"
100
+ //
101
+ // 2. Add a case to the switch above:
102
+ // case "orders/create": {
103
+ // const order = body;
104
+ // // Your logic here
105
+ // break;
106
+ // }
107
+ //
108
+ // 3. Deploy: firebase deploy --only functions:webhooks
109
+ // ──────────────────────────────────────────────────────────────────────────
110
+
111
+ module.exports = { webhookHandler };
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "hosting": {
3
3
  "public": "web",
4
+ "cleanUrls": true,
4
5
  "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
5
6
  "rewrites": [
6
7
  { "source": "/auth", "run": { "serviceId": "auth", "region": "us-central1" } },
@@ -22,7 +22,7 @@ api_version = "2026-01"
22
22
  topics = [ "app/uninstalled" ]
23
23
  uri = "{{APP_URL}}/webhooks"
24
24
 
25
- # GDPR mandatory webhooks (handled automatically by Shopify via compliance endpoints)
25
+ # GDPR mandatory webhooks
26
26
  [webhooks.privacy_compliance]
27
27
  customer_deletion_url = "{{APP_URL}}/webhooks"
28
28
  customer_data_request_url = "{{APP_URL}}/webhooks"
@@ -0,0 +1,290 @@
1
+ import { Router, Request, Response } from "express";
2
+ import { verifySessionToken } from "./verify-token";
3
+ import { getAccessToken } from "./auth";
4
+ import { db } from "./firebase";
5
+
6
+ export 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: Request, res: Response) => {
25
+ const shop = (req as any).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()) as any;
62
+ res.json({ shop: data.data?.shop });
63
+ } catch (err: any) {
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: Request, res: Response) => {
73
+ const shop = (req as any).shopDomain;
74
+ const query = (req.query.q as string) || "";
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()) as any;
119
+ const products = (data.data?.products?.edges || []).map((edge: any) => ({
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: any) {
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: Request, res: Response) => {
139
+ const shop = (req as any).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()) as any;
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: any) {
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: Request, res: Response) => {
220
+ const shop = (req as any).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: any) {
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: Request, res: Response) => {
240
+ const shop = (req as any).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: Record<string, any> = {};
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: any) {
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 as any).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
+ // ──────────────────────────────────────────────────────────────────────────