@decocms/nextjs 7.34.0 → 7.34.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/package.json +3 -3
- package/src/draft.ts +11 -5
- package/src/draftConstants.ts +15 -0
- package/src/draftMiddleware.test.ts +36 -4
- package/src/draftMiddleware.ts +31 -4
- package/src/draftShell.test.ts +72 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/nextjs",
|
|
3
|
-
"version": "7.34.
|
|
3
|
+
"version": "7.34.1",
|
|
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.
|
|
33
|
-
"@decocms/blocks-admin": "7.34.
|
|
32
|
+
"@decocms/blocks": "7.34.1",
|
|
33
|
+
"@decocms/blocks-admin": "7.34.1"
|
|
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
|
-
|
|
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
|
|
package/src/draftConstants.ts
CHANGED
|
@@ -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 {
|
|
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("
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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"));
|
package/src/draftMiddleware.ts
CHANGED
|
@@ -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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
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";
|