@decocms/start 6.1.0 → 6.2.1
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/.agents/skills/deco-migrate-script/SKILL.md +4 -3
- package/.cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md +13 -0
- package/.cursor/skills/deco-apps-vtex-review/SKILL.md +15 -0
- package/.cursor/skills/deco-server-functions-invoke/troubleshooting.md +22 -0
- package/docs/rum-plan.md +209 -0
- package/docs/runbooks/README.md +40 -0
- package/docs/runbooks/cache-hit-drop.md +83 -0
- package/docs/runbooks/commerce-upstream-slow.md +88 -0
- package/docs/runbooks/http-error-spike.md +98 -0
- package/docs/runbooks/http-latency-spike.md +82 -0
- package/docs/runbooks/tail-exception-spike.md +100 -0
- package/package.json +1 -1
- package/scripts/audit-observability-config.test.ts +251 -1
- package/scripts/audit-observability-config.ts +227 -26
- package/scripts/migrate/post-cleanup/rules.ts +90 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +103 -0
- package/scripts/migrate.ts +13 -0
- package/src/admin/invoke.test.ts +141 -0
- package/src/admin/invoke.ts +47 -14
|
@@ -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 "../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
|
+
});
|
package/src/admin/invoke.ts
CHANGED
|
@@ -58,6 +58,41 @@ export function clearInvokeHandlers(): void {
|
|
|
58
58
|
|
|
59
59
|
const JSON_HEADERS = { "Content-Type": "application/json" } as const;
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Copy headers that handlers wrote to `RequestContext.responseHeaders`
|
|
63
|
+
* onto an outgoing Response.
|
|
64
|
+
*
|
|
65
|
+
* Why this exists and is not a `for…of headers.entries()` one-liner:
|
|
66
|
+
* `Headers.entries()` (and `forEach`) collapses multiple `Set-Cookie`
|
|
67
|
+
* values into a single comma-joined string (per the Fetch spec).
|
|
68
|
+
* Browsers silently discard cookies whose value contains an unescaped
|
|
69
|
+
* comma, so every VTEX cart action that returns multiple cookies
|
|
70
|
+
* (`checkout.vtex.com__orderFormId`, `segment`, `sc`, `vtex_session`…)
|
|
71
|
+
* loses them in transit. The next request creates a fresh empty
|
|
72
|
+
* orderForm and the user lands on /checkout with an empty cart.
|
|
73
|
+
*
|
|
74
|
+
* `Headers.getSetCookie()` is the spec-blessed way to read the
|
|
75
|
+
* un-collapsed list. We append each value individually onto the
|
|
76
|
+
* response so the browser sees N distinct `Set-Cookie` headers, and
|
|
77
|
+
* use `forEach` to copy any non-cookie headers as-is.
|
|
78
|
+
*/
|
|
79
|
+
function forwardCtxHeadersTo(response: Response): void {
|
|
80
|
+
const ctx = RequestContext.current;
|
|
81
|
+
if (!ctx) return;
|
|
82
|
+
const cookies =
|
|
83
|
+
typeof ctx.responseHeaders.getSetCookie === "function"
|
|
84
|
+
? ctx.responseHeaders.getSetCookie()
|
|
85
|
+
: [];
|
|
86
|
+
for (const cookie of cookies) {
|
|
87
|
+
response.headers.append("set-cookie", cookie);
|
|
88
|
+
}
|
|
89
|
+
ctx.responseHeaders.forEach((value, key) => {
|
|
90
|
+
if (key.toLowerCase() !== "set-cookie") {
|
|
91
|
+
response.headers.append(key, value);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
61
96
|
const isDev =
|
|
62
97
|
typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
|
|
63
98
|
|
|
@@ -171,17 +206,7 @@ export async function handleInvoke(request: Request): Promise<Response> {
|
|
|
171
206
|
}
|
|
172
207
|
const filtered = selectFields(result, select);
|
|
173
208
|
const response = new Response(JSON.stringify(filtered), { status: 200, headers: JSON_HEADERS });
|
|
174
|
-
|
|
175
|
-
// Copy any headers that handlers wrote to RequestContext.responseHeaders
|
|
176
|
-
// (e.g., Set-Cookie from proxySetCookie). This mirrors deco-cx/deco's
|
|
177
|
-
// ctx.response.headers → HTTP Response forwarding.
|
|
178
|
-
const ctx = RequestContext.current;
|
|
179
|
-
if (ctx) {
|
|
180
|
-
for (const [key, value] of ctx.responseHeaders.entries()) {
|
|
181
|
-
response.headers.append(key, value);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
209
|
+
forwardCtxHeadersTo(response);
|
|
185
210
|
return response;
|
|
186
211
|
} catch (error) {
|
|
187
212
|
return errorResponse((error as Error).message, 500, error);
|
|
@@ -202,8 +227,11 @@ export async function handleInvoke(request: Request): Promise<Response> {
|
|
|
202
227
|
try {
|
|
203
228
|
let result = await found.handler(payload, request);
|
|
204
229
|
// If a loader returns a Response, extract its JSON body for batching.
|
|
205
|
-
// Set-Cookie
|
|
206
|
-
//
|
|
230
|
+
// Set-Cookie values from a handler-returned Response are *not*
|
|
231
|
+
// forwarded — those leave the AsyncLocalStorage scope. Handlers
|
|
232
|
+
// that need cookie passthrough must write to
|
|
233
|
+
// RequestContext.responseHeaders (which forwardCtxHeadersTo()
|
|
234
|
+
// below propagates onto the batch response).
|
|
207
235
|
if (result instanceof Response) {
|
|
208
236
|
try { result = await result.json(); } catch { result = null; }
|
|
209
237
|
}
|
|
@@ -217,7 +245,12 @@ export async function handleInvoke(request: Request): Promise<Response> {
|
|
|
217
245
|
}),
|
|
218
246
|
);
|
|
219
247
|
|
|
220
|
-
|
|
248
|
+
const response = new Response(JSON.stringify(results), { status: 200, headers: JSON_HEADERS });
|
|
249
|
+
// All batch handlers share the same RequestContext, so any Set-Cookie
|
|
250
|
+
// they appended (e.g. from VTEX vtexFetchWithCookies) is in
|
|
251
|
+
// `responseHeaders` by now. Forward it as N distinct Set-Cookie headers.
|
|
252
|
+
forwardCtxHeadersTo(response);
|
|
253
|
+
return response;
|
|
221
254
|
}
|
|
222
255
|
|
|
223
256
|
return errorResponse("No invoke key specified", 400);
|