Harden checkout activity API flow

This commit is contained in:
lzs 2026-06-19 19:35:50 +08:00
parent 4e46608c35
commit 11b656c327
8 changed files with 655 additions and 80 deletions

View File

@ -18,7 +18,7 @@ This project follows:
6. On success, backend returns `success`, `redirectUrl`, and localized `buttonText`.
7. Extension renders a button and opens `redirectUrl` in the current window.
Failures return `{ "success": false }`; the extension renders nothing and does not block the native Thank You Page.
Business failures return `{ "success": false }`; route-level validation errors can include an `error` object and non-2xx status. The extension renders nothing for either case and does not block the native Thank You Page.
## Local Setup
@ -153,7 +153,19 @@ Failure response:
}
```
CORS headers are returned for `POST` and `OPTIONS`; source trust relies on Shopify checkout session token validation.
Route-level validation failures include an error code and use the matching HTTP status:
```json
{
"success": false,
"error": {
"code": "ORDER_ID_REQUIRED",
"message": "orderId is required."
}
}
```
All route responses include `Cache-Control: no-store, max-age=0`. Authenticated `POST` and `OPTIONS` responses are wrapped with Shopify's checkout CORS helper; fallback CORS is only used when authentication itself fails before the helper is available. Source trust relies on Shopify checkout session token validation.
## Dreame Signing
@ -253,6 +265,8 @@ Current test coverage:
- `test/idempotency.test.ts`: same order/activity does not push twice and still returns button data.
- `test/config.test.ts`: app-owned Metaobject default type and legacy type override.
- `test/activity-flow.test.ts`: inactive status, non-zero status code, configurable success codes, configurable status active policy, successful push payload shape, push failure, missing email, missing customer ID.
- `test/activity-route.test.ts`: checkout auth/CORS/cache HTTP contract, invalid shop context, missing order ID, auth failure handling.
- `test/checkout-extension.test.ts`: extension app URL normalization, session-token POST request, non-2xx and invalid JSON fallback behavior.
## Deployment Steps

View File

@ -8,12 +8,23 @@ import { findSuccessfulPush, recordSuccessfulPush } from "../services/idempotenc
import { logActivityPush } from "../services/logger.server";
import { getOrderDetails } from "../services/order.server";
const CORS_HEADERS = {
const CACHE_CONTROL = "no-store, max-age=0";
const CHECKOUT_CORS_HEADERS = ["Authorization", "Content-Type"];
const FALLBACK_CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Cache-Control": CACHE_CONTROL,
};
type CheckoutAuthResult = Awaited<ReturnType<typeof authenticate.public.checkout>> & {
admin?: unknown;
session?: { shop?: string };
shop?: string;
};
type CheckoutCors = CheckoutAuthResult["cors"];
type ActivityRequestBody = {
orderId?: string;
orderNumber?: string;
@ -22,35 +33,58 @@ type ActivityRequestBody = {
export async function loader({ request }: LoaderFunctionArgs) {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
return handleOptions(request);
}
return json({ success: false }, { status: 405, headers: CORS_HEADERS });
return fallbackCors(
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
Allow: "POST",
}),
);
}
export async function action({ request }: ActionFunctionArgs) {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
return handleOptions(request);
}
if (request.method !== "POST") {
return json({ success: false }, { status: 405, headers: CORS_HEADERS });
return fallbackCors(
errorResponse("METHOD_NOT_ALLOWED", "Only POST is supported.", 405, {
Allow: "POST",
}),
);
}
let authResult: CheckoutAuthResult;
try {
authResult = (await authenticate.public.checkout(request, {
corsHeaders: CHECKOUT_CORS_HEADERS,
})) as CheckoutAuthResult;
} catch (error) {
return authErrorResponse(error);
}
const { cors, sessionToken } = authResult;
const shop = normalizeShopDomain(authResult.shop || authResult.session?.shop || sessionToken.dest);
if (!shop) {
return checkoutCors(
cors,
errorResponse("INVALID_SHOP_CONTEXT", "A valid Shopify shop context is required.", 401),
);
}
const body = await parseJsonBody(request);
if (!body) {
return checkoutCors(cors, errorResponse("INVALID_JSON", "Request body must be valid JSON.", 400));
}
if (typeof body.orderId !== "string" || body.orderId.trim() === "") {
return checkoutCors(cors, errorResponse("ORDER_ID_REQUIRED", "orderId is required.", 400));
}
try {
const checkoutAuth = (authenticate as unknown as { public?: { checkout?: (request: Request) => Promise<unknown> } })
.public?.checkout;
if (!checkoutAuth) {
throw new Error("Shopify checkout authentication is unavailable");
}
const authResult = (await checkoutAuth(request)) as {
shop?: string;
admin?: unknown;
sessionToken?: { dest?: string; iss?: string };
session?: { shop?: string };
};
const shop = authResult.shop || authResult.session?.shop || shopFromSessionToken(authResult.sessionToken);
const adminResult = authResult.admin ? { admin: authResult.admin } : shop ? await unauthenticated.admin(shop) : null;
const admin = adminResult?.admin as {
graphql: (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
@ -59,15 +93,10 @@ export async function action({ request }: ActionFunctionArgs) {
throw new Error("Invalid Shopify checkout session");
}
const body = (await request.json()) as ActivityRequestBody;
if (!body.orderId) {
return json({ success: false }, { headers: CORS_HEADERS });
}
const result = await runActivityCheck(
{
shop,
orderId: body.orderId,
orderId: body.orderId.trim(),
orderNumber: body.orderNumber,
locale: body.locale,
},
@ -84,21 +113,90 @@ export async function action({ request }: ActionFunctionArgs) {
},
);
return json(result, { headers: CORS_HEADERS });
return checkoutCors(cors, json(result, { headers: noStoreHeaders() }));
} catch {
return json({ success: false }, { headers: CORS_HEADERS });
return checkoutCors(cors, errorResponse("ACTIVITY_CHECK_FAILED", "Activity check failed.", 500));
}
}
function shopFromSessionToken(sessionToken: { dest?: string; iss?: string } | undefined): string | undefined {
const source = sessionToken?.dest || sessionToken?.iss;
if (!source) {
return undefined;
async function handleOptions(request: Request): Promise<Response> {
try {
const { cors } = await authenticate.public.checkout(request, {
corsHeaders: CHECKOUT_CORS_HEADERS,
});
return checkoutCors(cors, new Response(null, { status: 204, headers: noStoreHeaders() }));
} catch (error) {
return authErrorResponse(error);
}
}
async function parseJsonBody(request: Request): Promise<ActivityRequestBody | null> {
try {
return (await request.json()) as ActivityRequestBody;
} catch {
return null;
}
}
function normalizeShopDomain(value: unknown): string | null {
if (typeof value !== "string" || value.trim() === "") {
return null;
}
try {
return new URL(source).hostname;
const url = value.includes("://") ? value : `https://${value}`;
const hostname = new URL(url).hostname.toLowerCase();
return hostname.endsWith(".myshopify.com") ? hostname : null;
} catch {
return source.replace(/^https?:\/\//, "").split("/")[0];
return null;
}
}
function errorResponse(code: string, message: string, status: number, headers: HeadersInit = {}): Response {
return json(
{
success: false,
error: {
code,
message,
},
},
{
status,
headers: {
...headers,
...noStoreHeaders(),
},
},
);
}
function authErrorResponse(error: unknown): Response {
if (error instanceof Response) {
return fallbackCors(withNoStore(error));
}
return fallbackCors(errorResponse("CHECKOUT_AUTHENTICATION_FAILED", "Checkout authentication failed.", 401));
}
function checkoutCors(cors: CheckoutCors, response: Response): Response {
return cors(withNoStore(response));
}
function fallbackCors(response: Response): Response {
for (const [key, value] of Object.entries(FALLBACK_CORS_HEADERS)) {
response.headers.set(key, value);
}
return response;
}
function withNoStore(response: Response): Response {
response.headers.set("Cache-Control", CACHE_CONTROL);
return response;
}
function noStoreHeaders(): HeadersInit {
return {
"Cache-Control": CACHE_CONTROL,
};
}

