@decocms/nextjs 7.34.0 → 7.34.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/nextjs",
3
- "version": "7.34.0",
3
+ "version": "7.34.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -29,8 +29,8 @@
29
29
  "lint:unused": "knip"
30
30
  },
31
31
  "dependencies": {
32
- "@decocms/blocks": "7.34.0",
33
- "@decocms/blocks-admin": "7.34.0"
32
+ "@decocms/blocks": "7.34.2",
33
+ "@decocms/blocks-admin": "7.34.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "next": ">=15.0.0",
package/src/draft.ts CHANGED
@@ -29,11 +29,11 @@ import {
29
29
  } from "@decocms/blocks/cms";
30
30
  import { cookies, headers } from "next/headers";
31
31
  import { cache } from "react";
32
- import { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
32
+ import { DRAFT_COOKIE, DRAFT_HEADER, DRAFT_PARAM } from "./draftConstants";
33
33
 
34
34
  // Re-exported for back-compat: these used to live here, but the client badge
35
35
  // needs DRAFT_PARAM and cannot import this module (it pulls in `next/headers`).
36
- export { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
36
+ export { DRAFT_COOKIE, DRAFT_HEADER, DRAFT_PARAM } from "./draftConstants";
37
37
 
38
38
  /**
39
39
  * Request-scoped slot.
@@ -124,15 +124,21 @@ export function selectDraftPointer(
124
124
  export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boolean> {
125
125
  registerDraftOverride();
126
126
 
127
- const cookieStore = await cookies();
128
- const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value);
127
+ const [cookieStore, requestHeaders] = await Promise.all([cookies(), headers()]);
128
+
129
+ // Pointer precedence: the page's own `?__draft=` param wins; then the
130
+ // middleware-forwarded header (the only source a LAYOUT can see on the entry
131
+ // request — see DRAFT_HEADER); then the navigation cookie. This is what lets
132
+ // `ensureDraft()` bind from a layout so shell-resolved Header/Footer reflect
133
+ // the draft, not just page sections.
134
+ const carried = requestHeaders.get(DRAFT_HEADER) ?? cookieStore.get(DRAFT_COOKIE)?.value ?? null;
135
+ const pointer = selectDraftPointer(searchParams, carried);
129
136
  if (!pointer) return false;
130
137
 
131
138
  // Host gate, checked only once a pointer exists (headers() is a dynamic
132
139
  // API): the same build may serve the preview domain and the production
133
140
  // domain, and only hosts named in DECO_ALLOWED_PREVIEW_HOSTS may render
134
141
  // drafts — production stays published no matter what the URL carries.
135
- const requestHeaders = await headers();
136
142
  const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
137
143
  if (!isDraftHostAllowed(host)) return false;
138
144
 
@@ -13,3 +13,18 @@ export const DRAFT_PARAM = "__draft";
13
13
 
14
14
  /** Cookie that carries the pointer across in-preview navigation. */
15
15
  export const DRAFT_COOKIE = "__deco_draft";
16
+
17
+ /**
18
+ * Request header the middleware forwards the active pointer on, so the whole
19
+ * RSC tree — including the root layout / app shell — can bind the draft on the
20
+ * SAME request. The page reads `?__draft=` from its own `searchParams`, but a
21
+ * layout never receives `searchParams` and, on the entry request, the cookie
22
+ * has only just been set on the *response* (not yet readable). Forwarding the
23
+ * pointer as a request header closes both gaps so `ensureDraft()` works from a
24
+ * layout, letting shell-resolved Header/Footer reflect the draft too.
25
+ *
26
+ * Not a new trust surface: the pointer was never a secret (the draft id is the
27
+ * capability, host-scoping bounds blast radius — see `draftSource`), so a
28
+ * forwarded/forged header is equivalent to typing the query param.
29
+ */
30
+ export const DRAFT_HEADER = "x-deco-draft";
@@ -2,7 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
 
4
4
  import { DRAFT_COOKIE } from "./draft";
5
- import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware";
5
+ import {
6
+ applyDraft,
7
+ draftRequestHeaders,
8
+ prepareDraft,
9
+ rewriteToDraftRoute,
10
+ } from "./draftMiddleware";
6
11
 
7
12
  beforeEach(() => {
8
13
  // The middleware is host-gated; these tests run as the allowed host.
@@ -48,9 +53,12 @@ describe("prepareDraft", () => {
48
53
  expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
49
54
  });
50
55
 
51
- it("ignores a client-supplied draft header — the page owns the decision", () => {
52
- // There is no request-header path any more; the page reads searchParams
53
- // and cookies itself, so a forged header is simply not an input.
56
+ it("does not read a client-supplied draft header — the decision is param/cookie only", () => {
57
+ // The forwarded `x-deco-draft` header (read by `ensureDraft` in the layout)
58
+ // is an OUTPUT of the middleware decision, never an input to it: prepareDraft
59
+ // still decides purely from the param + cookie. A client that forges the
60
+ // header directly can't manufacture a decision here (and `draftRequestHeaders`
61
+ // strips it when not previewing).
54
62
  const req = new NextRequest(new URL("https://site.example/p"), {
55
63
  headers: { "x-deco-draft": "attacker.localhost@v1" },
56
64
  });
@@ -58,6 +66,30 @@ describe("prepareDraft", () => {
58
66
  });
59
67
  });
60
68
 
69
+ describe("draftRequestHeaders", () => {
70
+ it("forwards the active pointer on x-deco-draft so a layout can bind it", () => {
71
+ const req = request("https://site.example/p?__draft=abc.localhost@v1");
72
+ const headers = draftRequestHeaders(req, prepareDraft(req));
73
+ expect(headers.get("x-deco-draft")).toBe("abc.localhost@v1");
74
+ });
75
+
76
+ it("forwards the cookie-carried pointer on plain navigation", () => {
77
+ const req = request("https://site.example/p", "abc.localhost@v1");
78
+ const headers = draftRequestHeaders(req, prepareDraft(req));
79
+ expect(headers.get("x-deco-draft")).toBe("abc.localhost@v1");
80
+ });
81
+
82
+ it("strips a client-forged header when the request is not previewing", () => {
83
+ // Direct-to-origin request carrying a forged header but no real pointer:
84
+ // the forwarded headers must not carry it downstream.
85
+ const req = new NextRequest(new URL("https://site.example/p"), {
86
+ headers: { "x-deco-draft": "attacker.localhost@v1" },
87
+ });
88
+ const headers = draftRequestHeaders(req, prepareDraft(req));
89
+ expect(headers.get("x-deco-draft")).toBeNull();
90
+ });
91
+ });
92
+
61
93
  describe("applyDraft", () => {
62
94
  it("sets a partitioned cross-site cookie when entering draft mode", () => {
63
95
  const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
@@ -25,6 +25,7 @@ import { type NextRequest, NextResponse } from "next/server";
25
25
  import {
26
26
  DRAFT_COOKIE,
27
27
  DRAFT_COOKIE_OPTIONS,
28
+ DRAFT_HEADER,
28
29
  type DraftMiddlewareDecision,
29
30
  decideDraft,
30
31
  } from "./draft";
@@ -127,10 +128,36 @@ export function applyDraft(
127
128
  }
128
129
 
129
130
  /**
130
- * Convenience for sites with no middleware of their own: prepare, continue,
131
- * apply. Sites that already have middleware should call the two halves
132
- * directly so their own logic sits in between.
131
+ * Request headers to forward downstream, carrying the active pointer on
132
+ * `x-deco-draft` (see {@link DRAFT_HEADER}) so the RSC tree — including the app
133
+ * shell — can bind the draft via `ensureDraft()` and reflect it in
134
+ * shell-resolved Header/Footer, not only page sections.
135
+ *
136
+ * Pass to `NextResponse.next({ request: { headers } })`. Strips any
137
+ * client-supplied `x-deco-draft` when this request is not previewing, so the
138
+ * header is only ever present on a legitimately-gated preview request.
139
+ */
140
+ export function draftRequestHeaders(
141
+ request: NextRequest,
142
+ decision: DraftMiddlewareDecision,
143
+ ): Headers {
144
+ const headers = new Headers(request.headers);
145
+ if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
146
+ else headers.delete(DRAFT_HEADER);
147
+ return headers;
148
+ }
149
+
150
+ /**
151
+ * Convenience for sites with no middleware of their own: prepare, forward the
152
+ * pointer header, continue, apply. Sites that already have middleware should
153
+ * call the halves directly (`prepareDraft` → `draftRequestHeaders` on the
154
+ * `NextResponse.next({ request: { headers } })` → `applyDraft`) so their own
155
+ * logic sits in between.
133
156
  */
134
157
  export function draftMiddleware(request: NextRequest): NextResponse {
135
- return applyDraft(NextResponse.next(), prepareDraft(request));
158
+ const decision = prepareDraft(request);
159
+ const response = NextResponse.next({
160
+ request: { headers: draftRequestHeaders(request, decision) },
161
+ });
162
+ return applyDraft(response, decision);
136
163
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `ensureDraft()` must bind from the middleware-forwarded `x-deco-draft` header
3
+ * — the only pointer source a LAYOUT (app shell) can see: it receives no
4
+ * `searchParams`, and on the entry request the cookie has only just been set on
5
+ * the response. This is what lets shell-resolved Header/Footer reflect a draft,
6
+ * not just page sections.
7
+ */
8
+ import { clearDraftCache } from "@decocms/blocks/cms";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ // Mutable header/cookie state the mock reads, so each test drives its own.
12
+ let headerState = new Headers();
13
+ let cookieState: Record<string, string> = {};
14
+
15
+ vi.mock("next/headers", () => ({
16
+ headers: async () => headerState,
17
+ cookies: async () => ({
18
+ get: (name: string) => (name in cookieState ? { name, value: cookieState[name] } : undefined),
19
+ }),
20
+ }));
21
+
22
+ const { ensureDraft, DRAFT_HEADER, DRAFT_COOKIE } = await import("./draft");
23
+
24
+ const DRAFT_BLOCKS = { "pages-home": { title: "DRAFT" } };
25
+ const POINTER = "abc.localhost:60534@v1";
26
+
27
+ beforeEach(() => {
28
+ headerState = new Headers({ host: "site.example" });
29
+ cookieState = {};
30
+ process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
31
+ clearDraftCache();
32
+ vi.stubGlobal(
33
+ "fetch",
34
+ vi.fn(async (url: string) => {
35
+ if (String(url) === "http://abc.localhost:60534/_sandbox/decofile") {
36
+ return new Response(JSON.stringify(DRAFT_BLOCKS), {
37
+ status: 200,
38
+ headers: { "content-type": "application/json" },
39
+ });
40
+ }
41
+ throw new Error(`unexpected fetch: ${url}`);
42
+ }),
43
+ );
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.unstubAllGlobals();
48
+ delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
49
+ });
50
+
51
+ describe("ensureDraft — app-shell (layout) binding via the forwarded header", () => {
52
+ it("binds the draft from x-deco-draft with no searchParams (the layout case)", async () => {
53
+ headerState.set(DRAFT_HEADER, POINTER);
54
+ // No searchParams (layouts get none) and no cookie (entry request).
55
+ expect(await ensureDraft()).toBe(true);
56
+ });
57
+
58
+ it("still binds from the cookie when there is no forwarded header", async () => {
59
+ cookieState[DRAFT_COOKIE] = POINTER;
60
+ expect(await ensureDraft()).toBe(true);
61
+ });
62
+
63
+ it("does not bind when neither header, cookie, nor param is present", async () => {
64
+ expect(await ensureDraft()).toBe(false);
65
+ });
66
+
67
+ it("stays gated by the host allowlist even with a forwarded header", async () => {
68
+ headerState = new Headers({ host: "prod.example" });
69
+ headerState.set(DRAFT_HEADER, POINTER);
70
+ expect(await ensureDraft()).toBe(false);
71
+ });
72
+ });
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export { DraftPreviewIndicator } from "./DraftPreviewIndicator";
18
18
  export {
19
19
  DRAFT_COOKIE,
20
20
  DRAFT_COOKIE_OPTIONS,
21
+ DRAFT_HEADER,
21
22
  DRAFT_PARAM,
22
23
  type DraftMiddlewareDecision,
23
24
  type DraftSearchParams,
@@ -30,6 +31,7 @@ export {
30
31
  export {
31
32
  applyDraft,
32
33
  DRAFT_ROUTE_PREFIX,
34
+ draftRequestHeaders,
33
35
  prepareDraft,
34
36
  rewriteToDraftRoute,
35
37
  } from "./draftMiddleware";