create-shopify-firebase-app 1.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.
- package/LICENSE +21 -0
- package/README.md +404 -0
- package/bin/create.js +17 -0
- package/lib/index.js +488 -0
- package/lib/provision.js +390 -0
- package/package.json +51 -0
- package/templates/env.example +5 -0
- package/templates/extensions/theme-block/assets/app-block.css +11 -0
- package/templates/extensions/theme-block/assets/app-block.js +22 -0
- package/templates/extensions/theme-block/blocks/app-block.liquid +69 -0
- package/templates/extensions/theme-block/locales/en.default.json +10 -0
- package/templates/extensions/theme-block/shopify.extension.toml +10 -0
- package/templates/firebase.json +24 -0
- package/templates/firestore.indexes.json +4 -0
- package/templates/firestore.rules +10 -0
- package/templates/functions/package.json +29 -0
- package/templates/functions/src/admin-api.ts +116 -0
- package/templates/functions/src/auth.ts +130 -0
- package/templates/functions/src/config.ts +12 -0
- package/templates/functions/src/firebase.ts +10 -0
- package/templates/functions/src/index.ts +40 -0
- package/templates/functions/src/proxy.ts +50 -0
- package/templates/functions/src/verify-token.ts +55 -0
- package/templates/functions/src/webhooks.ts +77 -0
- package/templates/functions/tsconfig.json +15 -0
- package/templates/gitignore +33 -0
- package/templates/shopify.app.toml +38 -0
- package/templates/web/css/app.css +57 -0
- package/templates/web/index.html +64 -0
- package/templates/web/js/bridge.js +98 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import fetch from "node-fetch";
|
|
3
|
+
import { verifySessionToken } from "./verify-token";
|
|
4
|
+
import { getAccessToken } from "./auth";
|
|
5
|
+
|
|
6
|
+
export const adminApiRouter = Router();
|
|
7
|
+
|
|
8
|
+
// All admin routes require session token verification
|
|
9
|
+
adminApiRouter.use(verifySessionToken);
|
|
10
|
+
|
|
11
|
+
// ─── Get shop info ───────────────────────────────────────────────────────
|
|
12
|
+
adminApiRouter.get("/shop", async (req: Request, res: Response) => {
|
|
13
|
+
const shop = (req as any).shopDomain;
|
|
14
|
+
const accessToken = await getAccessToken(shop);
|
|
15
|
+
|
|
16
|
+
if (!accessToken) {
|
|
17
|
+
res.status(401).json({ error: "Shop not authenticated" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(
|
|
23
|
+
`https://${shop}/admin/api/2025-04/graphql.json`,
|
|
24
|
+
{
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"X-Shopify-Access-Token": accessToken,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
query: `{ shop { name email myshopifyDomain plan { displayName } } }`,
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const data = (await response.json()) as any;
|
|
37
|
+
res.json({ shop: data.data?.shop });
|
|
38
|
+
} catch (err: any) {
|
|
39
|
+
console.error("Shop info error:", err);
|
|
40
|
+
res.status(500).json({ error: err.message });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─── Search products (example) ───────────────────────────────────────────
|
|
45
|
+
adminApiRouter.get("/products/search", async (req: Request, res: Response) => {
|
|
46
|
+
const shop = (req as any).shopDomain;
|
|
47
|
+
const query = (req.query.q as string) || "";
|
|
48
|
+
const accessToken = await getAccessToken(shop);
|
|
49
|
+
|
|
50
|
+
if (!accessToken) {
|
|
51
|
+
res.status(401).json({ error: "Shop not authenticated" });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(
|
|
57
|
+
`https://${shop}/admin/api/2025-04/graphql.json`,
|
|
58
|
+
{
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"X-Shopify-Access-Token": accessToken,
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
query: `
|
|
66
|
+
query SearchProducts($query: String!) {
|
|
67
|
+
products(first: 10, query: $query) {
|
|
68
|
+
edges {
|
|
69
|
+
node {
|
|
70
|
+
id
|
|
71
|
+
title
|
|
72
|
+
handle
|
|
73
|
+
status
|
|
74
|
+
featuredImage { url }
|
|
75
|
+
variants(first: 1) {
|
|
76
|
+
edges { node { id price } }
|
|
77
|
+
}
|
|
78
|
+
priceRangeV2 {
|
|
79
|
+
minVariantPrice { amount currencyCode }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`,
|
|
86
|
+
variables: { query },
|
|
87
|
+
}),
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const data = (await response.json()) as any;
|
|
92
|
+
const products = (data.data?.products?.edges || []).map((edge: any) => ({
|
|
93
|
+
id: edge.node.id,
|
|
94
|
+
title: edge.node.title,
|
|
95
|
+
handle: edge.node.handle,
|
|
96
|
+
status: edge.node.status,
|
|
97
|
+
image: edge.node.featuredImage?.url || null,
|
|
98
|
+
variantId: edge.node.variants.edges[0]?.node.id || null,
|
|
99
|
+
price: edge.node.priceRangeV2?.minVariantPrice?.amount,
|
|
100
|
+
currency: edge.node.priceRangeV2?.minVariantPrice?.currencyCode,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
res.json({ products });
|
|
104
|
+
} catch (err: any) {
|
|
105
|
+
console.error("Product search error:", err);
|
|
106
|
+
res.status(500).json({ error: err.message });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// Add your admin API routes below.
|
|
112
|
+
// All routes are protected by session token verification.
|
|
113
|
+
//
|
|
114
|
+
// const shop = (req as any).shopDomain;
|
|
115
|
+
// const accessToken = await getAccessToken(shop);
|
|
116
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import fetch from "node-fetch";
|
|
4
|
+
import { getConfig } from "./config";
|
|
5
|
+
import { db } from "./firebase";
|
|
6
|
+
|
|
7
|
+
export const authRouter = Router();
|
|
8
|
+
|
|
9
|
+
// ─── Step 1: Start OAuth ─────────────────────────────────────────────────
|
|
10
|
+
// Merchant clicks "Install" → redirect to Shopify consent screen.
|
|
11
|
+
authRouter.get("/", (req: Request, res: Response) => {
|
|
12
|
+
const { shop } = req.query;
|
|
13
|
+
if (!shop || typeof shop !== "string") {
|
|
14
|
+
res.status(400).send("Missing shop parameter");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const config = getConfig();
|
|
19
|
+
const nonce = crypto.randomBytes(16).toString("hex");
|
|
20
|
+
const redirectUri = `${config.appUrl}/auth/callback`;
|
|
21
|
+
|
|
22
|
+
// Store nonce for CSRF protection
|
|
23
|
+
db.collection("authNonces").doc(nonce).set({
|
|
24
|
+
shop,
|
|
25
|
+
createdAt: new Date().toISOString(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const authUrl =
|
|
29
|
+
`https://${shop}/admin/oauth/authorize` +
|
|
30
|
+
`?client_id=${config.apiKey}` +
|
|
31
|
+
`&scope=${config.scopes}` +
|
|
32
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
|
33
|
+
`&state=${nonce}`;
|
|
34
|
+
|
|
35
|
+
res.redirect(authUrl);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── Step 2: OAuth Callback ──────────────────────────────────────────────
|
|
39
|
+
// Shopify redirects back with code + HMAC. Verify, exchange, store session.
|
|
40
|
+
authRouter.get("/callback", async (req: Request, res: Response) => {
|
|
41
|
+
const { shop, code, hmac, state } = req.query;
|
|
42
|
+
|
|
43
|
+
if (!shop || !code || !hmac) {
|
|
44
|
+
res.status(400).send("Missing required parameters");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const config = getConfig();
|
|
49
|
+
|
|
50
|
+
// Verify HMAC (primary security check — timing-safe)
|
|
51
|
+
const queryParams = { ...req.query };
|
|
52
|
+
delete queryParams.hmac;
|
|
53
|
+
delete queryParams.signature;
|
|
54
|
+
const message = Object.keys(queryParams)
|
|
55
|
+
.sort()
|
|
56
|
+
.map((key) => `${key}=${queryParams[key]}`)
|
|
57
|
+
.join("&");
|
|
58
|
+
const generatedHmac = crypto
|
|
59
|
+
.createHmac("sha256", config.apiSecret)
|
|
60
|
+
.update(message)
|
|
61
|
+
.digest("hex");
|
|
62
|
+
|
|
63
|
+
const hmacBuffer = Buffer.from(hmac as string);
|
|
64
|
+
const generatedBuffer = Buffer.from(generatedHmac);
|
|
65
|
+
if (
|
|
66
|
+
hmacBuffer.length !== generatedBuffer.length ||
|
|
67
|
+
!crypto.timingSafeEqual(generatedBuffer, hmacBuffer)
|
|
68
|
+
) {
|
|
69
|
+
res.status(403).send("HMAC verification failed");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Clean up nonce
|
|
74
|
+
if (state) {
|
|
75
|
+
const nonceDoc = await db.collection("authNonces").doc(state as string).get();
|
|
76
|
+
if (nonceDoc.exists) await nonceDoc.ref.delete();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Exchange code for access token
|
|
80
|
+
try {
|
|
81
|
+
const tokenResponse = await fetch(
|
|
82
|
+
`https://${shop}/admin/oauth/access_token`,
|
|
83
|
+
{
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
client_id: config.apiKey,
|
|
88
|
+
client_secret: config.apiSecret,
|
|
89
|
+
code,
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!tokenResponse.ok) {
|
|
95
|
+
const errorText = await tokenResponse.text();
|
|
96
|
+
console.error("Token exchange failed:", errorText);
|
|
97
|
+
res.status(500).send("Token exchange failed");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tokenData = (await tokenResponse.json()) as {
|
|
102
|
+
access_token: string;
|
|
103
|
+
scope: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Store session in Firestore
|
|
107
|
+
await db
|
|
108
|
+
.collection("shopSessions")
|
|
109
|
+
.doc(shop as string)
|
|
110
|
+
.set({
|
|
111
|
+
shop,
|
|
112
|
+
accessToken: tokenData.access_token,
|
|
113
|
+
scope: tokenData.scope,
|
|
114
|
+
installedAt: new Date().toISOString(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
console.log(`App installed for shop: ${shop}`);
|
|
118
|
+
res.redirect(`https://${shop}/admin/apps/${config.apiKey}`);
|
|
119
|
+
} catch (err: any) {
|
|
120
|
+
console.error("OAuth error:", err);
|
|
121
|
+
res.status(500).send("OAuth error");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Helper: get stored access token for a shop
|
|
126
|
+
export async function getAccessToken(shop: string): Promise<string | null> {
|
|
127
|
+
const doc = await db.collection("shopSessions").doc(shop).get();
|
|
128
|
+
if (!doc.exists) return null;
|
|
129
|
+
return doc.data()?.accessToken || null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
export 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
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as admin from "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
|
+
export const db = admin.firestore();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as functions from "firebase-functions";
|
|
2
|
+
import "./firebase"; // Initialize Firebase first — must be before other imports
|
|
3
|
+
import express from "express";
|
|
4
|
+
import cors from "cors";
|
|
5
|
+
import { authRouter } from "./auth";
|
|
6
|
+
import { adminApiRouter } from "./admin-api";
|
|
7
|
+
import { proxyRouter } from "./proxy";
|
|
8
|
+
import { webhookRouter } from "./webhooks";
|
|
9
|
+
|
|
10
|
+
const expressApp = express();
|
|
11
|
+
expressApp.use(cors({ origin: true }));
|
|
12
|
+
|
|
13
|
+
// Capture raw body for webhook HMAC verification.
|
|
14
|
+
// Shopify signs the raw request body — we need it before JSON parsing.
|
|
15
|
+
expressApp.use(
|
|
16
|
+
express.json({
|
|
17
|
+
limit: "2mb",
|
|
18
|
+
verify: (req: any, _res, buf) => {
|
|
19
|
+
req.rawBody = buf.toString("utf8");
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
expressApp.use(express.urlencoded({ extended: true }));
|
|
24
|
+
|
|
25
|
+
// ─── Routes ────────────────────────────────────────────────────────────────
|
|
26
|
+
expressApp.use("/auth", authRouter); // OAuth install + callback
|
|
27
|
+
expressApp.use("/api", adminApiRouter); // Admin dashboard API (JWT auth)
|
|
28
|
+
expressApp.use("/proxy", proxyRouter); // App Proxy routes (HMAC auth)
|
|
29
|
+
expressApp.use("/webhooks", webhookRouter); // Webhook handlers
|
|
30
|
+
|
|
31
|
+
// Health check
|
|
32
|
+
expressApp.get("/", (_req, res) => {
|
|
33
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Export as a single Cloud Function.
|
|
37
|
+
// Firebase Hosting rewrites (firebase.json) forward requests here.
|
|
38
|
+
export const app = functions
|
|
39
|
+
.runWith({ timeoutSeconds: 60, memory: "256MB" })
|
|
40
|
+
.https.onRequest(expressApp);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { getConfig } from "./config";
|
|
4
|
+
|
|
5
|
+
export 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: Record<string, any>): boolean {
|
|
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: Request, res: Response) => {
|
|
37
|
+
if (!verifyProxySignature(req.query as Record<string, any>)) {
|
|
38
|
+
res.status(403).json({ error: "Invalid signature" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const shop = req.query.shop as string;
|
|
43
|
+
res.json({ message: `Hello from the app proxy! Shop: ${shop}` });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// Add storefront-facing routes below.
|
|
48
|
+
// Always verify the proxy signature first.
|
|
49
|
+
// Enable App Proxy in shopify.app.toml.
|
|
50
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import jwt from "jsonwebtoken";
|
|
3
|
+
import { getConfig } from "./config";
|
|
4
|
+
|
|
5
|
+
// App Bridge session token payload
|
|
6
|
+
// Docs: https://shopify.dev/docs/apps/build/authentication/session-tokens
|
|
7
|
+
interface SessionTokenPayload {
|
|
8
|
+
iss: string; // https://{shop}.myshopify.com/admin
|
|
9
|
+
dest: string; // https://{shop}.myshopify.com
|
|
10
|
+
aud: string; // API key
|
|
11
|
+
sub: string; // User ID
|
|
12
|
+
exp: number;
|
|
13
|
+
nbf: number;
|
|
14
|
+
iat: number;
|
|
15
|
+
jti: string;
|
|
16
|
+
sid: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Middleware: verify App Bridge session token.
|
|
20
|
+
// The embedded dashboard sends Authorization: Bearer <jwt> with every request.
|
|
21
|
+
export function verifySessionToken(
|
|
22
|
+
req: Request,
|
|
23
|
+
res: Response,
|
|
24
|
+
next: NextFunction,
|
|
25
|
+
): void {
|
|
26
|
+
const authHeader = req.headers.authorization;
|
|
27
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
28
|
+
res.status(401).json({ error: "Missing Authorization header" });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const token = authHeader.split(" ")[1];
|
|
33
|
+
const config = getConfig();
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const decoded = jwt.verify(token, config.apiSecret, {
|
|
37
|
+
algorithms: ["HS256"],
|
|
38
|
+
}) as SessionTokenPayload;
|
|
39
|
+
|
|
40
|
+
if (decoded.aud !== config.apiKey) {
|
|
41
|
+
res.status(403).json({ error: "Token audience mismatch" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Extract shop from issuer URL
|
|
46
|
+
const issUrl = new URL(decoded.iss);
|
|
47
|
+
(req as any).shopDomain = issUrl.hostname;
|
|
48
|
+
(req as any).sessionToken = decoded;
|
|
49
|
+
|
|
50
|
+
next();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("Session token verification failed:", err);
|
|
53
|
+
res.status(401).json({ error: "Invalid session token" });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Router, Request, Response } from "express";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { getConfig } from "./config";
|
|
4
|
+
import { db } from "./firebase";
|
|
5
|
+
|
|
6
|
+
export const webhookRouter = Router();
|
|
7
|
+
|
|
8
|
+
// ─── Verify Shopify Webhook HMAC ─────────────────────────────────────────
|
|
9
|
+
function verifyWebhookHmac(rawBody: string, hmacHeader: string): boolean {
|
|
10
|
+
const config = getConfig();
|
|
11
|
+
const hash = crypto
|
|
12
|
+
.createHmac("sha256", config.apiSecret)
|
|
13
|
+
.update(rawBody, "utf8")
|
|
14
|
+
.digest("base64");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hmacHeader));
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Webhook Handler ─────────────────────────────────────────────────────
|
|
24
|
+
// All topics route to this single POST endpoint.
|
|
25
|
+
// Topic is identified via X-Shopify-Topic header.
|
|
26
|
+
// IMPORTANT: Respond 200 within 5 seconds. Do heavy work async.
|
|
27
|
+
webhookRouter.post("/", async (req: Request, res: Response) => {
|
|
28
|
+
const hmac = req.headers["x-shopify-hmac-sha256"] as string;
|
|
29
|
+
const topic = req.headers["x-shopify-topic"] as string;
|
|
30
|
+
const shop = req.headers["x-shopify-shop-domain"] as string;
|
|
31
|
+
|
|
32
|
+
// Verify HMAC
|
|
33
|
+
const rawBody = (req as any).rawBody;
|
|
34
|
+
if (rawBody && hmac && !verifyWebhookHmac(rawBody, hmac)) {
|
|
35
|
+
console.error("Webhook HMAC verification failed");
|
|
36
|
+
res.status(401).send("Unauthorized");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(`Webhook: ${topic} from ${shop}`);
|
|
41
|
+
|
|
42
|
+
switch (topic) {
|
|
43
|
+
// ── App lifecycle ──────────────────────────────────────────────────
|
|
44
|
+
case "app/uninstalled": {
|
|
45
|
+
await db.collection("shopSessions").doc(shop).delete();
|
|
46
|
+
console.log(`Session cleaned up for ${shop}`);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── GDPR mandatory webhooks (required for App Store) ───────────────
|
|
51
|
+
case "customers/data_request": {
|
|
52
|
+
// Customer requested their data. Export within 30 days if you store any.
|
|
53
|
+
console.log(`Customer data request: ${shop}`);
|
|
54
|
+
// TODO: implement if you store customer data
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case "customers/redact": {
|
|
59
|
+
// Customer requested deletion. Delete within 30 days.
|
|
60
|
+
console.log(`Customer redact: ${shop}`);
|
|
61
|
+
// TODO: implement if you store customer data
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "shop/redact": {
|
|
66
|
+
// 48h after uninstall. Delete ALL shop data.
|
|
67
|
+
console.log(`Shop redact: ${shop}`);
|
|
68
|
+
// TODO: delete all data for this shop from Firestore
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
default:
|
|
73
|
+
console.log(`Unhandled webhook: ${topic}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.status(200).send("OK");
|
|
77
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"noImplicitReturns": true,
|
|
5
|
+
"noUnusedLocals": false,
|
|
6
|
+
"outDir": "lib",
|
|
7
|
+
"sourceMap": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"target": "es2020",
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"resolveJsonModule": true
|
|
12
|
+
},
|
|
13
|
+
"compileOnSave": true,
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Firebase
|
|
5
|
+
.firebase/
|
|
6
|
+
.firebaserc
|
|
7
|
+
firebase-debug.log
|
|
8
|
+
firestore-debug.log
|
|
9
|
+
ui-debug.log
|
|
10
|
+
|
|
11
|
+
# TypeScript build
|
|
12
|
+
functions/lib/
|
|
13
|
+
|
|
14
|
+
# Secrets
|
|
15
|
+
.env
|
|
16
|
+
functions/.env
|
|
17
|
+
.runtimeconfig.json
|
|
18
|
+
functions/.runtimeconfig.json
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.vscode/
|
|
22
|
+
.idea/
|
|
23
|
+
*.swp
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
|
28
|
+
|
|
29
|
+
# Shopify CLI
|
|
30
|
+
.shopify/
|
|
31
|
+
|
|
32
|
+
# Claude Code
|
|
33
|
+
.claude/
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Shopify App Configuration
|
|
2
|
+
# Docs: https://shopify.dev/docs/apps/tools/cli/configuration
|
|
3
|
+
|
|
4
|
+
name = "{{APP_NAME}}"
|
|
5
|
+
client_id = "{{API_KEY}}"
|
|
6
|
+
application_url = "{{APP_URL}}"
|
|
7
|
+
embedded = true
|
|
8
|
+
|
|
9
|
+
[access_scopes]
|
|
10
|
+
scopes = "{{SCOPES}}"
|
|
11
|
+
|
|
12
|
+
[auth]
|
|
13
|
+
redirect_urls = [
|
|
14
|
+
"{{APP_URL}}/auth/callback"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[webhooks]
|
|
18
|
+
api_version = "2025-04"
|
|
19
|
+
|
|
20
|
+
# App lifecycle
|
|
21
|
+
[[webhooks.subscriptions]]
|
|
22
|
+
topics = [ "app/uninstalled" ]
|
|
23
|
+
uri = "{{APP_URL}}/webhooks"
|
|
24
|
+
|
|
25
|
+
# GDPR mandatory webhooks (handled automatically by Shopify via compliance endpoints)
|
|
26
|
+
[webhooks.privacy_compliance]
|
|
27
|
+
customer_deletion_url = "{{APP_URL}}/webhooks"
|
|
28
|
+
customer_data_request_url = "{{APP_URL}}/webhooks"
|
|
29
|
+
shop_deletion_url = "{{APP_URL}}/webhooks"
|
|
30
|
+
|
|
31
|
+
# Uncomment to enable App Proxy (storefront-facing routes)
|
|
32
|
+
# [app_proxy]
|
|
33
|
+
# url = "{{APP_URL}}/proxy"
|
|
34
|
+
# subpath = "myapp"
|
|
35
|
+
# prefix = "apps"
|
|
36
|
+
|
|
37
|
+
[pos]
|
|
38
|
+
embedded = false
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* Shopify-like admin styles for embedded app dashboard */
|
|
2
|
+
|
|
3
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
4
|
+
|
|
5
|
+
body {
|
|
6
|
+
font-family: -apple-system, BlinkMacSystemFont, "San Francisco", "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
7
|
+
font-size: 14px; line-height: 1.5; color: #1a1a1a; background: #f6f6f7;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.app-page { max-width: 960px; margin: 0 auto; padding: 20px 16px; }
|
|
11
|
+
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
|
12
|
+
.page-header h1 { font-size: 20px; font-weight: 600; }
|
|
13
|
+
|
|
14
|
+
.card {
|
|
15
|
+
background: #fff; border: 1px solid #e1e3e5; border-radius: 12px;
|
|
16
|
+
padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
17
|
+
}
|
|
18
|
+
.card h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
|
19
|
+
|
|
20
|
+
.btn {
|
|
21
|
+
display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;
|
|
22
|
+
border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer;
|
|
23
|
+
border: 1px solid #c9cccf; background: #fff; color: #1a1a1a;
|
|
24
|
+
transition: background 0.15s;
|
|
25
|
+
}
|
|
26
|
+
.btn:hover { background: #f6f6f7; }
|
|
27
|
+
.btn-primary { background: #008060; color: #fff; border-color: #008060; }
|
|
28
|
+
.btn-primary:hover { background: #006e52; }
|
|
29
|
+
.btn-danger { color: #d72c0d; border-color: #d72c0d; }
|
|
30
|
+
.btn-danger:hover { background: #fff4f4; }
|
|
31
|
+
|
|
32
|
+
.form-group { margin-bottom: 16px; }
|
|
33
|
+
.form-group label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 4px; color: #6d7175; }
|
|
34
|
+
.form-group input, .form-group select, .form-group textarea {
|
|
35
|
+
width: 100%; padding: 8px 12px; border: 1px solid #c9cccf; border-radius: 8px; font-size: 14px;
|
|
36
|
+
}
|
|
37
|
+
.form-group input:focus, .form-group select:focus { outline: none; border-color: #008060; box-shadow: 0 0 0 1px #008060; }
|
|
38
|
+
|
|
39
|
+
.empty-state { text-align: center; padding: 40px 20px; }
|
|
40
|
+
.empty-state h3 { font-size: 16px; margin-bottom: 8px; }
|
|
41
|
+
.empty-state p { color: #6d7175; margin-bottom: 16px; }
|
|
42
|
+
|
|
43
|
+
.loading-overlay { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 20px; color: #6d7175; }
|
|
44
|
+
.spinner { width: 20px; height: 20px; border: 2px solid #e1e3e5; border-top-color: #008060; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
|
45
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
46
|
+
|
|
47
|
+
.toast {
|
|
48
|
+
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(100px);
|
|
49
|
+
background: #1a1a1a; color: #fff; padding: 10px 20px; border-radius: 8px;
|
|
50
|
+
font-size: 13px; transition: transform 0.3s ease; z-index: 9999;
|
|
51
|
+
}
|
|
52
|
+
.toast.show { transform: translateX(-50%) translateY(0); }
|
|
53
|
+
|
|
54
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
|
55
|
+
.badge-success { background: #e4f5e9; color: #00713a; }
|
|
56
|
+
.badge-warning { background: #fff8e6; color: #8a6500; }
|
|
57
|
+
.badge-error { background: #fff4f4; color: #d72c0d; }
|