@decocms/blocks-admin 7.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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@decocms/blocks-admin",
3
+ "version": "7.0.0",
4
+ "type": "module",
5
+ "description": "Deco admin protocol: /live/_meta, /.decofile, /deco/invoke, /deco/render, plus admin-facing site setup and commerce-app auto-configuration",
6
+ "main": "./src/admin/index.ts",
7
+ "exports": {
8
+ ".": "./src/admin/index.ts",
9
+ "./setup": "./src/createAdminSetup.ts",
10
+ "./admin/setup": "./src/admin/setup.ts",
11
+ "./apps": "./src/apps/index.ts",
12
+ "./apps/autoconfig": "./src/apps/autoconfig.ts",
13
+ "./sdk/htmlShell": "./src/sdk/htmlShell.ts",
14
+ "./sdk/setupApps": "./src/sdk/setupApps.ts"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "test": "vitest run --root ../.. packages/blocks-admin/",
19
+ "typecheck": "tsc --noEmit",
20
+ "lint:unused": "knip"
21
+ },
22
+ "dependencies": {
23
+ "@decocms/blocks": "7.0.0"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^19.0.0",
31
+ "@types/react-dom": "^19.0.0",
32
+ "knip": "^5.86.0",
33
+ "typescript": "^5.9.0"
34
+ },
35
+ "publishConfig": {
36
+ "registry": "https://registry.npmjs.org",
37
+ "access": "public"
38
+ }
39
+ }
@@ -0,0 +1,49 @@
1
+ const ADMIN_ORIGINS = new Set([
2
+ "https://admin.deco.cx",
3
+ "https://v0-admin.deco.cx",
4
+ "https://play.deco.cx",
5
+ "https://admin-cx.deco.page",
6
+ "https://deco.chat",
7
+ "https://admin.decocms.com",
8
+ "https://decocms.com",
9
+ ]);
10
+
11
+ /**
12
+ * Register additional allowed admin origins.
13
+ * Useful for self-hosted admin UIs or custom dashboards.
14
+ */
15
+ export function registerAdminOrigin(origin: string): void {
16
+ ADMIN_ORIGINS.add(origin);
17
+ }
18
+
19
+ /**
20
+ * Register multiple additional allowed admin origins.
21
+ */
22
+ export function registerAdminOrigins(origins: string[]): void {
23
+ for (const origin of origins) {
24
+ ADMIN_ORIGINS.add(origin);
25
+ }
26
+ }
27
+
28
+ export function isAdminOrLocalhost(request: Request): boolean {
29
+ const origin = request.headers.get("origin") || request.headers.get("referer") || "";
30
+
31
+ if (origin.includes("localhost") || origin.includes("127.0.0.1")) {
32
+ return true;
33
+ }
34
+
35
+ for (const domain of ADMIN_ORIGINS) {
36
+ if (origin.startsWith(domain)) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ export function corsHeaders(request: Request): Record<string, string> {
42
+ const origin = request.headers.get("origin") || "*";
43
+ return {
44
+ "Access-Control-Allow-Origin": origin,
45
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
46
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, If-None-Match",
47
+ "Access-Control-Allow-Credentials": "true",
48
+ };
49
+ }
@@ -0,0 +1,146 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { KV_KEYS, type KVNamespace, getRevision, loadBlocks, setBlocks } from "@decocms/blocks/cms";
3
+ import { handleDecofileReload, setFastDeployKVGetter } from "./decofile";
4
+
5
+ // In vitest, import.meta.env.DEV is true, so handleDecofileReload skips the
6
+ // auth check (same branch the dev Vite plugin uses) — no token needed here.
7
+
8
+ // decofile.ts no longer hard-imports getFastDeployKV (that would create a
9
+ // admin → tanstack dependency, which is backwards). Instead the
10
+ // getter is injected — mirror the enablement rule that used to live in
11
+ // sdk/kvHydration.ts (DECO_FAST_DEPLOY flag + duck-typed DECO_KV binding) so
12
+ // these tests still exercise the KV write-through path end-to-end.
13
+ setFastDeployKVGetter((env) => {
14
+ const flag = env.DECO_FAST_DEPLOY;
15
+ if (flag !== "1" && flag !== "true") return null;
16
+ const binding = env.DECO_KV as KVNamespace | undefined;
17
+ if (binding && typeof binding.get === "function") return binding;
18
+ return null;
19
+ });
20
+
21
+ function reload(payload: unknown, env?: Record<string, unknown>) {
22
+ const req = new Request("http://x/.decofile", {
23
+ method: "POST",
24
+ body: JSON.stringify(payload),
25
+ headers: { "Content-Type": "application/json" },
26
+ });
27
+ return handleDecofileReload(req, env);
28
+ }
29
+
30
+ function makeKV() {
31
+ const store = new Map<string, string>();
32
+ const kv: KVNamespace = {
33
+ get: (k) => Promise.resolve(store.get(k) ?? null),
34
+ put: (k, v) => {
35
+ store.set(k, v);
36
+ return Promise.resolve();
37
+ },
38
+ delete: (k) => {
39
+ store.delete(k);
40
+ return Promise.resolve();
41
+ },
42
+ };
43
+ return { kv, store };
44
+ }
45
+
46
+ beforeEach(() => {
47
+ setBlocks({ Site: { name: "base" }, "pages-home": { path: "/" } });
48
+ });
49
+
50
+ describe("handleDecofileReload — full replacement (back-compat)", () => {
51
+ it("replaces the whole decofile when body is a raw block map", async () => {
52
+ const full = { Site: { name: "new" }, "pages-x": { path: "/x" } };
53
+ const res = await reload(full);
54
+ const json = (await res.json()) as { mode: string; ok: boolean };
55
+ expect(res.status).toBe(200);
56
+ expect(json.ok).toBe(true);
57
+ expect(json.mode).toBe("full");
58
+ expect(loadBlocks()).toEqual(full);
59
+ });
60
+ });
61
+
62
+ describe("handleDecofileReload — delta payloads", () => {
63
+ it("adds/updates blocks, leaving others intact", async () => {
64
+ const res = await reload({ blocks: { "pages-x": { path: "/x" } } });
65
+ const json = (await res.json()) as { mode: string };
66
+ expect(json.mode).toBe("delta");
67
+ expect(loadBlocks()).toEqual({
68
+ Site: { name: "base" },
69
+ "pages-home": { path: "/" },
70
+ "pages-x": { path: "/x" },
71
+ });
72
+ });
73
+
74
+ it("deletes a block when its delta value is null", async () => {
75
+ await reload({ blocks: { "pages-home": null } });
76
+ expect(loadBlocks()).toEqual({ Site: { name: "base" } });
77
+ });
78
+
79
+ it("does NOT treat a full decofile that has many keys as a delta", async () => {
80
+ const res = await reload({ Site: { name: "a" }, blocks: { name: "a block named blocks" } });
81
+ const json = (await res.json()) as { mode: string };
82
+ // Two top-level keys → full replacement, not a delta envelope.
83
+ expect(json.mode).toBe("full");
84
+ });
85
+ });
86
+
87
+ describe("handleDecofileReload — KV write-through", () => {
88
+ it("writes the snapshot + revision to KV when DECO_KV is bound", async () => {
89
+ const { kv, store } = makeKV();
90
+ const res = await reload(
91
+ { blocks: { "pages-x": { path: "/x" } } },
92
+ { DECO_KV: kv, DECO_FAST_DEPLOY: "1" },
93
+ );
94
+ const json = (await res.json()) as { kvWritten: boolean; revision: string };
95
+
96
+ expect(json.kvWritten).toBe(true);
97
+ expect(store.get(KV_KEYS.SNAPSHOT)).toBe(JSON.stringify(loadBlocks()));
98
+ expect(store.get(KV_KEYS.REVISION)).toBe(getRevision());
99
+ expect(store.get(KV_KEYS.REVISION)).toBe(json.revision);
100
+ });
101
+
102
+ it("reports kvWritten=false when no KV binding is present", async () => {
103
+ const res = await reload({ blocks: { "pages-x": { path: "/x" } } }, {});
104
+ const json = (await res.json()) as { kvWritten: boolean };
105
+ expect(json.kvWritten).toBe(false);
106
+ });
107
+
108
+ it("reports kvWritten=false when bound but DECO_FAST_DEPLOY is not set", async () => {
109
+ const { kv, store } = makeKV();
110
+ const res = await reload({ blocks: { "pages-x": { path: "/x" } } }, { DECO_KV: kv });
111
+ const json = (await res.json()) as { kvWritten: boolean };
112
+ expect(json.kvWritten).toBe(false);
113
+ expect(store.size).toBe(0); // nothing written to KV
114
+ });
115
+
116
+ it("does not fail the request when the KV write throws", async () => {
117
+ const env = {
118
+ DECO_FAST_DEPLOY: "1",
119
+ DECO_KV: {
120
+ get: () => Promise.resolve(null),
121
+ put: () => Promise.reject(new Error("KV down")),
122
+ delete: () => Promise.resolve(),
123
+ } as KVNamespace,
124
+ };
125
+ const res = await reload({ blocks: { "pages-x": { path: "/x" } } }, env);
126
+ const json = (await res.json()) as { ok: boolean; kvWritten: boolean };
127
+ expect(res.status).toBe(200);
128
+ expect(json.ok).toBe(true);
129
+ expect(json.kvWritten).toBe(false);
130
+ // Local state still updated despite KV failure.
131
+ expect(loadBlocks()["pages-x"]).toEqual({ path: "/x" });
132
+ });
133
+ });
134
+
135
+ describe("handleDecofileReload — validation", () => {
136
+ it("returns 400 on invalid JSON", async () => {
137
+ const req = new Request("http://x/.decofile", { method: "POST", body: "{not json" });
138
+ const res = await handleDecofileReload(req, {});
139
+ expect(res.status).toBe(400);
140
+ });
141
+
142
+ it("returns 400 when the body is not an object", async () => {
143
+ const res = await reload([1, 2, 3], {});
144
+ expect(res.status).toBe(400);
145
+ });
146
+ });
@@ -0,0 +1,189 @@
1
+ import { KV_KEYS, getRevision, loadBlocks, setBlocks } from "@decocms/blocks/cms";
2
+ import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
3
+ import { getRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
4
+ import { invalidateMetaCache } from "./meta";
5
+
6
+ type FastDeployKVGetter = (
7
+ env: Record<string, unknown>,
8
+ ) => { get: Function; put: Function; delete: Function } | null;
9
+
10
+ let getFastDeployKV: FastDeployKVGetter = () => null;
11
+
12
+ /**
13
+ * Inject the fast-deploy KV getter. Fast-deploy is Cloudflare/tanstack-only —
14
+ * `admin` cannot depend on `tanstack`'s `sdk/kvHydration`
15
+ * directly (that dependency direction is backwards), so the site's own
16
+ * setup wiring calls this once at startup, e.g.
17
+ * `setFastDeployKVGetter(getFastDeployKV)` from its own
18
+ * `sdk/kvHydration`. `next` never calls it, so the KV write-through
19
+ * below silently no-ops there.
20
+ */
21
+ export function setFastDeployKVGetter(getter: FastDeployKVGetter): void {
22
+ getFastDeployKV = getter;
23
+ }
24
+
25
+ export function handleDecofileRead(): Response {
26
+ const blocks = loadBlocks();
27
+ const revision = getRevision();
28
+
29
+ return new Response(JSON.stringify(blocks), {
30
+ status: 200,
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ "Cache-Control": "no-cache",
34
+ ...(revision ? { ETag: `"${revision}"` } : {}),
35
+ },
36
+ });
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Delta payloads
41
+ //
42
+ // Studio (admin.deco.cx) and CI can POST a partial update instead of the whole
43
+ // decofile. The delta envelope is:
44
+ //
45
+ // { "blocks": { "<blockName>": <blockJson> | null, ... } }
46
+ //
47
+ // where a `null` value deletes that block. A delta is identified by a body that
48
+ // has EXACTLY one top-level key, `blocks`, holding an object — a full decofile
49
+ // always carries many top-level block keys (Site, pages-*, …), so there is no
50
+ // realistic collision. Any other object body is treated as a full decofile
51
+ // replacement (backward-compatible with the dev Vite plugin).
52
+ // ---------------------------------------------------------------------------
53
+
54
+ interface DeltaPayload {
55
+ blocks: Record<string, unknown | null>;
56
+ }
57
+
58
+ function isObject(v: unknown): v is Record<string, unknown> {
59
+ return !!v && typeof v === "object" && !Array.isArray(v);
60
+ }
61
+
62
+ function isDeltaPayload(body: unknown): body is DeltaPayload {
63
+ return isObject(body) && Object.keys(body).length === 1 && isObject(body.blocks);
64
+ }
65
+
66
+ /** Apply a delta over the current decofile: set non-null values, delete nulls. */
67
+ function applyDelta(
68
+ base: Record<string, unknown>,
69
+ delta: Record<string, unknown | null>,
70
+ ): Record<string, unknown> {
71
+ const merged = { ...base };
72
+ for (const [name, value] of Object.entries(delta)) {
73
+ if (value === null || value === undefined) {
74
+ delete merged[name];
75
+ } else {
76
+ merged[name] = value;
77
+ }
78
+ }
79
+ return merged;
80
+ }
81
+
82
+ /**
83
+ * Write the current in-memory decofile snapshot to KV so other isolates pick
84
+ * it up on their next revision poll. No-op (returns false) when fast-deploy is
85
+ * disabled. The revision stored MUST equal the runtime's `getRevision()` so
86
+ * pollers don't see a permanent mismatch.
87
+ */
88
+ async function writeSnapshotToKV(env: Record<string, unknown> | undefined): Promise<boolean> {
89
+ const kv = env ? getFastDeployKV(env) : null;
90
+ if (!kv) return false;
91
+
92
+ const blocks = loadBlocks();
93
+ const revision = getRevision();
94
+ await kv.put(KV_KEYS.SNAPSHOT, JSON.stringify(blocks));
95
+ if (revision) await kv.put(KV_KEYS.REVISION, revision);
96
+ return true;
97
+ }
98
+
99
+ export async function handleDecofileReload(
100
+ request: Request,
101
+ env?: Record<string, unknown>,
102
+ ): Promise<Response> {
103
+ // Resolve the runtime env once. Prefer an explicitly-passed env, then the
104
+ // per-request Workers env stashed by workerEntry (`setRuntimeEnv`). Used for
105
+ // both the reload token and the fast-deploy KV binding.
106
+ const runtimeEnv = env ?? getRuntimeEnv();
107
+
108
+ // In dev mode the Vite plugin POSTs new blocks here to hot-reload without
109
+ // module invalidation (which breaks TanStack Start/Router state). Skip auth
110
+ // so the plugin can POST from localhost.
111
+ // Uses import.meta.env.DEV directly (not isDevMode()) because isDevMode()
112
+ // bypass auth. Vite statically replaces import.meta.env.DEV with `false`
113
+ // in production builds, so this branch is dead-code-eliminated.
114
+ const isViteDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV;
115
+ if (!isViteDev) {
116
+ const authHeader = request.headers.get("Authorization") || "";
117
+ const expectedToken =
118
+ (runtimeEnv?.DECO_RELEASE_RELOAD_TOKEN as string | undefined) ??
119
+ (typeof globalThis.process !== "undefined"
120
+ ? globalThis.process.env?.DECO_RELEASE_RELOAD_TOKEN
121
+ : undefined);
122
+
123
+ if (!expectedToken || authHeader !== expectedToken) {
124
+ return new Response("Unauthorized", { status: 401 });
125
+ }
126
+ }
127
+
128
+ let body: unknown;
129
+ try {
130
+ body = await request.json();
131
+ } catch {
132
+ return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
133
+ status: 400,
134
+ headers: { "Content-Type": "application/json" },
135
+ });
136
+ }
137
+
138
+ if (!isObject(body)) {
139
+ return new Response(JSON.stringify({ error: "Body must be a JSON object" }), {
140
+ status: 400,
141
+ headers: { "Content-Type": "application/json" },
142
+ });
143
+ }
144
+
145
+ const previousBlockCount = Object.keys(loadBlocks()).length;
146
+
147
+ // Delta merge (partial update) or full decofile replacement.
148
+ let nextBlocks: Record<string, unknown>;
149
+ let isDelta: boolean;
150
+ if (isDeltaPayload(body)) {
151
+ nextBlocks = applyDelta(loadBlocks(), body.blocks);
152
+ isDelta = true;
153
+ } else {
154
+ nextBlocks = body;
155
+ isDelta = false;
156
+ }
157
+
158
+ setBlocks(nextBlocks);
159
+ // Invalidate the meta ETag so the admin re-fetches the schema on next poll.
160
+ invalidateMetaCache();
161
+ // Clear stale loader cache entries after decofile update
162
+ clearLoaderCache();
163
+
164
+ // Fast-deploy: persist the new snapshot to KV so other isolates converge.
165
+ // A failed KV write does NOT fail the request — this isolate is already
166
+ // updated; we surface `kvWritten: false` so the caller can retry.
167
+ let kvWritten = false;
168
+ try {
169
+ kvWritten = await writeSnapshotToKV(runtimeEnv);
170
+ } catch (e) {
171
+ console.warn("[CMS/KV] write-through after publish failed:", e);
172
+ }
173
+
174
+ const newBlockCount = Object.keys(nextBlocks).length;
175
+ const revision = getRevision();
176
+
177
+ return new Response(
178
+ JSON.stringify({
179
+ ok: true,
180
+ mode: isDelta ? "delta" : "full",
181
+ previousBlockCount,
182
+ newBlockCount,
183
+ revision,
184
+ kvWritten,
185
+ timestamp: Date.now(),
186
+ }),
187
+ { status: 200, headers: { "Content-Type": "application/json" } },
188
+ );
189
+ }
@@ -0,0 +1,29 @@
1
+ export { corsHeaders, isAdminOrLocalhost, registerAdminOrigin, registerAdminOrigins } from "./cors";
2
+ export { handleDecofileRead, handleDecofileReload, setFastDeployKVGetter } from "./decofile";
3
+ export {
4
+ clearInvokeHandlers,
5
+ handleInvoke,
6
+ type InvokeAction,
7
+ type InvokeLoader,
8
+ registerInvokeHandlers,
9
+ setInvokeActions,
10
+ setInvokeLoaders,
11
+ } from "./invoke";
12
+ export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
13
+ export { handleMeta, setMetaData } from "./meta";
14
+ export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
15
+ export {
16
+ composeMeta,
17
+ getRegisteredLoaders,
18
+ getRegisteredMatchers,
19
+ type ActionConfig,
20
+ type LoaderConfig,
21
+ type MatcherConfig,
22
+ type MetaResponse,
23
+ registerActionSchema,
24
+ registerActionSchemas,
25
+ registerLoaderSchema,
26
+ registerLoaderSchemas,
27
+ registerMatcherSchema,
28
+ registerMatcherSchemas,
29
+ } from "@decocms/blocks/cms";
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Regression tests for /deco/invoke Set-Cookie propagation.
3
+ *
4
+ * The historical bug: the single- and batch-invoke paths copied
5
+ * `RequestContext.responseHeaders` to the HTTP response via
6
+ * `headers.entries()`, which collapses multiple `Set-Cookie` values
7
+ * into a single comma-joined string. Browsers silently discard those,
8
+ * so every VTEX cart action lost its session cookies and the user
9
+ * ended up at /checkout with an empty cart.
10
+ *
11
+ * These tests pin the fix: when a handler appends multiple
12
+ * Set-Cookie values to `RequestContext.responseHeaders`, the response
13
+ * returned by `handleInvoke` must surface them as N distinct
14
+ * Set-Cookie headers (readable via `response.headers.getSetCookie()`).
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
19
+ import {
20
+ clearInvokeHandlers,
21
+ handleInvoke,
22
+ registerInvokeHandlers,
23
+ } from "./invoke";
24
+
25
+ const COOKIE_A = "checkout.vtex.com__orderFormId=of-123; Path=/; HttpOnly";
26
+ const COOKIE_B = "segment=eyJjYW1wYWlnbnMiOiJ4In0=; Path=/; HttpOnly";
27
+ const COOKIE_C = "sc=1; Path=/; HttpOnly";
28
+
29
+ function makeInvokeRequest(key: string, body: unknown = {}): Request {
30
+ return new Request(`http://localhost/deco/invoke/${key}`, {
31
+ method: "POST",
32
+ headers: { "content-type": "application/json" },
33
+ body: JSON.stringify(body),
34
+ });
35
+ }
36
+
37
+ function makeBatchRequest(body: Record<string, unknown>): Request {
38
+ return new Request("http://localhost/deco/invoke", {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify(body),
42
+ });
43
+ }
44
+
45
+ describe("handleInvoke — Set-Cookie propagation (single)", () => {
46
+ beforeEach(() => clearInvokeHandlers());
47
+ afterEach(() => clearInvokeHandlers());
48
+
49
+ it("forwards multiple Set-Cookie values as distinct headers", async () => {
50
+ registerInvokeHandlers({
51
+ "vtex/actions/addItemsToCart": async () => {
52
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
53
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
54
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
55
+ return { orderFormId: "of-123" };
56
+ },
57
+ });
58
+
59
+ const request = makeInvokeRequest("vtex/actions/addItemsToCart");
60
+ const response = await RequestContext.run(request, () => handleInvoke(request));
61
+
62
+ const cookies = response.headers.getSetCookie();
63
+ expect(cookies).toHaveLength(3);
64
+ expect(cookies).toContain(COOKIE_A);
65
+ expect(cookies).toContain(COOKIE_B);
66
+ expect(cookies).toContain(COOKIE_C);
67
+ });
68
+
69
+ it("does not collapse cookies into a single Set-Cookie entry", async () => {
70
+ registerInvokeHandlers({
71
+ "vtex/actions/foo": async () => {
72
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
73
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
74
+ return {};
75
+ },
76
+ });
77
+
78
+ const request = makeInvokeRequest("vtex/actions/foo");
79
+ const response = await RequestContext.run(request, () => handleInvoke(request));
80
+
81
+ // The regressed bug appended a single comma-joined string, so
82
+ // `getSetCookie()` returned a 1-element array. The fix appends each
83
+ // value individually — verifying the count alone catches the regression.
84
+ expect(response.headers.getSetCookie()).toHaveLength(2);
85
+ });
86
+
87
+ it("forwards non-cookie headers unchanged", async () => {
88
+ registerInvokeHandlers({
89
+ "vtex/actions/withHeader": async () => {
90
+ RequestContext.responseHeaders.append("x-vtex-trace-id", "abc-123");
91
+ return {};
92
+ },
93
+ });
94
+
95
+ const request = makeInvokeRequest("vtex/actions/withHeader");
96
+ const response = await RequestContext.run(request, () => handleInvoke(request));
97
+ expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
98
+ });
99
+
100
+ it("does not forward Set-Cookie when handler writes none", async () => {
101
+ registerInvokeHandlers({
102
+ "vtex/loaders/productList": async () => ({ items: [] }),
103
+ });
104
+
105
+ const request = makeInvokeRequest("vtex/loaders/productList");
106
+ const response = await RequestContext.run(request, () => handleInvoke(request));
107
+ expect(response.headers.getSetCookie()).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
112
+ beforeEach(() => clearInvokeHandlers());
113
+ afterEach(() => clearInvokeHandlers());
114
+
115
+ it("forwards cookies that batch handlers append to the shared context", async () => {
116
+ registerInvokeHandlers({
117
+ "vtex/actions/addItemsToCart": async () => {
118
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_A);
119
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_B);
120
+ return { orderFormId: "of-123" };
121
+ },
122
+ "vtex/loaders/productList": async () => {
123
+ // Loader writes its own cookie (e.g. segment) — must also propagate.
124
+ RequestContext.responseHeaders.append("set-cookie", COOKIE_C);
125
+ return { items: [] };
126
+ },
127
+ });
128
+
129
+ const request = makeBatchRequest({
130
+ "vtex/actions/addItemsToCart": { orderFormId: "x" },
131
+ "vtex/loaders/productList": {},
132
+ });
133
+ const response = await RequestContext.run(request, () => handleInvoke(request));
134
+
135
+ const cookies = response.headers.getSetCookie();
136
+ expect(cookies).toHaveLength(3);
137
+ expect(cookies).toContain(COOKIE_A);
138
+ expect(cookies).toContain(COOKIE_B);
139
+ expect(cookies).toContain(COOKIE_C);
140
+ });
141
+ });