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.
- package/README.md +122 -74
- package/lib/index.js +540 -238
- package/lib/preflight.js +358 -0
- package/lib/provision.js +187 -5
- package/package.json +1 -1
- package/templates/js/functions/package.json +6 -6
- package/templates/js/functions/src/admin-api.js +1 -1
- package/templates/js/functions/src/auth.js +45 -13
- package/templates/js/functions/src/firebase.js +6 -4
- package/templates/js/functions/src/index.js +14 -0
- package/templates/shared/extensions/theme-block/blocks/app-block.liquid +1 -1
- package/templates/shared/extensions/theme-block/shopify.extension.toml +1 -1
- package/templates/shared/firebase.json +1 -1
- package/templates/shared/firestore.rules +10 -2
- package/templates/shopify.app.toml +1 -1
- package/templates/ts/functions/package.json +10 -10
- package/templates/ts/functions/src/admin-api.ts +5 -2
- package/templates/ts/functions/src/auth.ts +69 -15
- package/templates/ts/functions/src/firebase.ts +8 -4
- package/templates/ts/functions/tsconfig.json +2 -1
- package/templates/web/css/app.css +78 -1138
- package/templates/web/index.html +79 -68
- package/templates/web/js/app.js +60 -33
- package/templates/web/js/pages/home.js +33 -22
- package/templates/web/js/pages/polaris-demo.js +25 -83
- package/templates/web/js/pages/products.js +100 -114
- package/templates/web/js/pages/settings.js +68 -166
- package/templates/web/polaris.html +1221 -950
- package/templates/web/products.html +35 -40
- package/templates/web/settings.html +69 -17
|
@@ -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
|
|
32
|
-
res.status(400).send("
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
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 =
|
|
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,
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
rules_version = '2';
|
|
2
2
|
service cloud.firestore {
|
|
3
3
|
match /databases/{database}/documents {
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": true,
|
|
4
4
|
"main": "lib/index.js",
|
|
5
5
|
"engines": {
|
|
6
|
-
"node": "
|
|
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.
|
|
20
|
-
"express": "^
|
|
21
|
-
"firebase-admin": "^
|
|
22
|
-
"firebase-functions": "^
|
|
23
|
-
"jsonwebtoken": "^9.0.
|
|
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.
|
|
27
|
-
"@types/express": "^
|
|
28
|
-
"@types/jsonwebtoken": "^9.0.
|
|
29
|
-
"typescript": "^
|
|
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-
|
|
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
|
-
|
|
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
|
|
33
|
-
res.status(400).send("
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
182
|
+
.doc(shop)
|
|
129
183
|
.set({
|
|
130
184
|
shop,
|
|
131
185
|
accessToken: tokenData.access_token,
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
7
|
-
|
|
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 =
|
|
14
|
+
export const db = getFirestore();
|