create-shopify-firebase-app 2.0.6 → 2.1.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.
@@ -2,6 +2,18 @@ const crypto = require("crypto");
2
2
  const { getConfig } = require("./config");
3
3
  const { db } = require("./firebase");
4
4
 
5
+ // Shop domains are always <handle>.myshopify.com. Anything else is rejected —
6
+ // the value ends up in a redirect Location header and in Firestore doc IDs,
7
+ // so an unvalidated `shop` is an open redirect.
8
+ const SHOP_DOMAIN_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
9
+
10
+ // The state nonce is 16 random bytes hex-encoded (see handleStart).
11
+ const NONCE_PATTERN = /^[a-f0-9]{32}$/;
12
+
13
+ function isValidShopDomain(shop) {
14
+ return typeof shop === "string" && SHOP_DOMAIN_PATTERN.test(shop);
15
+ }
16
+
5
17
  /**
6
18
  * Standalone OAuth handler — no Express, no middleware overhead.
7
19
  *
@@ -20,16 +32,16 @@ async function authHandler(req, res) {
20
32
  if (urlPath === "/auth/callback") {
21
33
  await handleCallback(req, res);
22
34
  } else {
23
- handleStart(req, res);
35
+ await handleStart(req, res);
24
36
  }
25
37
  }
26
38
 
27
39
  // ─── Step 1: Start OAuth ─────────────────────────────────────────────────
28
40
  // Merchant clicks "Install" -> redirect to Shopify consent screen.
29
- function handleStart(req, res) {
41
+ async function handleStart(req, res) {
30
42
  const { shop } = req.query;
31
- if (!shop || typeof shop !== "string") {
32
- res.status(400).send("Missing shop parameter");
43
+ if (!isValidShopDomain(shop)) {
44
+ res.status(400).send("Invalid shop parameter");
33
45
  return;
34
46
  }
35
47
 
@@ -37,11 +49,18 @@ function handleStart(req, res) {
37
49
  const nonce = crypto.randomBytes(16).toString("hex");
38
50
  const redirectUri = `${config.appUrl}/auth/callback`;
39
51
 
40
- // Store nonce for CSRF protection
41
- db.collection("authNonces").doc(nonce).set({
42
- shop,
43
- createdAt: new Date().toISOString(),
44
- });
52
+ // Store nonce for CSRF protection. Must be awaited — Cloud Functions may
53
+ // freeze the instance once the response is sent, dropping in-flight writes.
54
+ try {
55
+ await db.collection("authNonces").doc(nonce).set({
56
+ shop,
57
+ createdAt: new Date().toISOString(),
58
+ });
59
+ } catch (err) {
60
+ console.error("Failed to store auth nonce:", err);
61
+ res.status(500).send("Failed to start OAuth");
62
+ return;
63
+ }
45
64
 
46
65
  const authUrl =
47
66
  `https://${shop}/admin/oauth/authorize` +
@@ -63,6 +82,11 @@ async function handleCallback(req, res) {
63
82
  return;
64
83
  }
65
84
 
85
+ if (!isValidShopDomain(shop)) {
86
+ res.status(400).send("Invalid shop parameter");
87
+ return;
88
+ }
89
+
66
90
  const config = getConfig();
67
91
 
68
92
  // Verify HMAC (timing-safe comparison)
@@ -88,11 +112,19 @@ async function handleCallback(req, res) {
88
112
  return;
89
113
  }
90
114
 
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();
115
+ // Verify and consume the CSRF nonce. A missing or unknown state means this
116
+ // callback did not come from a flow we started, so it must be rejected.
117
+ if (typeof state !== "string" || !NONCE_PATTERN.test(state)) {
118
+ res.status(403).send("Missing or malformed state parameter");
119
+ return;
120
+ }
121
+
122
+ const nonceDoc = await db.collection("authNonces").doc(state).get();
123
+ if (!nonceDoc.exists || nonceDoc.data()?.shop !== shop) {
124
+ res.status(403).send("Invalid state parameter");
125
+ return;
95
126
  }
127
+ await nonceDoc.ref.delete();
96
128
 
97
129
  // Exchange code for access token
98
130
  try {
@@ -1,12 +1,14 @@
1
- const admin = require("firebase-admin");
1
+ const { initializeApp, getApps } = require("firebase-admin/app");
2
+ const { getFirestore } = require("firebase-admin/firestore");
2
3
 
3
4
  // Initialize Firebase Admin SDK once.
4
5
  // Credentials are auto-detected on Firebase infrastructure.
5
6
  // For local dev, use `firebase emulators:start`.
6
- if (!admin.apps.length) {
7
- admin.initializeApp();
7
+ // firebase-admin v14 is modular-only — the old `admin.*` namespace was removed.
8
+ if (!getApps().length) {
9
+ initializeApp();
8
10
  }
9
11
 
10
- const db = admin.firestore();
12
+ const db = getFirestore();
11
13
 
12
14
  module.exports = { db };
@@ -47,6 +47,14 @@ apiApp.use(cors({ origin: true }));
47
47
  apiApp.use(express.json());
48
48
  apiApp.use("/api", adminApiRouter);
49
49
 
50
+ // Express 5 forwards rejected promises from async handlers here, so one
51
+ // error middleware covers every route. Must keep all four arguments.
52
+ // eslint-disable-next-line no-unused-vars
53
+ apiApp.use((err, req, res, next) => {
54
+ console.error("Unhandled API error:", err);
55
+ res.status(500).json({ error: "Internal server error" });
56
+ });
57
+
50
58
  exports.api = onRequest(
51
59
  { memory: "256MiB", timeoutSeconds: 60, invoker: "public" },
52
60
  apiApp,
@@ -68,6 +76,12 @@ proxyApp.use(cors({ origin: true }));
68
76
  proxyApp.use(express.json());
69
77
  proxyApp.use("/proxy", proxyRouter);
70
78
 
79
+ // eslint-disable-next-line no-unused-vars
80
+ proxyApp.use((err, req, res, next) => {
81
+ console.error("Unhandled proxy error:", err);
82
+ res.status(500).json({ error: "Internal server error" });
83
+ });
84
+
71
85
  exports.proxy = onRequest(
72
86
  { memory: "256MiB", timeoutSeconds: 30, invoker: "public" },
73
87
  proxyApp,
@@ -27,7 +27,7 @@
27
27
  {% endif %}
28
28
  </div>
29
29
 
30
- {{ 'app-block.js' | asset_url | script_tag }}
30
+ <script src="{{ 'app-block.js' | asset_url }}" defer></script>
31
31
 
32
32
  {% schema %}
33
33
  {
@@ -1,4 +1,4 @@
1
- api_version = "2025-04"
1
+ api_version = "2026-07"
2
2
 
3
3
  [[extensions]]
4
4
  name = "App Block"
@@ -18,7 +18,7 @@
18
18
  {
19
19
  "source": "functions",
20
20
  "codebase": "default",
21
- "runtime": "nodejs20"
21
+ "runtime": "nodejs22"
22
22
  }
23
23
  ],
24
24
  "firestore": {
@@ -1,8 +1,16 @@
1
1
  rules_version = '2';
2
2
  service cloud.firestore {
3
3
  match /databases/{database}/documents {
4
- // All access goes through Cloud Functions (admin SDK).
5
- // No direct client-side access the backend is the single trusted entry point.
4
+ // Deny-all by default. Every read/write goes through Cloud Functions using
5
+ // the firebase-admin SDK, which bypasses these rules entirely so denying
6
+ // all client access costs the app nothing and is the safest posture.
7
+ //
8
+ // This matters: `shopSessions` holds Shopify offline access tokens and
9
+ // `authNonces` holds OAuth state nonces. Either one leaking to a browser
10
+ // client would let an attacker impersonate the app against a merchant's
11
+ // shop or forge an OAuth callback. Do not add per-collection `allow` rules
12
+ // here — if the frontend needs data, expose it through an authenticated
13
+ // Cloud Functions endpoint instead.
6
14
  match /{document=**} {
7
15
  allow read, write: if false;
8
16
  }
@@ -15,7 +15,7 @@ redirect_urls = [
15
15
  ]
16
16
 
17
17
  [webhooks]
18
- api_version = "2026-01"
18
+ api_version = "2026-07"
19
19
 
20
20
  # App lifecycle
21
21
  [[webhooks.subscriptions]]
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "main": "lib/index.js",
5
5
  "engines": {
6
- "node": "20"
6
+ "node": "22"
7
7
  },
8
8
  "scripts": {
9
9
  "build": "tsc",
@@ -16,16 +16,16 @@
16
16
  "deploy:all": "firebase deploy"
17
17
  },
18
18
  "dependencies": {
19
- "cors": "^2.8.5",
20
- "express": "^4.21.0",
21
- "firebase-admin": "^13.0.0",
22
- "firebase-functions": "^6.3.0",
23
- "jsonwebtoken": "^9.0.2"
19
+ "cors": "^2.8.6",
20
+ "express": "^5.2.1",
21
+ "firebase-admin": "^14.2.0",
22
+ "firebase-functions": "^7.3.2",
23
+ "jsonwebtoken": "^9.0.3"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/cors": "^2.8.17",
27
- "@types/express": "^4.17.21",
28
- "@types/jsonwebtoken": "^9.0.7",
29
- "typescript": "^5.6.3"
26
+ "@types/cors": "^2.8.19",
27
+ "@types/express": "^5.0.6",
28
+ "@types/jsonwebtoken": "^9.0.10",
29
+ "typescript": "^7.0.2"
30
30
  }
31
31
  }
@@ -9,8 +9,9 @@ export const adminApiRouter = Router();
9
9
  adminApiRouter.use(verifySessionToken);
10
10
 
11
11
  // Shopify API version — update when Shopify releases new versions
12
+ // (quarterly: January, April, July, October)
12
13
  // Docs: https://shopify.dev/docs/api/usage/versioning
13
- const API_VERSION = "2026-01";
14
+ const API_VERSION = "2026-07";
14
15
 
15
16
  // Default app settings — returned when no settings are saved yet
16
17
  const DEFAULT_SETTINGS = {
@@ -145,7 +146,9 @@ adminApiRouter.get("/products/:id", async (req: Request, res: Response) => {
145
146
  return;
146
147
  }
147
148
 
148
- const productId = req.params.id;
149
+ // Express 5 types params as string | string[] (repeatable params);
150
+ // a single ":id" segment is always a string.
151
+ const productId = req.params.id as string;
149
152
  // Support both raw numeric IDs and full GID format
150
153
  const gid = productId.startsWith("gid://")
151
154
  ? productId
@@ -1,8 +1,22 @@
1
1
  import crypto from "crypto";
2
2
  import { getConfig } from "./config";
3
3
  import { db } from "./firebase";
4
+ import { Timestamp } from "firebase-admin/firestore";
4
5
  import type { Request } from "firebase-functions/v2/https";
5
6
 
7
+ // ─── Shop domain validation ──────────────────────────────────────────────
8
+ // The shop param is attacker-controlled and ends up in a redirect Location
9
+ // and in outbound API URLs. Shopify shop domains are always
10
+ // "<store-handle>.myshopify.com" — reject anything else outright.
11
+ const SHOP_DOMAIN_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
12
+
13
+ function isValidShopDomain(shop: unknown): shop is string {
14
+ return typeof shop === "string" && SHOP_DOMAIN_PATTERN.test(shop);
15
+ }
16
+
17
+ // OAuth state nonces are single-use and short-lived.
18
+ const NONCE_TTL_MS = 10 * 60 * 1000; // 10 minutes
19
+
6
20
  /**
7
21
  * Standalone OAuth handler — no Express, no middleware overhead.
8
22
  *
@@ -21,16 +35,16 @@ export async function authHandler(req: Request, res: any): Promise<void> {
21
35
  if (urlPath === "/auth/callback") {
22
36
  await handleCallback(req, res);
23
37
  } else {
24
- handleStart(req, res);
38
+ await handleStart(req, res);
25
39
  }
26
40
  }
27
41
 
28
42
  // ─── Step 1: Start OAuth ─────────────────────────────────────────────────
29
43
  // Merchant clicks "Install" → redirect to Shopify consent screen.
30
- function handleStart(req: Request, res: any): void {
44
+ async function handleStart(req: Request, res: any): Promise<void> {
31
45
  const { shop } = req.query;
32
- if (!shop || typeof shop !== "string") {
33
- res.status(400).send("Missing shop parameter");
46
+ if (!isValidShopDomain(shop)) {
47
+ res.status(400).send("Invalid or missing shop parameter");
34
48
  return;
35
49
  }
36
50
 
@@ -38,11 +52,19 @@ function handleStart(req: Request, res: any): void {
38
52
  const nonce = crypto.randomBytes(16).toString("hex");
39
53
  const redirectUri = `${config.appUrl}/auth/callback`;
40
54
 
41
- // Store nonce for CSRF protection
42
- db.collection("authNonces").doc(nonce).set({
43
- shop,
44
- createdAt: new Date().toISOString(),
45
- });
55
+ // Store nonce for CSRF protection. This MUST be awaited — the function
56
+ // instance can be frozen the moment we respond, so a pending write may
57
+ // never land and the callback would have nothing to verify against.
58
+ // expiresAt is a Timestamp so you can attach a Firestore TTL policy to
59
+ // this collection and have stale nonces purged automatically.
60
+ await db
61
+ .collection("authNonces")
62
+ .doc(nonce)
63
+ .set({
64
+ shop,
65
+ createdAt: new Date().toISOString(),
66
+ expiresAt: Timestamp.fromMillis(Date.now() + NONCE_TTL_MS),
67
+ });
46
68
 
47
69
  const authUrl =
48
70
  `https://${shop}/admin/oauth/authorize` +
@@ -59,7 +81,12 @@ function handleStart(req: Request, res: any): void {
59
81
  async function handleCallback(req: Request, res: any): Promise<void> {
60
82
  const { shop, code, hmac, state } = req.query;
61
83
 
62
- if (!shop || !code || !hmac) {
84
+ if (!isValidShopDomain(shop)) {
85
+ res.status(400).send("Invalid or missing shop parameter");
86
+ return;
87
+ }
88
+
89
+ if (!code || !hmac) {
63
90
  res.status(400).send("Missing required parameters");
64
91
  return;
65
92
  }
@@ -89,10 +116,37 @@ async function handleCallback(req: Request, res: any): Promise<void> {
89
116
  return;
90
117
  }
91
118
 
92
- // Clean up nonce
93
- if (state) {
94
- const nonceDoc = await db.collection("authNonces").doc(state as string).get();
95
- if (nonceDoc.exists) await nonceDoc.ref.delete();
119
+ // ─── Verify state nonce (CSRF) ─────────────────────────────────────────
120
+ // A callback we did not initiate has no matching nonce, so an absent or
121
+ // unknown state must be rejected — not merely skipped.
122
+ if (!state || typeof state !== "string") {
123
+ res.status(403).send("Missing state parameter");
124
+ return;
125
+ }
126
+
127
+ const nonceRef = db.collection("authNonces").doc(state);
128
+ const nonceDoc = await nonceRef.get();
129
+ if (!nonceDoc.exists) {
130
+ res.status(403).send("Invalid state parameter");
131
+ return;
132
+ }
133
+
134
+ // Burn the nonce before validating it so it can never be replayed,
135
+ // whatever the outcome below.
136
+ await nonceRef.delete();
137
+
138
+ const nonceData = nonceDoc.data() ?? {};
139
+ const expiresAt = nonceData.expiresAt as Timestamp | undefined;
140
+
141
+ // The nonce must still be fresh AND belong to the shop calling back,
142
+ // otherwise a nonce issued for one store could authorize another.
143
+ if (!expiresAt || expiresAt.toMillis() < Date.now()) {
144
+ res.status(403).send("Expired state parameter");
145
+ return;
146
+ }
147
+ if (nonceData.shop !== shop) {
148
+ res.status(403).send("State parameter does not match shop");
149
+ return;
96
150
  }
97
151
 
98
152
  // Exchange code for access token
@@ -125,7 +179,7 @@ async function handleCallback(req: Request, res: any): Promise<void> {
125
179
  // Store session in Firestore
126
180
  await db
127
181
  .collection("shopSessions")
128
- .doc(shop as string)
182
+ .doc(shop)
129
183
  .set({
130
184
  shop,
131
185
  accessToken: tokenData.access_token,
@@ -1,10 +1,14 @@
1
- import * as admin from "firebase-admin";
1
+ import { getApps, initializeApp } from "firebase-admin/app";
2
+ import { getFirestore } from "firebase-admin/firestore";
2
3
 
3
4
  // Initialize Firebase Admin SDK once.
4
5
  // Credentials are auto-detected on Firebase infrastructure.
5
6
  // For local dev, use `firebase emulators:start`.
6
- if (!admin.apps.length) {
7
- admin.initializeApp();
7
+ //
8
+ // firebase-admin v14 removed the legacy `admin.*` namespace — use the
9
+ // modular entry points (firebase-admin/app, firebase-admin/firestore).
10
+ if (!getApps().length) {
11
+ initializeApp();
8
12
  }
9
13
 
10
- export const db = admin.firestore();
14
+ export const db = getFirestore();
@@ -4,9 +4,10 @@
4
4
  "noImplicitReturns": true,
5
5
  "noUnusedLocals": false,
6
6
  "outDir": "lib",
7
+ "rootDir": "src",
7
8
  "sourceMap": true,
8
9
  "strict": true,
9
- "target": "es2020",
10
+ "target": "es2023",
10
11
  "esModuleInterop": true,
11
12
  "resolveJsonModule": true
12
13
  },