View File

@ -218,7 +218,7 @@ curl -i -X OPTIONS https://<public-backend-domain>/api/action/activity/check
- 首页或健康页返回 200。
- OPTIONS 返回 204并包含 CORS headers。
- 未带 Shopify checkout session token 的 POST 应返回 `{"success":false}`
- 未带 Shopify checkout session token 的 POST 应返回 HTTP 4xx且不应触发第三方推送
## 11. 部署 Shopify 配置和扩展

View File

@ -97,11 +97,25 @@ Content-Type: application/json
}
```
路由级校验失败会返回对应 HTTP 状态码和错误码,例如:
```json
{
"success": false,
"error": {
"code": "ORDER_ID_REQUIRED",
"message": "orderId is required."
}
}
```
说明:
- 前端不传 `api_secret`
- 前端传入的订单字段只作为最小定位信息,不作为可信业务数据。
- 完整订单数据由后端通过 Shopify Admin GraphQL 查询。
- `POST``OPTIONS` 响应带 `Cache-Control: no-store, max-age=0`
- 已认证的 Checkout Extension 响应使用 Shopify checkout CORS helper 包装;认证失败前无法拿到 helper 时使用最小 fallback CORS。
- CORS 对 Checkout Extension 放开,来源可信由 session token 校验负责。
## 5. Shopify 配置
@ -445,7 +459,7 @@ curl -i -X OPTIONS https://<backend-domain>/api/action/activity/check
- 首页或健康页返回 200。
- OPTIONS 返回 204并包含 CORS headers。
- 未带 session token 的 POST 不应成功推送。
- 未带 session token 的 POST 应返回 HTTP 4xx不应成功推送。
### 11.5 部署 Shopify 配置和 Extension

View File

@ -1,60 +1,33 @@
import { useEffect, useState } from "react";
import { reactExtension, Button, InlineStack, useApi } from "@shopify/ui-extensions-react/checkout";
type ActivityResponse =
| {
success: true;
redirectUrl: string;
buttonText: string;
}
| {
success: false;
};
import {
getAppUrlSetting,
getCheckoutLocale,
getCheckoutOrder,
requestActivityCheck,
type ActivityResponse,
type CheckoutExtensionApi,
} from "./activity-api";
export default reactExtension("purchase.thank-you.block.render", () => <DreameActivity />);
function DreameActivity() {
const api = useApi<"purchase.thank-you.block.render">() as any;
const api = useApi<"purchase.thank-you.block.render">() as unknown as CheckoutExtensionApi;
const [response, setResponse] = useState<ActivityResponse>({ success: false });
useEffect(() => {
let cancelled = false;
async function checkActivity() {
const appUrl = String(api.settings?.current?.app_url || "").replace(/\/$/, "");
const order = api.orderConfirmation?.current?.order || api.orderConfirmation?.value?.order;
const orderId = order?.id;
if (!appUrl || !orderId) {
return;
}
try {
const token = await api.sessionToken.get();
const locale =
api.localization?.current?.language?.isoCode ||
api.localization?.value?.language?.isoCode ||
api.i18n?.locale ||
"en";
const result = (await fetch(`${appUrl}/api/action/activity/check`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
orderId,
orderNumber: order?.name,
locale,
}),
}).then((res) => res.json())) as ActivityResponse;
const result = await requestActivityCheck({
appUrl: getAppUrlSetting(api),
order: getCheckoutOrder(api),
locale: getCheckoutLocale(api),
getSessionToken: () => api.sessionToken.get(),
});
if (!cancelled) {
setResponse(result.success ? result : { success: false });
}
} catch {
if (!cancelled) {
setResponse({ success: false });
}
setResponse(result);
}
}

View File

@ -0,0 +1,181 @@
export type ActivityResponse =
| {
success: true;
redirectUrl: string;
buttonText: string;
}
| {
success: false;
};
export type CheckoutOrder = {
id?: string;
name?: string;
};
export type CheckoutExtensionApi = {
settings?: {
current?: {
app_url?: unknown;
};
value?: {
app_url?: unknown;
};
};
orderConfirmation?: {
current?: {
order?: CheckoutOrder;
};
value?: {
order?: CheckoutOrder;
};
};
localization?: {
current?: {
language?: {
isoCode?: string;
};
};
value?: {
language?: {
isoCode?: string;
};
};
};
i18n?: {
locale?: string;
};
sessionToken: {
get: () => Promise<string>;
};
};
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
type RequestActivityCheckOptions = {
appUrl: unknown;
order?: CheckoutOrder;
locale: string;
getSessionToken: () => Promise<string>;
fetchImpl?: FetchLike;
timeoutMs?: number;
};
export const DEFAULT_ACTIVITY_REQUEST_TIMEOUT_MS = 10_000;
export function normalizeAppUrl(rawValue: unknown): string | null {
if (typeof rawValue !== "string" || rawValue.trim() === "") {
return null;
}
try {
const url = new URL(rawValue.trim());
if (url.protocol !== "https:" || url.username || url.password) {
return null;
}
const pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return `${url.origin}${pathname}`;
} catch {
return null;
}
}
export async function requestActivityCheck({
appUrl,
order,
locale,
getSessionToken,
fetchImpl = fetch,
timeoutMs = DEFAULT_ACTIVITY_REQUEST_TIMEOUT_MS,
}: RequestActivityCheckOptions): Promise<ActivityResponse> {
const baseUrl = normalizeAppUrl(appUrl);
if (!baseUrl || !order?.id) {
return hiddenActivityResponse();
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const token = await getSessionToken();
if (typeof token !== "string" || token.trim() === "") {
return hiddenActivityResponse();
}
const endpoint = new URL("api/action/activity/check", `${baseUrl}/`).toString();
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
orderId: order.id,
orderNumber: order.name,
locale,
}),
cache: "no-store",
signal: controller.signal,
});
if (!response.ok) {
return hiddenActivityResponse();
}
const payload = await response.json().catch(() => null);
return toActivityResponse(payload);
} catch {
return hiddenActivityResponse();
} finally {
clearTimeout(timeout);
}
}
export function getAppUrlSetting(api: CheckoutExtensionApi): unknown {
return api.settings?.current?.app_url ?? api.settings?.value?.app_url;
}
export function getCheckoutOrder(api: CheckoutExtensionApi): CheckoutOrder | undefined {
return api.orderConfirmation?.current?.order ?? api.orderConfirmation?.value?.order;
}
export function getCheckoutLocale(api: CheckoutExtensionApi): string {
return (
api.localization?.current?.language?.isoCode ||
api.localization?.value?.language?.isoCode ||
api.i18n?.locale ||
"en"
);
}
function toActivityResponse(payload: unknown): ActivityResponse {
if (!payload || typeof payload !== "object") {
return hiddenActivityResponse();
}
const response = payload as Record<string, unknown>;
if (response.success !== true) {
return hiddenActivityResponse();
}
if (typeof response.redirectUrl !== "string" || response.redirectUrl.trim() === "") {
return hiddenActivityResponse();
}
if (typeof response.buttonText !== "string" || response.buttonText.trim() === "") {
return hiddenActivityResponse();
}
return {
success: true,
redirectUrl: response.redirectUrl,
buttonText: response.buttonText,
};
}
function hiddenActivityResponse(): ActivityResponse {
return {
success: false,
};
}

186
test/activity-route.test.ts Normal file
View File

@ -0,0 +1,186 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
const mocks = vi.hoisted(() => ({
checkout: vi.fn(),
unauthenticatedAdmin: vi.fn(),
runActivityCheck: vi.fn(),
}));
vi.mock("../app/shopify.server", () => ({
authenticate: {
public: {
checkout: mocks.checkout,
},
},
unauthenticated: {
admin: mocks.unauthenticatedAdmin,
},
}));
vi.mock("../app/services/activity.server", () => ({
runActivityCheck: mocks.runActivityCheck,
}));
vi.mock("../app/services/config.server", () => ({
getActivityConfig: vi.fn(),
}));
vi.mock("../app/services/dreame-client.server", () => ({
callPush: vi.fn(),
callStatus: vi.fn(),
}));
vi.mock("../app/services/idempotency.server", () => ({
findSuccessfulPush: vi.fn(),
recordSuccessfulPush: vi.fn(),
}));
vi.mock("../app/services/logger.server", () => ({
logActivityPush: vi.fn(),
}));
vi.mock("../app/services/order.server", () => ({
getOrderDetails: vi.fn(),
}));
function cors(response: Response): Response {
response.headers.set("X-Cors-Applied", "true");
return response;
}
async function postAction(body: unknown): Promise<Response> {
const { action } = await import("../app/routes/api.action.activity.check");
return action({
request: new Request("https://app.example.com/api/action/activity/check", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}),
context: {},
params: {},
} as ActionFunctionArgs) as Promise<Response>;
}
describe("activity check route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkout.mockResolvedValue({
sessionToken: {
dest: "https://demo.myshopify.com",
},
cors,
});
mocks.unauthenticatedAdmin.mockResolvedValue({
admin: {
graphql: vi.fn(),
},
});
mocks.runActivityCheck.mockResolvedValue({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "Claim",
});
});
test("wraps success responses with Shopify cors and no-store cache headers", async () => {
const response = await postAction({
orderId: "gid://shopify/Order/123",
orderNumber: "#1001",
locale: "en",
});
expect(response.status).toBe(200);
expect(response.headers.get("X-Cors-Applied")).toBe("true");
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.json()).resolves.toEqual({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "Claim",
});
expect(mocks.runActivityCheck).toHaveBeenCalledWith(
expect.objectContaining({
shop: "demo.myshopify.com",
orderId: "gid://shopify/Order/123",
}),
expect.any(Object),
);
});
test("rejects checkout tokens without a myshopify shop context", async () => {
mocks.checkout.mockResolvedValue({
sessionToken: {
dest: "https://not-shop.example.com",
},
cors,
});
const response = await postAction({
orderId: "gid://shopify/Order/123",
});
expect(response.status).toBe(401);
expect(response.headers.get("X-Cors-Applied")).toBe("true");
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.json()).resolves.toEqual({
success: false,
error: {
code: "INVALID_SHOP_CONTEXT",
message: "A valid Shopify shop context is required.",
},
});
expect(mocks.unauthenticatedAdmin).not.toHaveBeenCalled();
expect(mocks.runActivityCheck).not.toHaveBeenCalled();
});
test("returns a bad request when orderId is missing", async () => {
const response = await postAction({
orderNumber: "#1001",
});
expect(response.status).toBe(400);
expect(response.headers.get("X-Cors-Applied")).toBe("true");
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.json()).resolves.toEqual({
success: false,
error: {
code: "ORDER_ID_REQUIRED",
message: "orderId is required.",
},
});
expect(mocks.runActivityCheck).not.toHaveBeenCalled();
});
test("preserves Shopify authentication failures as HTTP errors", async () => {
mocks.checkout.mockRejectedValue(new Response("Unauthorized", { status: 401 }));
const response = await postAction({
orderId: "gid://shopify/Order/123",
});
expect(response.status).toBe(401);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Cache-Control")).toContain("no-store");
expect(await response.text()).toBe("Unauthorized");
expect(mocks.unauthenticatedAdmin).not.toHaveBeenCalled();
expect(mocks.runActivityCheck).not.toHaveBeenCalled();
});
test("uses Shopify checkout authentication for OPTIONS responses", async () => {
const { loader } = await import("../app/routes/api.action.activity.check");
const response = await loader({
request: new Request("https://app.example.com/api/action/activity/check", {
method: "OPTIONS",
}),
context: {},
params: {},
} as LoaderFunctionArgs);
expect(response.status).toBe(204);
expect(response.headers.get("X-Cors-Applied")).toBe("true");
expect(response.headers.get("Cache-Control")).toContain("no-store");
expect(mocks.checkout).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,109 @@
import { describe, expect, test, vi } from "vitest";
import { normalizeAppUrl, requestActivityCheck } from "../extensions/thank-you-activity/src/activity-api";
describe("checkout extension activity api", () => {
test("normalizes only HTTPS app URLs", () => {
expect(normalizeAppUrl("https://app.example.com/")).toBe("https://app.example.com");
expect(normalizeAppUrl(" https://app.example.com/path/ ")).toBe("https://app.example.com/path");
expect(normalizeAppUrl("http://localhost:3000")).toBeNull();
expect(normalizeAppUrl("")).toBeNull();
});
test("posts order context with a fresh checkout session token", async () => {
const fetchImpl = vi.fn(async () =>
new Response(
JSON.stringify({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "Claim",
}),
{ status: 200 },
),
);
const getSessionToken = vi.fn(async () => "checkout-token");
const result = await requestActivityCheck({
appUrl: "https://app.example.com/",
order: {
id: "gid://shopify/Order/123",
name: "#1001",
},
locale: "en",
getSessionToken,
fetchImpl,
timeoutMs: 100,
});
expect(result).toEqual({
success: true,
redirectUrl: "https://activity.example.com",
buttonText: "Claim",
});
expect(getSessionToken).toHaveBeenCalledOnce();
expect(fetchImpl).toHaveBeenCalledWith(
"https://app.example.com/api/action/activity/check",
expect.objectContaining({
method: "POST",
cache: "no-store",
headers: {
Authorization: "Bearer checkout-token",
"Content-Type": "application/json",
},
body: JSON.stringify({
orderId: "gid://shopify/Order/123",
orderNumber: "#1001",
locale: "en",
}),
signal: expect.any(AbortSignal),
}),
);
});
test("returns hidden state for non-2xx and invalid JSON responses", async () => {
const nonOk = await requestActivityCheck({
appUrl: "https://app.example.com",
order: { id: "gid://shopify/Order/123" },
locale: "en",
getSessionToken: async () => "checkout-token",
fetchImpl: async () => new Response(JSON.stringify({ success: true }), { status: 500 }),
timeoutMs: 100,
});
const invalidJson = await requestActivityCheck({
appUrl: "https://app.example.com",
order: { id: "gid://shopify/Order/123" },
locale: "en",
getSessionToken: async () => "checkout-token",
fetchImpl: async () => new Response("not json", { status: 200 }),
timeoutMs: 100,
});
expect(nonOk).toEqual({ success: false });
expect(invalidJson).toEqual({ success: false });
});
test("does not call the backend when app URL or order ID is unavailable", async () => {
const fetchImpl = vi.fn();
const invalidUrl = await requestActivityCheck({
appUrl: "http://localhost:3000",
order: { id: "gid://shopify/Order/123" },
locale: "en",
getSessionToken: async () => "checkout-token",
fetchImpl,
timeoutMs: 100,
});
const missingOrder = await requestActivityCheck({
appUrl: "https://app.example.com",
order: undefined,
locale: "en",
getSessionToken: async () => "checkout-token",
fetchImpl,
timeoutMs: 100,
});
expect(invalidUrl).toEqual({ success: false });
expect(missingOrder).toEqual({ success: false });
expect(fetchImpl).not.toHaveBeenCalled();
});
});