From 1997e7b40a47e8f61b37d57277753818025c311b Mon Sep 17 00:00:00 2001 From: amit-kanswal-cs Date: Fri, 23 Oct 2026 14:30:57 +0530 Subject: [PATCH 1/3] fix: MKT-19619 pass backend body, status text and headers through api() errors appSdk.api() returned `body: null`, `statusText: "Internal Server Error"` and the *request* headers for every 4xx/5xx proxied through the platform. The host resolves the `apiAdapter` channel with the upstream AxiosResponse for HTTP errors; dispatchAdapter then rejected `{ ...response, config }`, a flat object with no `response` key, so dispatchApiRequest's `err.response` branch never ran and the transport-error fallback built the Response from fields that do not exist on that object. Changes - dispatchAdapter: for HTTP errors also nest the reply under `response`. The flat keys (status, data, headers) are kept so createAdapter() consumers are unaffected. Transport errors ({ status, message, stack }) are rejected unchanged. - dispatchApiRequest: convert `err.response` with axiosToFetchResponse so object bodies are JSON-encoded instead of "[object Object]"; guard `err.config?.headers` on the fallback. - axiosToFetchResponse: build headers from the *response* headers instead of the request config (apps could not read response headers even on success); drop set-cookie and authorization. - Tests: new __test__/adapter.test.ts drives dispatchApiRequest and dispatchAdapter through a mocked post-robot (200, 404 JSON/string, 500 text, host 502 transport shape, post-robot failure, header pass-through and stripping). Jest 27 has no fetch globals, so a tiny custom environment copies Node's Response/Headers/Blob into the sandbox. 9 of the 14 new cases fail on develop and pass with this change. Co-Authored-By: Claude Fable 5.1 --- __test__/adapter.test.ts | 263 +++++++++++++++++++++++++++ __test__/helpers/fetchEnvironment.js | 22 +++ src/utils/adapter.ts | 26 ++- src/utils/utils.ts | 19 +- 4 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 __test__/adapter.test.ts create mode 100644 __test__/helpers/fetchEnvironment.js diff --git a/__test__/adapter.test.ts b/__test__/adapter.test.ts new file mode 100644 index 00000000..5855c066 --- /dev/null +++ b/__test__/adapter.test.ts @@ -0,0 +1,263 @@ +/** + * @jest-environment ./__test__/helpers/fetchEnvironment.js + */ +import PostRobot from "post-robot"; +import { AxiosResponse } from "axios"; + +import { dispatchAdapter, dispatchApiRequest } from "../src/utils/adapter"; +import { axiosToFetchResponse } from "../src/utils/utils"; + +jest.mock("post-robot", () => ({ + __esModule: true, + default: { + sendToParent: jest.fn(), + on: jest.fn(), + CONFIG: {}, + }, +})); + +const sendToParent = PostRobot.sendToParent as unknown as jest.Mock; + +/** What the host resolves the `apiAdapter` channel with for an HTTP reply. */ +const hostHttpReply = ( + status: number, + statusText: string, + data: unknown, + headers: Record = { "content-type": "application/json" } +) => ({ + data: { data, status, statusText, headers, config: {}, request: {} }, +}); + +/** What the host resolves with when it could not reach the backend. */ +const hostTransportReply = { + data: { + status: 502, + message: "connect ECONNREFUSED", + stack: "Error: connect ECONNREFUSED\n at TCPConnectWrap", + }, +}; + +describe("dispatchApiRequest (appSdk.api)", () => { + afterEach(() => { + sendToParent.mockReset(); + }); + + it("returns a fetch Response with body and response headers on success", async () => { + sendToParent.mockResolvedValue( + hostHttpReply( + 200, + "OK", + { ok: true }, + { + "content-type": "application/json", + "x-request-id": "abc-123", + } + ) + ); + + const res = await dispatchApiRequest("https://backend.test/x", { + method: "POST", + headers: { "x-client": "app" }, + body: JSON.stringify({ a: 1 }), + }); + + expect(sendToParent).toHaveBeenCalledWith( + "apiAdapter", + expect.objectContaining({ + url: "https://backend.test/x", + method: "POST", + data: JSON.stringify({ a: 1 }), + }) + ); + expect(res.status).toBe(200); + expect(res.statusText).toBe("OK"); + expect(await res.json()).toEqual({ ok: true }); + // response headers, not request headers + expect(res.headers.get("x-request-id")).toBe("abc-123"); + expect(res.headers.get("x-client")).toBeNull(); + }); + + it("returns the backend JSON body on a 404 (MKT-19619)", async () => { + sendToParent.mockResolvedValue( + hostHttpReply(404, "Not Found", { + message: "Locale code mismatch for Chinese (Simplified)", + }) + ); + + const res = await dispatchApiRequest("https://backend.test/x", { + method: "POST", + }); + + expect(res.status).toBe(404); + expect(res.statusText).toBe("Not Found"); + expect(res.headers.get("content-type")).toBe("application/json"); + expect(await res.json()).toEqual({ + message: "Locale code mismatch for Chinese (Simplified)", + }); + }); + + it("returns a string body on a 404 as-is", async () => { + sendToParent.mockResolvedValue( + hostHttpReply(404, "Not Found", '{"message":"already a string"}') + ); + + const res = await dispatchApiRequest("https://backend.test/x"); + + expect(res.status).toBe(404); + expect(await res.text()).toBe('{"message":"already a string"}'); + }); + + it("returns a text/plain body on a 500", async () => { + sendToParent.mockResolvedValue( + hostHttpReply(500, "Internal Server Error", "boom", { + "content-type": "text/plain", + }) + ); + + const res = await dispatchApiRequest("https://backend.test/x"); + + expect(res.status).toBe(500); + expect(res.statusText).toBe("Internal Server Error"); + expect(res.headers.get("content-type")).toBe("text/plain"); + expect(await res.text()).toBe("boom"); + }); + + it("keeps the host transport error message when the backend is unreachable", async () => { + sendToParent.mockResolvedValue(hostTransportReply); + + const res = await dispatchApiRequest("https://backend.test/x"); + + expect(res.status).toBe(502); + expect(res.statusText).toBe("connect ECONNREFUSED"); + expect(await res.text()).toContain("ECONNREFUSED"); + }); + + it("returns a 500 when the post-robot call itself fails", async () => { + sendToParent.mockRejectedValue(new Error("no parent")); + + const res = await dispatchApiRequest("https://backend.test/x"); + + expect(res.status).toBe(500); + expect(res.statusText).toBe("Something went wrong with the request"); + }); + + it("strips set-cookie and authorization from response headers", async () => { + sendToParent.mockResolvedValue( + hostHttpReply( + 200, + "OK", + { ok: true }, + { + "content-type": "application/json", + "set-cookie": "sid=1", + authorization: "Bearer x", + "x-safe": "yes", + } + ) + ); + + const res = await dispatchApiRequest("https://backend.test/x"); + + expect(res.headers.get("set-cookie")).toBeNull(); + expect(res.headers.get("authorization")).toBeNull(); + expect(res.headers.get("x-safe")).toBe("yes"); + }); +}); + +describe("dispatchAdapter (appSdk.createAdapter)", () => { + afterEach(() => { + sendToParent.mockReset(); + }); + + it("resolves with an axios-like response on success", async () => { + sendToParent.mockResolvedValue(hostHttpReply(200, "OK", { ok: true })); + const config = { url: "/x", method: "GET" }; + + const res = (await dispatchAdapter(PostRobot)(config)) as AxiosResponse; + + expect(res.status).toBe(200); + expect(res.data).toEqual({ ok: true }); + expect(res.config).toBe(config); + }); + + it("rejects an HTTP error with the flat keys plus a nested response", async () => { + sendToParent.mockResolvedValue( + hostHttpReply(404, "Not Found", { message: "nope" }) + ); + + await expect( + dispatchAdapter(PostRobot)({ url: "/x" }) + ).rejects.toMatchObject({ + // existing shape apps already rely on + status: 404, + statusText: "Not Found", + data: { message: "nope" }, + config: { url: "/x" }, + // new: nested response for api() + response: { status: 404, data: { message: "nope" } }, + }); + }); + + it("rejects a transport error without a nested response", async () => { + sendToParent.mockResolvedValue(hostTransportReply); + + const err: any = await dispatchAdapter(PostRobot)({ url: "/x" }).catch( + (e) => e + ); + + expect(err.status).toBe(502); + expect(err.message).toBe("connect ECONNREFUSED"); + expect(err.response).toBeUndefined(); + }); + + it("rejects with an AxiosError when post-robot fails", async () => { + sendToParent.mockRejectedValue(new Error("no parent")); + + await expect( + dispatchAdapter(PostRobot)({ url: "/x" }) + ).rejects.toMatchObject({ + isAxiosError: true, + code: "ERR_INTERNAL_SERVER", + message: "Something went wrong with the request", + }); + }); +}); + +describe("axiosToFetchResponse", () => { + const base = { status: 200, statusText: "OK", config: {} as any }; + + it("JSON-encodes object bodies and defaults content-type to application/json", async () => { + const res = axiosToFetchResponse({ + ...base, + data: { a: 1 }, + headers: {}, + } as AxiosResponse); + + expect(res.headers.get("content-type")).toBe("application/json"); + expect(await res.json()).toEqual({ a: 1 }); + }); + + it("passes string bodies through and defaults content-type to octet-stream", async () => { + const res = axiosToFetchResponse({ + ...base, + data: "raw", + headers: {}, + } as AxiosResponse); + + expect(res.headers.get("content-type")).toBe( + "application/octet-stream" + ); + expect(await res.text()).toBe("raw"); + }); + + it("tolerates a missing headers object", async () => { + const res = axiosToFetchResponse({ + ...base, + data: null, + headers: undefined, + } as unknown as AxiosResponse); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("null"); + }); +}); diff --git a/__test__/helpers/fetchEnvironment.js b/__test__/helpers/fetchEnvironment.js new file mode 100644 index 00000000..1f32db2c --- /dev/null +++ b/__test__/helpers/fetchEnvironment.js @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const NodeEnvironment = require("jest-environment-node"); + +/** + * Jest 27 does not expose the WHATWG fetch globals inside the test sandbox. + * The adapter builds `Response`/`Headers` objects, so copy Node's own + * implementations (Node >= 18) into the sandbox. + */ +class FetchEnvironment extends NodeEnvironment { + constructor(config, context) { + super(config, context); + ["Response", "Headers", "Request", "Blob", "FormData"].forEach( + (name) => { + if (!this.global[name] && global[name]) { + this.global[name] = global[name]; + } + } + ); + } +} + +module.exports = FetchEnvironment; diff --git a/src/utils/adapter.ts b/src/utils/adapter.ts index 9c19fc44..c92f1508 100644 --- a/src/utils/adapter.ts +++ b/src/utils/adapter.ts @@ -25,7 +25,19 @@ export const dispatchAdapter = const { data: response } = event as { data: AxiosResponse }; if (response.status >= 400) { - return reject({ ...response, config }); + // The host replies with the upstream AxiosResponse for + // HTTP errors and with `{ status, message, stack }` for + // transport failures. Keep the flat keys that + // createAdapter() consumers already read (err.status, + // err.data) and, for HTTP errors, also nest the reply + // under `response` so api() can convert it the same + // way it converts an AxiosError. + const isHttpError = "data" in response; + return reject({ + ...response, + config, + ...(isHttpError && { response }), + }); } resolve({ data: response.data, @@ -69,17 +81,17 @@ export const dispatchApiRequest = async ( return axiosToFetchResponse(axiosResponse); } catch (err: any) { + // HTTP error (4xx/5xx): the backend body, status text and response + // headers are passed through unchanged, as fetch would. if (err.response) { - return new Response(err.response?.data, { - status: err.response.status, - statusText: err.response.statusText, - headers: err.response.headers, - }); + return axiosToFetchResponse(err.response); } + // Transport error (host could not reach the backend, or the + // post-robot call itself failed). return new Response(err.stack, { status: err.status || 500, statusText: err.message || "Internal Server Error", - headers: err.config.headers, + headers: err.config?.headers, }); } }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7f14cdf8..6eb99d4f 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -86,8 +86,17 @@ export const fetchToAxiosConfig = ( return axiosConfig; }; +const BLOCKED_RESPONSE_HEADERS = ["set-cookie", "authorization"]; + export function axiosToFetchResponse(axiosRes: AxiosResponse): Response { - const { data, status, statusText, config } = axiosRes; + const { data, status, statusText, headers: rawHeaders } = axiosRes; + + const headersObj: Record = {}; + Object.entries(rawHeaders ?? {}).forEach(([key, value]) => { + if (value === undefined || value === null) return; + if (BLOCKED_RESPONSE_HEADERS.includes(key.toLowerCase())) return; + headersObj[key.toLowerCase()] = String(value); + }); let body: BodyInit; let contentType = "application/json"; @@ -98,19 +107,19 @@ export function axiosToFetchResponse(axiosRes: AxiosResponse): Response { data instanceof ArrayBuffer ) { body = data; - contentType = config.headers["content-type"] || "application/octet-stream"; + contentType = headersObj["content-type"] || "application/octet-stream"; } else { body = JSON.stringify(data); } - if (!config.headers["content-type"]) { - config.headers["content-type"] = contentType; + if (!headersObj["content-type"]) { + headersObj["content-type"] = contentType; } const responseInit: ResponseInit = { status, statusText, - headers: config.headers, + headers: new Headers(headersObj), }; return new Response(body, responseInit); From 3dccd80c97bb2231d21b1c66c488a71c8a065582 Mon Sep 17 00:00:00 2001 From: amit-kanswal-cs Date: Fri, 23 Oct 2026 14:35:11 +0530 Subject: [PATCH 2/3] chore: fix security-sca and security-license CI checks - Upgrade axios ^1.7.9 -> ^1.20.0 (lock 1.12.2 -> 1.20.0). Snyk flagged 37 issues in axios@1.12.2 and its transitive form-data@4.0.4 / follow-redirects@1.15.9; 1.20.0 is the version Snyk recommends and pulls form-data 4.0.6 and follow-redirects 1.16.1. `npm audit --omit=dev`: 0. - LICENSE: copyright range 2022-2025 -> 2022-2026 (policy-scan requires the current year). Both checks were already failing on develop; no source change. Co-Authored-By: Claude Fable 5.1 --- LICENSE | 2 +- package-lock.json | 57 ++++++++++++++++++++++++++--------------------- package.json | 4 ++-- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/LICENSE b/LICENSE index 46e33814..e0e3bd2d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022-2025 Contentstack +Copyright (c) 2022-2026 Contentstack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package-lock.json b/package-lock.json index 794e05d8..bb6ceab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.3.1", "license": "MIT", "dependencies": { - "axios": "^1.7.9", + "axios": "^1.20.0", "jsonfile": "^6.1.0", "loader-utils": "^3.2.1", "post-robot": "^8.0.31", @@ -3571,7 +3571,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "dependencies": { "debug": "4" }, @@ -3777,25 +3776,28 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/axios/node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4888,7 +4890,6 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -6102,15 +6103,16 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -6444,9 +6446,10 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -6574,7 +6577,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -9725,8 +9727,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -10449,9 +10450,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.9.0", diff --git a/package.json b/package.json index 7653c0c4..0795779f 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "webpack-dev-server": "^4.7.3" }, "dependencies": { - "axios": "^1.7.9", + "axios": "^1.20.0", "jsonfile": "^6.1.0", "loader-utils": "^3.2.1", "post-robot": "^8.0.31", @@ -78,4 +78,4 @@ "engines": { "node": ">=18.x" } -} \ No newline at end of file +} From f0d062387cdb4a037dff61cc20dc317cd455a9a1 Mon Sep 17 00:00:00 2001 From: amit-kanswal-cs Date: Fri, 23 Oct 2026 16:28:55 +0530 Subject: [PATCH 3/3] chore: bump version to 2.5.0 Minor bump: api() now returns the backend body, status text and response headers on 4xx/5xx and exposes response headers on success; createAdapter() rejections gain a nested `response` key (additive). Last published is 2.4.1. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb6ceab0..da449eaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/app-sdk", - "version": "2.3.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/app-sdk", - "version": "2.3.1", + "version": "2.5.0", "license": "MIT", "dependencies": { "axios": "^1.20.0", diff --git a/package.json b/package.json index 0795779f..db8e4459 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/app-sdk", - "version": "2.3.1", + "version": "2.5.0", "types": "dist/src/index.d.ts", "description": "The Contentstack App SDK allows you to customize your Contentstack applications.", "main": "dist/index.js",