From d508a527eabcedd2c532305aaab8effbdfe6701e Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 17:48:54 -0300 Subject: [PATCH 1/7] feat: pull-based draft preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A site can now render its OWN pages against a Studio sandbox's working-tree draft, by fetching the merged decofile itself over a plain GET. This replaces pushing the decofile into a POST body at `/live/previews`. That only worked because deco's own runtime honours a POST render — Next.js and most frameworks render on GET only — and it could produce just one statically rendered component: no hydration, no navigation, and client components invoked server-side. Going through the site's real route fixes all three, and the payload is fetched once per version instead of re-sent on every navigation. Shape: `?__draft=@` on any page URL. The site resolves the handle against a CONFIGURED origin suffix, so it never fetches a caller-supplied URL and there is no SSRF surface to defend. - `@decocms/blocks` — `draftSource`: pointer parsing, origin construction, fetch, and a bounded version cache. Framework-agnostic; binding a draft to a request is injected via `setDraftOverrideGetter`, the same shape as `setFastDeployKVGetter`, so the runtime keeps its zero-dependency direction. - `@decocms/nextjs` — `ensureDraft` (page-level) plus `prepareDraft`/`applyDraft` (middleware, on a `./middleware` subpath so the edge graph never imports the root barrel), wired into `createDecoPage`. Two findings worth stating, both measured rather than assumed: A layout's `await` does NOT gate its children — App Router renders them concurrently, so a layout-level resolve lands after sections have already read `loadBlocks()` and silently rendered published content. The resolve therefore belongs on the PAGE. Request scoping uses React `cache()`, not AsyncLocalStorage: `RequestContext.run` is never entered on Next, and ALS cannot wrap a component's children anyway. Verified with concurrent requests. Next reserves `Cache-Control` and `Vary` on dynamic responses and overwrites them from middleware AND from `next.config` `headers()`; the wire keeps `no-cache, must-revalidate`. Isolated with a marker header on the same rules, so this is Next owning those two names, not our rules failing to match. `no-cache` still forbids reuse without revalidating with the origin, so a shared cache cannot serve a draft to another visitor — but closing the gap fully needs a CDN-level rule at deploy time. `X-Robots-Tag` does get through. Inert unless BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES` are set, mirroring Fast Deploy's opt-in: upgrading the package must never be enough to start fetching from the network and rendering unpublished content. A test asserts `fetch` is never called when disabled, and that an unconfigured site never touches a dynamic API — otherwise every page would lose static/ISR rendering on upgrade. --- packages/blocks/src/cms/draftSource.test.ts | 201 ++++++++++++++++++ packages/blocks/src/cms/draftSource.ts | 197 +++++++++++++++++ packages/blocks/src/cms/index.ts | 14 +- packages/blocks/src/cms/loader.ts | 24 ++- packages/nextjs/package.json | 97 ++++----- packages/nextjs/src/config.cjs | 57 ++++- packages/nextjs/src/config.test.ts | 39 ++++ .../nextjs/src/createDecoPage.draft.test.tsx | 89 ++++++++ packages/nextjs/src/createDecoPage.tsx | 64 +++++- packages/nextjs/src/draft.test.ts | 73 +++++++ packages/nextjs/src/draft.ts | 188 ++++++++++++++++ packages/nextjs/src/draftMiddleware.test.ts | 81 +++++++ packages/nextjs/src/draftMiddleware.ts | 79 +++++++ packages/nextjs/src/index.ts | 11 + 14 files changed, 1146 insertions(+), 68 deletions(-) create mode 100644 packages/blocks/src/cms/draftSource.test.ts create mode 100644 packages/blocks/src/cms/draftSource.ts create mode 100644 packages/nextjs/src/createDecoPage.draft.test.tsx create mode 100644 packages/nextjs/src/draft.test.ts create mode 100644 packages/nextjs/src/draft.ts create mode 100644 packages/nextjs/src/draftMiddleware.test.ts create mode 100644 packages/nextjs/src/draftMiddleware.ts diff --git a/packages/blocks/src/cms/draftSource.test.ts b/packages/blocks/src/cms/draftSource.test.ts new file mode 100644 index 00000000..ce994e77 --- /dev/null +++ b/packages/blocks/src/cms/draftSource.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + buildDraftOrigin, + clearDraftCache, + isDraftPreviewEnabled, + parseDraftPointer, + resolveDraftDecofile, +} from "./draftSource"; + +const ENV_ON = { + DECO_DRAFT_PREVIEW: "1", + DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com", +}; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +beforeEach(() => { + clearDraftCache(); +}); + +describe("parseDraftPointer", () => { + it("parses handle@version", () => { + expect(parseDraftPointer("gimenes-abc123@ff00")).toEqual({ + handle: "gimenes-abc123", + version: "ff00", + }); + }); + + it("rejects more than one @", () => { + // A naive split("@") accepts this and silently uses the first two + // segments — the exact hole found while spiking the fetch path. + expect(parseDraftPointer("a@b@c")).toBeNull(); + }); + + it("rejects a handle that could escape the authority", () => { + expect(parseDraftPointer("evil.com/x@v1")).toBeNull(); + expect(parseDraftPointer("user:pw@v1")).toBeNull(); + expect(parseDraftPointer("a/../b@v1")).toBeNull(); + expect(parseDraftPointer(".leading-dot@v1")).toBeNull(); + }); + + it("rejects empty halves and missing input", () => { + expect(parseDraftPointer("@v1")).toBeNull(); + expect(parseDraftPointer("handle@")).toBeNull(); + expect(parseDraftPointer("handle")).toBeNull(); + expect(parseDraftPointer(null)).toBeNull(); + expect(parseDraftPointer("")).toBeNull(); + }); +}); + +describe("buildDraftOrigin", () => { + it("builds https from the configured suffix", () => { + expect(buildDraftOrigin("abc", [".preview-studio.decocms.com"])).toBe( + "https://abc.preview-studio.decocms.com", + ); + }); + + it("uses http for a localhost suffix (local e2e)", () => { + expect(buildDraftOrigin("abc", [".localhost:3200"])).toBe("http://abc.localhost:3200"); + }); + + it("returns null with no configured suffix — never guesses an origin", () => { + expect(buildDraftOrigin("abc", [])).toBeNull(); + }); +}); + +describe("isDraftPreviewEnabled", () => { + it("needs both the flag and a suffix", () => { + expect(isDraftPreviewEnabled(ENV_ON)).toBe(true); + expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW: "1" })).toBe(false); + expect( + isDraftPreviewEnabled({ + DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com", + }), + ).toBe(false); + expect(isDraftPreviewEnabled({})).toBe(false); + }); +}); + +describe("resolveDraftDecofile", () => { + it("fetches the sandbox decofile and returns it", async () => { + const calls: string[] = []; + const blocks = await resolveDraftDecofile({ + pointer: "abc@v1", + env: ENV_ON, + fetchImpl: (async (url: string) => { + calls.push(String(url)); + return jsonResponse({ "pages-home": { title: "draft" } }); + }) as unknown as typeof fetch, + }); + + expect(blocks).toEqual({ "pages-home": { title: "draft" } }); + expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]); + }); + + it("is inert unless explicitly enabled — no fetch at all", async () => { + let called = false; + const blocks = await resolveDraftDecofile({ + pointer: "abc@v1", + env: { DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com" }, + fetchImpl: (async () => { + called = true; + return jsonResponse({}); + }) as unknown as typeof fetch, + }); + + expect(blocks).toBeNull(); + expect(called).toBe(false); + }); + + it("caches by version — one fetch per version, not per request", async () => { + let fetches = 0; + const fetchImpl = (async () => { + fetches++; + return jsonResponse({ n: fetches }); + }) as unknown as typeof fetch; + + const a = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl }); + const b = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl }); + expect(fetches).toBe(1); + expect(b).toBe(a); + + await resolveDraftDecofile({ pointer: "abc@v2", env: ENV_ON, fetchImpl }); + expect(fetches).toBe(2); + }); + + it("bounds the cache so multi-MB decofiles can't accumulate", async () => { + let fetches = 0; + const fetchImpl = (async () => { + fetches++; + return jsonResponse({ n: fetches }); + }) as unknown as typeof fetch; + + for (const v of ["v1", "v2", "v3", "v4"]) { + await resolveDraftDecofile({ pointer: `abc@${v}`, env: ENV_ON, fetchImpl }); + } + expect(fetches).toBe(4); + + // v1 was evicted (cap is 3), so it must re-fetch rather than serve stale. + await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl }); + expect(fetches).toBe(5); + + // v4 is still resident. + await resolveDraftDecofile({ pointer: "abc@v4", env: ENV_ON, fetchImpl }); + expect(fetches).toBe(5); + }); + + it("degrades to published on a malformed pointer, without fetching", async () => { + let called = false; + const blocks = await resolveDraftDecofile({ + pointer: "a@b@c", + env: ENV_ON, + fetchImpl: (async () => { + called = true; + return jsonResponse({}); + }) as unknown as typeof fetch, + }); + expect(blocks).toBeNull(); + expect(called).toBe(false); + }); + + it("degrades to published on a non-2xx sandbox", async () => { + const blocks = await resolveDraftDecofile({ + pointer: "abc@v1", + env: ENV_ON, + fetchImpl: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch, + }); + expect(blocks).toBeNull(); + }); + + it("degrades to published when the sandbox is unreachable", async () => { + const blocks = await resolveDraftDecofile({ + pointer: "abc@v1", + env: ENV_ON, + fetchImpl: (async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch, + }); + expect(blocks).toBeNull(); + }); + + it("degrades to published on an unparseable body", async () => { + const blocks = await resolveDraftDecofile({ + pointer: "abc@v1", + env: ENV_ON, + fetchImpl: (async () => + new Response("not json", { + status: 200, + headers: { "content-type": "text/html" }, + })) as unknown as typeof fetch, + }); + expect(blocks).toBeNull(); + }); +}); diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts new file mode 100644 index 00000000..912d90cd --- /dev/null +++ b/packages/blocks/src/cms/draftSource.ts @@ -0,0 +1,197 @@ +/** + * Draft preview — pull-based decofile override. + * + * A Studio sandbox serves the working-tree draft at + * `GET /_sandbox/decofile`; a production site pulls it and renders its + * own real pages against it. This replaces pushing the decofile into a POST + * body, which only deco's own runtime honours — Next.js and most frameworks + * render on GET only. + * + * This module is the framework-agnostic half: pointer parsing, origin + * construction, fetching, and version caching. Binding a resolved draft to a + * request is framework-specific (see `@decocms/nextjs`'s draft wiring) and + * reaches this module through {@link setDraftOverrideGetter} — the same + * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its + * zero-dependency direction. + * + * Inert unless BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES` + * are set, mirroring Fast Deploy's opt-in: upgrading the package must never be + * enough to start fetching from the network and rendering unpublished content. + */ + +/** A parsed `@` draft pointer. */ +export interface DraftPointer { + /** Sandbox handle — the subdomain under a configured origin suffix. */ + handle: string; + /** Content version (the daemon's ETag). Immutable, so safe to cache on. */ + version: string; +} + +/** + * Sandbox handles are `[a-z0-9-]`, always leading with an alphanumeric. + * + * Validated BEFORE the handle is interpolated into an authority, so it cannot + * smuggle `/`, `@`, `:` or userinfo into the URL and redirect the fetch at some + * other host. + */ +const HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; + +/** + * Parse `@`. + * + * Requires EXACTLY one `@`: a naive `split("@")` accepts `a@b@c` and silently + * uses the first two segments, which is how a malformed pointer sneaks past + * validation. Returns null on anything unexpected — callers fall back to + * published content. + */ +export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null { + if (!raw) return null; + const parts = raw.split("@"); + if (parts.length !== 2) return null; + const [handle, version] = parts; + if (!handle || !version) return null; + if (!HANDLE_RE.test(handle)) return null; + return { handle, version }; +} + +/** Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. */ +function readSuffixes(env: Record): string[] { + return (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Build the sandbox origin for a handle. + * + * The origin comes from CONFIGURED suffixes, never from caller input, so there + * is no SSRF surface to defend and no allowlist to keep correct. A `localhost` + * suffix (local e2e) speaks http; everything else is https. + */ +export function buildDraftOrigin(handle: string, suffixes: string[]): string | null { + const suffix = suffixes[0]; + if (!suffix) return null; + if (!HANDLE_RE.test(handle)) return null; + const scheme = suffix.includes("localhost") ? "http" : "https"; + return `${scheme}://${handle}${suffix}`; +} + +/** + * Version cache. + * + * Bounded on purpose: a decofile is routinely multi-megabyte, so an unbounded + * map keyed by version would grow with every save until the process died. + * Keyed by version (content-addressed), so a hit is always correct. + */ +const MAX_CACHED_VERSIONS = 3; +const byVersion = new Map>(); + +function cacheDraft(version: string, blocks: Record): void { + // Re-insert to make this the most recently used key. + byVersion.delete(version); + byVersion.set(version, blocks); + while (byVersion.size > MAX_CACHED_VERSIONS) { + const oldest = byVersion.keys().next().value; + if (oldest === undefined) break; + byVersion.delete(oldest); + } +} + +/** Test seam — drops every cached version. */ +export function clearDraftCache(): void { + byVersion.clear(); +} + +export interface ResolveDraftOptions { + /** Raw `@` pointer from the request. */ + pointer: string | null | undefined; + /** Defaults to `process.env`. */ + env?: Record; + /** Defaults to global `fetch`. Injected in tests. */ + fetchImpl?: typeof fetch; +} + +/** + * Resolve a draft pointer to a decofile, or null to render published content. + * + * Null on every failure path — disabled, malformed pointer, unreachable + * sandbox, non-2xx — because a draft that cannot be resolved must degrade to + * published rather than break the page. Callers that need to *tell the user* + * the draft failed should check {@link isDraftPreviewEnabled} and surface it + * themselves; silently showing published content while the user believes they + * are looking at a draft is the failure mode worth avoiding. + */ +export async function resolveDraftDecofile( + options: ResolveDraftOptions, +): Promise | null> { + const env = + options.env ?? + (globalThis as { process?: { env?: Record } }).process?.env ?? + {}; + if (env.DECO_DRAFT_PREVIEW !== "1") return null; + + const parsed = parseDraftPointer(options.pointer); + if (!parsed) return null; + + const cached = byVersion.get(parsed.version); + if (cached) return cached; + + const origin = buildDraftOrigin(parsed.handle, readSuffixes(env)); + if (!origin) return null; + + const doFetch = options.fetchImpl ?? fetch; + let res: Response; + try { + res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" }); + } catch { + return null; + } + if (!res.ok) return null; + + let blocks: Record; + try { + blocks = (await res.json()) as Record; + } catch { + return null; + } + + cacheDraft(parsed.version, blocks); + return blocks; +} + +/** True when the feature is switched on and configured. Inert otherwise. */ +export function isDraftPreviewEnabled(env?: Record): boolean { + const e = + env ?? + (globalThis as { process?: { env?: Record } }).process?.env ?? + {}; + return e.DECO_DRAFT_PREVIEW === "1" && readSuffixes(e).length > 0; +} + +// --------------------------------------------------------------------------- +// Request binding (dependency-injected by the framework binding) +// --------------------------------------------------------------------------- + +type DraftOverrideGetter = () => Record | null | undefined; + +let getDraftOverride: DraftOverrideGetter = () => undefined; + +/** + * Inject the request-scoped draft getter. + * + * Binding a value to "the current request" is framework-specific and `blocks` + * must not know about any framework: `@decocms/nextjs` backs this with React + * `cache()` (App Router has no AsyncLocalStorage request scope of its own — + * `RequestContext.run` is never entered there). Bindings that do have an ALS + * request scope can back it with that instead. Never called → returns + * undefined → `loadBlocks()` behaves exactly as before. + */ +export function setDraftOverrideGetter(getter: DraftOverrideGetter): void { + getDraftOverride = getter; +} + +/** The current request's draft blocks, if a binding registered one. */ +export function getRequestDraftOverride(): Record | null | undefined { + return getDraftOverride(); +} diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 68798ae4..9643e324 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -12,6 +12,16 @@ export { revisionKey, snapshotKey, } from "./blockSource"; +export type { DraftPointer, ResolveDraftOptions } from "./draftSource"; +export { + buildDraftOrigin, + clearDraftCache, + getRequestDraftOverride, + isDraftPreviewEnabled, + parseDraftPointer, + resolveDraftDecofile, + setDraftOverrideGetter, +} from "./draftSource"; export type { DecoPage, Resolvable } from "./loader"; export { findPageByPath, @@ -82,8 +92,6 @@ export { unregisterCommerceLoader, WELL_KNOWN_TYPES, } from "./resolve"; -export type { SectionLoaderContext } from "./sectionLoaderContext"; -export { buildSectionLoaderContext } from "./sectionLoaderContext"; export type { ActionConfig, AppSchemas, @@ -105,6 +113,8 @@ export { registerMatcherSchema, registerMatcherSchemas, } from "./schema"; +export type { SectionLoaderContext } from "./sectionLoaderContext"; +export { buildSectionLoaderContext } from "./sectionLoaderContext"; export type { SectionLoaderFn } from "./sectionLoaders"; export { getDegradedSections, diff --git a/packages/blocks/src/cms/loader.ts b/packages/blocks/src/cms/loader.ts index 5c98e8f7..5ac26069 100644 --- a/packages/blocks/src/cms/loader.ts +++ b/packages/blocks/src/cms/loader.ts @@ -1,5 +1,6 @@ import * as asyncHooks from "node:async_hooks"; import { djb2Hex } from "../sdk/djb2"; +import { getRequestDraftOverride } from "./draftSource"; export type Resolvable = { __resolveType?: string; @@ -86,7 +87,8 @@ export function setBlocks(blocks: Record) { /** * Load the current blocks. If running inside a `withBlocksOverride` scope - * (admin preview), the override is merged on top of the base blocks. + * (admin preview) or a request carrying a draft-preview override, that + * override is merged on top of the base blocks. */ export function loadBlocks(): Record { // Re-sync from globalThis in case setBlocks was called in another module instance @@ -95,7 +97,10 @@ export function loadBlocks(): Record { revision = G.__deco.revision ?? null; } - const override = blocksOverrideStorage.getStore(); + // `withBlocksOverride` (an explicit admin render of a specific payload) wins + // over an ambient draft: the caller named the exact blocks to render, so a + // draft pointer on the same request must not silently replace them. + const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride(); if (override) { const merged = { ...blockData }; for (const [key, value] of Object.entries(override)) { @@ -203,12 +208,14 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> { // same regardless of whether @types/node has its own `URLPattern` global or // not — there's nothing for a local declaration to collide with. type MatchPatternResult = { - pathname: { groups: Record }; + pathname: { groups: Record }; }; declare const URLPattern: { - new (init: { pathname: string }): { - exec(input: { pathname: string }): MatchPatternResult | null; - }; + new (init: { + pathname: string; + }): { + exec(input: { pathname: string }): MatchPatternResult | null; + }; }; /** @@ -231,10 +238,7 @@ declare const URLPattern: { * `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this * package's `engines` floor). Node 22 and older lack it. */ -export function matchPath( - pattern: string, - urlPath: string, -): Record | null { +export function matchPath(pattern: string, urlPath: string): Record | null { if (typeof URLPattern === "undefined") { throw new Error( "@decocms/blocks: this runtime has no URLPattern Web API, so CMS page " + diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6512f759..e1ca070e 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,50 +1,51 @@ { - "name": "@decocms/nextjs", - "version": "0.0.0", - "type": "module", - "engines": { - "node": ">=24" - }, - "description": "Deco framework binding for Next.js App Router", - "repository": { - "type": "git", - "url": "https://github.com/decocms/blocks.git", - "directory": "packages/nextjs" - }, - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./routeHandlers": "./src/routeHandlers.ts", - "./setup": "./src/setup.ts", - "./config": { - "types": "./src/config.d.cts", - "default": "./src/config.cjs" - } - }, - "scripts": { - "build": "tsc", - "test": "vitest run --root ../.. packages/nextjs/", - "typecheck": "tsc --noEmit", - "lint:unused": "knip" - }, - "dependencies": { - "@decocms/blocks": "workspace:*", - "@decocms/blocks-admin": "workspace:*" - }, - "peerDependencies": { - "next": ">=15.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "knip": "^5.86.0", - "next": "^15.4.0", - "typescript": "^5.9.0" - }, - "publishConfig": { - "registry": "https://registry.npmjs.org", - "access": "public" - } + "name": "@decocms/nextjs", + "version": "0.0.0", + "type": "module", + "engines": { + "node": ">=24" + }, + "description": "Deco framework binding for Next.js App Router", + "repository": { + "type": "git", + "url": "https://github.com/decocms/blocks.git", + "directory": "packages/nextjs" + }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./routeHandlers": "./src/routeHandlers.ts", + "./setup": "./src/setup.ts", + "./config": { + "types": "./src/config.d.cts", + "default": "./src/config.cjs" + }, + "./middleware": "./src/draftMiddleware.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/nextjs/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/blocks-admin": "workspace:*" + }, + "peerDependencies": { + "next": ">=15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "next": "^15.4.0", + "typescript": "^5.9.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + } } diff --git a/packages/nextjs/src/config.cjs b/packages/nextjs/src/config.cjs index 56ee330f..ce1714c9 100644 --- a/packages/nextjs/src/config.cjs +++ b/packages/nextjs/src/config.cjs @@ -12,6 +12,7 @@ * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves * the whole protocol. * 2. transpilePackages for the raw-TS @decocms packages. + * 3. Response headers that keep a draft-preview render out of every cache. */ const DECO_REWRITES = [ { source: "/.decofile", destination: "/deco/decofile" }, @@ -21,8 +22,56 @@ const DECO_REWRITES = [ const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]; +/** + * A draft render must never be cached or indexed: with the pointer in a cookie + * it shares a URL with the published page, so a shared cache keyed on URL alone + * would serve unpublished content to a real visitor. + * + * Two rules, not one: `has` entries within a rule are ANDed, and the two + * signals never coincide. On ENTRY the pointer is in the query and the cookie + * has not been set yet (middleware sets it in the response); on every + * navigation after that it is the cookie and the query is gone. + * + * MEASURED LIMIT — do not assume `no-store` is in force. On a *dynamic* App + * Router response Next reserves `Cache-Control` and `Vary` and overwrites + * whatever we set, from here AND from middleware: the wire shows + * `Cache-Control: no-cache, must-revalidate` and + * `Vary: rsc, next-router-state-tree, …`. Verified that these rules do match + * (a marker header on the same rules came through on both signals and was + * correctly absent otherwise) — it is those two header names specifically that + * Next owns. `X-Robots-Tag` does get through. + * + * Why that is still safe: `no-cache` is not "don't cache", it is "never reuse + * without revalidating with the origin", so a compliant shared cache cannot + * serve a stored draft to a different visitor — it has to ask us first, and we + * answer per-request. That also covers the loss of `Vary: Cookie`. The residual + * risk is a cache that ignores `no-cache`; closing that needs a CDN-level rule, + * which is a deployment concern rather than something this package can set. + * `Cache-Control`/`Vary` are kept here so the intent is explicit and so they + * apply anywhere Next is not overriding them. + */ +const DRAFT_NO_STORE_HEADERS = [ + { key: "Cache-Control", value: "no-store, private" }, + { key: "Vary", value: "Cookie" }, + { key: "X-Robots-Tag", value: "noindex, nofollow" }, +]; + +const DECO_DRAFT_HEADERS = [ + { + source: "/:path*", + has: [{ type: "query", key: "__draft" }], + headers: DRAFT_NO_STORE_HEADERS, + }, + { + source: "/:path*", + has: [{ type: "cookie", key: "__deco_draft" }], + headers: DRAFT_NO_STORE_HEADERS, + }, +]; + function withDeco(nextConfig = {}) { const userRewrites = nextConfig.rewrites; + const userHeaders = nextConfig.headers; return { ...nextConfig, transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])], @@ -31,7 +80,13 @@ function withDeco(nextConfig = {}) { if (Array.isArray(user)) return [...DECO_REWRITES, ...user]; return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] }; }, + async headers() { + const user = typeof userHeaders === "function" ? await userHeaders() : (userHeaders ?? []); + // Draft rules first: a site's own catch-all Cache-Control must not win + // over the one thing standing between a draft and a shared cache. + return [...DECO_DRAFT_HEADERS, ...user]; + }, }; } -module.exports = { withDeco, DECO_REWRITES }; +module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS }; diff --git a/packages/nextjs/src/config.test.ts b/packages/nextjs/src/config.test.ts index 57fb3d24..8eeec50a 100644 --- a/packages/nextjs/src/config.test.ts +++ b/packages/nextjs/src/config.test.ts @@ -45,3 +45,42 @@ describe("withDeco", () => { expect(cfg.transpilePackages).toContain("other"); }); }); + +describe("withDeco draft headers", () => { + it("adds no-store/no-index rules for both draft signals", async () => { + const headers = await withDeco({}).headers!(); + // Two rules, not one: `has` entries within a rule are ANDed, and the query + // (entry) and cookie (navigation) signals never coincide. + expect(headers).toHaveLength(2); + expect(headers[0].has).toEqual([{ type: "query", key: "__draft" }]); + expect(headers[1].has).toEqual([{ type: "cookie", key: "__deco_draft" }]); + + for (const rule of headers) { + const byKey = Object.fromEntries(rule.headers.map((h) => [h.key, h.value])); + // `no-store` is what actually stops a shared cache serving unpublished + // content to a real visitor — the pointer being a cookie means draft and + // published share a URL. + expect(byKey["Cache-Control"]).toBe("no-store, private"); + expect(byKey.Vary).toBe("Cookie"); + expect(byKey["X-Robots-Tag"]).toBe("noindex, nofollow"); + } + }); + + it("keeps a site's own headers, with the draft rules taking precedence", async () => { + const cfg = withDeco({ + headers: async () => [ + { source: "/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=3600" }] }, + ], + }); + const headers = await cfg.headers!(); + expect(headers).toHaveLength(3); + // A site's catch-all cache policy must not out-rank the draft rules. + expect(headers[0].has).toBeDefined(); + expect(headers[1].has).toBeDefined(); + expect(headers[2].headers[0].value).toBe("public, max-age=3600"); + }); + + it("leaves a site without headers() untouched apart from the draft rules", async () => { + expect(await withDeco({}).headers!()).toHaveLength(2); + }); +}); diff --git a/packages/nextjs/src/createDecoPage.draft.test.tsx b/packages/nextjs/src/createDecoPage.draft.test.tsx new file mode 100644 index 00000000..50ddcb72 --- /dev/null +++ b/packages/nextjs/src/createDecoPage.draft.test.tsx @@ -0,0 +1,89 @@ +/** + * Draft-preview wiring in `createDecoPage`. + * + * Separate from `createDecoPage.test.tsx` because this file has to mock + * `next/headers`, and that mock would otherwise apply to the plain + * (non-draft) cases too — which are worth keeping honest about never + * touching a dynamic API at all. + */ + +import { registerSections, setBlocks } from "@decocms/blocks/cms"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The gate must stop us reaching cookies() at all when the feature is off. +const cookiesMock = vi.fn(async () => ({ get: () => undefined })); +vi.mock("next/headers", () => ({ cookies: () => cookiesMock() })); + +// `connection()` is Next's runtime opt-out from static rendering; outside a +// request scope it throws, so it is stubbed to a no-op here. +const connectionMock = vi.fn(async () => {}); +vi.mock("next/server", () => ({ connection: () => connectionMock() })); + +const { createDecoPage } = await import("./createDecoPage"); + +function Hero({ label }: { label?: string }) { + return

{`hero-${label ?? "none"}`}

; +} + +function seedPublishedHome() { + registerSections({ "site/sections/DraftHero.tsx": async () => ({ default: Hero }) }); + setBlocks({ + "pages-home": { + path: "/", + sections: [{ __resolveType: "site/sections/DraftHero.tsx", label: "published" }], + }, + }); +} + +afterEach(() => { + cookiesMock.mockClear(); + connectionMock.mockClear(); + delete process.env.DECO_DRAFT_PREVIEW; + delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES; +}); + +describe("createDecoPage — draft preview gate", () => { + it("never reads cookies() when the feature is off", async () => { + // Load-bearing, not a micro-optimisation: cookies() is a dynamic API, so + // calling it unconditionally would opt EVERY page of EVERY site out of + // static/ISR rendering the moment this package is upgraded. + seedPublishedHome(); + + const { default: Page } = createDecoPage({ siteName: "test-site" }); + const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) })); + + expect(html).toContain("hero-published"); + expect(cookiesMock).not.toHaveBeenCalled(); + expect(connectionMock).not.toHaveBeenCalled(); + }); + + it("stays off when the flag is set but no origin suffix is configured", async () => { + // Both halves are required — a bare flag with nowhere to fetch from is + // inert, mirroring Fast Deploy's opt-in. + process.env.DECO_DRAFT_PREVIEW = "1"; + seedPublishedHome(); + + const { default: Page } = createDecoPage({ siteName: "test-site" }); + const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) })); + + expect(html).toContain("hero-published"); + expect(cookiesMock).not.toHaveBeenCalled(); + }); + + it("reads cookies() once the feature is fully configured", async () => { + process.env.DECO_DRAFT_PREVIEW = "1"; + process.env.DECO_SANDBOX_ORIGIN_SUFFIXES = ".preview-studio.decocms.com"; + seedPublishedHome(); + + const { default: Page } = createDecoPage({ siteName: "test-site" }); + const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) })); + + // No draft pointer on the request, so the page still renders published — + // enabling the feature must not change what an ordinary visitor sees. + expect(html).toContain("hero-published"); + expect(cookiesMock).toHaveBeenCalled(); + // Nothing was bound, so no need to force the page dynamic. + expect(connectionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/src/createDecoPage.tsx b/packages/nextjs/src/createDecoPage.tsx index b782381c..f22808ba 100644 --- a/packages/nextjs/src/createDecoPage.tsx +++ b/packages/nextjs/src/createDecoPage.tsx @@ -1,14 +1,17 @@ -import { cache } from "react"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next"; import { + type DecoPageResult, extractSeoFromProps, extractSeoFromSections, - resolveDecoPage, - type DecoPageResult, + isDraftPreviewEnabled, type PageSeo, + resolveDecoPage, } from "@decocms/blocks/cms"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { cache } from "react"; import { DecoPageRenderer } from "./DecoPageRenderer"; +import { type DraftSearchParams, ensureDraft } from "./draft"; interface CreateDecoPageOptions { siteName: string; @@ -16,6 +19,12 @@ interface CreateDecoPageOptions { interface PageProps { params: Promise<{ slug?: string[] }>; + /** + * Next always supplies this; optional here so existing callers (and unit + * tests) that construct props by hand keep compiling. Draft preview reads + * `?__draft=` from it — see bindDraftOnce. + */ + searchParams?: Promise; } function pathFromSlug(slug: string[] | undefined): string { @@ -67,8 +76,47 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) { const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {})); - async function generateMetadata({ params }: PageProps): Promise { + /** + * Bind this request's draft decofile, at most once, BEFORE anything resolves + * a page. + * + * Ordering is load-bearing twice over: + * + * 1. `resolveDecoPage` calls `loadBlocks()`, so a draft bound afterwards + * would resolve the page against published content and silently render + * the wrong thing. + * 2. `resolveForPath` is `cache()`d per request and Next may run + * `generateMetadata` and the page body concurrently — whichever resolves + * first wins for both. So this must be awaited at the top of BOTH, not + * just the page. `cache()` here makes the second call free. + * + * Gated on `isDraftPreviewEnabled()` — a plain env read, not a dynamic API — + * so sites that never opt in behave exactly as before. That gate is doing + * real work: `cookies()` and `searchParams` are both dynamic, and touching + * them unconditionally would opt EVERY page out of static/ISR rendering. + * + * KNOWN COST: with the flag on, every page served by this route becomes + * dynamic, because reading the pointer requires `cookies()`. Acceptable + * while the feature is opt-in; the fix, when it needs to run on a + * statically-rendered production site, is for middleware to rewrite draft + * requests onto a separate dynamic route so ordinary traffic keeps its cache. + */ + const bindDraftOnce = cache(async (searchParams?: DraftSearchParams): Promise => { + if (!isDraftPreviewEnabled()) return false; + + const bound = await ensureDraft(searchParams); + + // A draft render must never be cached — ISR or the Full Route Cache would + // hand unpublished content to a real visitor. `revalidate` is a static + // export and cannot vary per request, so opt out at runtime instead. + if (bound) await connection(); + + return bound; + }); + + async function generateMetadata({ params, searchParams }: PageProps): Promise { const { slug } = await params; + await bindDraftOnce(await searchParams); const page = await resolveForPath(pathFromSlug(slug)); if (!page) return {}; @@ -81,9 +129,11 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) { }; } - async function Page({ params }: PageProps) { + async function Page({ params, searchParams }: PageProps) { const { slug } = await params; const pathname = pathFromSlug(slug); + // Before resolveForPath — see bindDraftOnce. + await bindDraftOnce(await searchParams); const page = await resolveForPath(pathname); if (!page) notFound(); diff --git a/packages/nextjs/src/draft.test.ts b/packages/nextjs/src/draft.test.ts new file mode 100644 index 00000000..7345cee6 --- /dev/null +++ b/packages/nextjs/src/draft.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { DRAFT_COOKIE_OPTIONS, DRAFT_PARAM, decideDraft } from "./draft"; + +function urlWith(param?: string): URL { + const url = new URL("https://site.example/some/page"); + if (param !== undefined) url.searchParams.set(DRAFT_PARAM, param); + return url; +} + +describe("decideDraft", () => { + it("enters draft mode from the param and sets the cookie", () => { + expect(decideDraft(urlWith("abc@v1"), null)).toEqual({ + pointer: "abc@v1", + setCookie: "abc@v1", + clearCookie: false, + }); + }); + + it("carries the draft across navigation via the cookie alone", () => { + // The param is gone — this is the in-preview link click that a + // param-only design would silently drop back to published. + expect(decideDraft(urlWith(), "abc@v1")).toEqual({ + pointer: "abc@v1", + setCookie: null, + clearCookie: false, + }); + }); + + it("lets the param override a stale cookie", () => { + // Studio navigating to a newer version must win over the older pointer + // still sitting in the cookie, or a save would never be reflected. + expect(decideDraft(urlWith("abc@v2"), "abc@v1")).toEqual({ + pointer: "abc@v2", + setCookie: "abc@v2", + clearCookie: false, + }); + }); + + it("leaves draft mode on ?__draft=off, even with a cookie set", () => { + expect(decideDraft(urlWith("off"), "abc@v1")).toEqual({ + pointer: null, + setCookie: null, + clearCookie: true, + }); + }); + + it("is inert for an ordinary request", () => { + expect(decideDraft(urlWith(), null)).toEqual({ + pointer: null, + setCookie: null, + clearCookie: false, + }); + }); +}); + +describe("DRAFT_COOKIE_OPTIONS", () => { + it("is set up to survive a cross-site preview iframe", () => { + // The preview iframe is cross-site (Studio embeds the production origin), + // so the cookie is third-party. Without SameSite=None+Secure it is never + // sent; without Partitioned (CHIPS) it is dropped as browsers wind down + // unpartitioned third-party cookies. These attributes are load-bearing, + // not incidental. + expect(DRAFT_COOKIE_OPTIONS.sameSite).toBe("none"); + expect(DRAFT_COOKIE_OPTIONS.secure).toBe(true); + expect(DRAFT_COOKIE_OPTIONS.partitioned).toBe(true); + expect(DRAFT_COOKIE_OPTIONS.httpOnly).toBe(true); + }); + + it("is short-lived so a stale pointer can't pin an old version", () => { + expect(DRAFT_COOKIE_OPTIONS.maxAge).toBeLessThanOrEqual(60 * 60); + }); +}); diff --git a/packages/nextjs/src/draft.ts b/packages/nextjs/src/draft.ts new file mode 100644 index 00000000..c9c6d341 --- /dev/null +++ b/packages/nextjs/src/draft.ts @@ -0,0 +1,188 @@ +/** + * Draft preview — the Next.js binding. + * + * `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (pointer + * parsing, origin construction, fetch, version cache). This file binds a + * resolved draft to the current request. + * + * ## Why React `cache()` and not AsyncLocalStorage + * + * App Router never enters `RequestContext.run` (that is a TanStack/Workers + * path), and ALS cannot help here anyway: you cannot wrap `ALS.run()` around a + * component's children, because the children render later, outside the call. + * `cache()` gives a per-request memoized value in RSC, which is exactly the + * scope needed — verified concurrently, see `draft.test.ts`. + * + * ## Why this must be awaited by the PAGE, not a layout + * + * A layout's `await` does NOT gate its children: App Router renders a layout + * and its children concurrently, so sections call `loadBlocks()` before a + * layout-level resolve lands, and silently render published content. The + * resolve has to happen somewhere that returns the subtree *after* awaiting — + * i.e. the page component. `ensureDraft()` exists so each site makes one call + * instead of re-deriving that ordering rule. + */ +import { resolveDraftDecofile, setDraftOverrideGetter } from "@decocms/blocks/cms"; +import { cookies } from "next/headers"; +import { cache } from "react"; + +/** Cookie that carries the pointer across in-preview navigation. */ +export const DRAFT_COOKIE = "__deco_draft"; +/** Query param that enters draft mode (and, with `off`, leaves it). */ +export const DRAFT_PARAM = "__draft"; + +/** + * Request-scoped slot. + * + * `cache()` memoizes per-request, so every call within one request gets the + * same object and a different one per request. Mutable by design: the page + * fills it before returning its subtree, and nested sections read it + * synchronously through `loadBlocks()`. + */ +const draftSlot = cache((): { blocks: Record | null } => ({ + blocks: null, +})); + +/** + * Register the request-scoped getter with the runtime. + * + * Idempotent and safe to call at module scope: outside a request `cache()` + * still returns an object, whose `blocks` is null, so `loadBlocks()` sees no + * override and behaves exactly as before. + */ +let registered = false; +export function registerDraftOverride(): void { + if (registered) return; + registered = true; + setDraftOverrideGetter(() => draftSlot().blocks); +} + +/** A page's `searchParams` prop, before it is narrowed. */ +export type DraftSearchParams = Record; + +/** First value of a search param that may legitimately repeat. */ +function firstParam(searchParams: DraftSearchParams | undefined, key: string): string | null { + const raw = searchParams?.[key]; + if (Array.isArray(raw)) return raw[0] ?? null; + return raw ?? null; +} + +/** + * The pointer for this request: the query param wins, the cookie carries + * navigation, `off` exits. + * + * Read directly from the page rather than trusted from a request header. The + * param has to take precedence or a save would never surface — Studio + * navigating to a new version would keep rendering whatever older pointer is + * still sitting in the cookie. + */ +export function selectDraftPointer( + searchParams: DraftSearchParams | undefined, + cookieValue: string | null | undefined, +): string | null { + const param = firstParam(searchParams, DRAFT_PARAM); + if (param === "off") return null; + if (param) return param; + return cookieValue ?? null; +} + +/** + * Resolve the request's draft, if any, and bind it for the rest of the render. + * + * Call this from the PAGE, awaited, before returning the subtree: + * + * ```tsx + * export default async function Page({ searchParams }) { + * await ensureDraft(await searchParams); + * return ; + * } + * ``` + * + * Reads the param from the page's own `searchParams` and the cookie via + * `cookies()`. There is deliberately no request header in the path: the page + * owns the decision, so a draft keeps working on routes the middleware matcher + * never sees, and there is one less forgeable input to reason about. (The + * pointer was never a secret — the sandbox handle is the capability — so a + * client supplying one directly is equivalent to typing the query param.) + * + * Returns whether a draft was bound, so callers can surface an explicit + * "draft unavailable" state instead of silently showing published content — + * the failure mode most likely to mislead someone reviewing their own edits. + */ +export async function ensureDraft(searchParams?: DraftSearchParams): Promise { + registerDraftOverride(); + + const cookieStore = await cookies(); + const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value); + if (!pointer) return false; + + const blocks = await resolveDraftDecofile({ pointer }); + if (!blocks) return false; + + draftSlot().blocks = blocks; + return true; +} + +// --------------------------------------------------------------------------- +// Middleware helper +// --------------------------------------------------------------------------- + +/** What middleware should do with this request. */ +export interface DraftMiddlewareDecision { + /** The active pointer, or null. Drives the cache/indexing headers. */ + pointer: string | null; + /** Set the cookie to this value (entering draft mode). */ + setCookie: string | null; + /** Clear the cookie (leaving draft mode via `?__draft=off`). */ + clearCookie: boolean; +} + +/** + * Decide the draft state for a request, from `?__draft=` and the cookie. + * + * The param is authoritative on entry and the cookie carries subsequent + * in-preview navigation — a param alone dies on the first link click, and a + * cookie alone cannot be relied on: the preview iframe is cross-site, so the + * cookie is third-party and may be blocked outright (Safari ITP) or + * partitioned (Chrome CHIPS). Entry therefore never depends on cookie support. + * + * `?__draft=off` leaves draft mode, so a session can be ended deliberately + * rather than waiting for a cookie to expire. + * + * Pure and framework-free so it can be unit-tested without a Next request; the + * caller applies the decision to its own `NextResponse`. + */ +export function decideDraft( + url: URL, + cookieValue: string | null | undefined, +): DraftMiddlewareDecision { + const param = url.searchParams.get(DRAFT_PARAM); + + if (param === "off") { + return { pointer: null, setCookie: null, clearCookie: true }; + } + if (param) { + return { pointer: param, setCookie: param, clearCookie: false }; + } + if (cookieValue) { + return { pointer: cookieValue, setCookie: null, clearCookie: false }; + } + return { pointer: null, setCookie: null, clearCookie: false }; +} + +/** + * Cookie attributes for the draft pointer. + * + * `SameSite=None; Secure` is mandatory for a cross-site iframe, and + * `Partitioned` (CHIPS) is what keeps it working as browsers wind down + * unpartitioned third-party cookies. Short-lived: a draft session is minutes, + * and a stale pointer would keep pinning an old version. + */ +export const DRAFT_COOKIE_OPTIONS = { + httpOnly: true, + secure: true, + sameSite: "none", + partitioned: true, + path: "/", + maxAge: 60 * 30, +} as const; diff --git a/packages/nextjs/src/draftMiddleware.test.ts b/packages/nextjs/src/draftMiddleware.test.ts new file mode 100644 index 00000000..9735d63a --- /dev/null +++ b/packages/nextjs/src/draftMiddleware.test.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { describe, expect, it } from "vitest"; + +import { DRAFT_COOKIE } from "./draft"; +import { applyDraft, prepareDraft } from "./draftMiddleware"; + +function request(url: string, cookie?: string): NextRequest { + const req = new NextRequest(new URL(url), { + headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {}, + }); + return req; +} + +describe("prepareDraft", () => { + it("reads the pointer from the param", () => { + expect(prepareDraft(request("https://site.example/p?__draft=abc@v1")).pointer).toBe("abc@v1"); + }); + + it("reads the pointer from the cookie on a plain navigation", () => { + expect(prepareDraft(request("https://site.example/p", "abc@v1")).pointer).toBe("abc@v1"); + }); + + it("ignores a client-supplied draft header — the page owns the decision", () => { + // There is no request-header path any more; the page reads searchParams + // and cookies itself, so a forged header is simply not an input. + const req = new NextRequest(new URL("https://site.example/p"), { + headers: { "x-deco-draft": "attacker@v1" }, + }); + expect(prepareDraft(req).pointer).toBeNull(); + }); +}); + +describe("applyDraft", () => { + it("sets a partitioned cross-site cookie when entering draft mode", () => { + const decision = prepareDraft(request("https://site.example/p?__draft=abc@v1")); + const res = applyDraft(NextResponse.next(), decision); + + const setCookie = res.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain(`${DRAFT_COOKIE}=abc%40v1`); + // The preview iframe is cross-site, so these attributes are what make the + // cookie survive at all — not hardening extras. + expect(setCookie).toMatch(/SameSite=None/i); + expect(setCookie).toMatch(/Secure/i); + expect(setCookie).toMatch(/Partitioned/i); + expect(setCookie).toMatch(/HttpOnly/i); + }); + + it("marks a draft response uncacheable and unindexable", () => { + // With the pointer in a cookie, draft and published share a URL — a CDN + // keyed on URL alone would serve unpublished content to a real visitor. + const decision = prepareDraft(request("https://site.example/p", "abc@v1")); + const res = applyDraft(NextResponse.next(), decision); + + expect(res.headers.get("cache-control")).toBe("no-store, private"); + expect(res.headers.get("vary")).toBe("Cookie"); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + }); + + it("leaves an ordinary response completely untouched", () => { + const decision = prepareDraft(request("https://site.example/p")); + const res = applyDraft(NextResponse.next(), decision); + + expect(res.headers.get("cache-control")).toBeNull(); + expect(res.headers.get("vary")).toBeNull(); + expect(res.headers.get("x-robots-tag")).toBeNull(); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + it("clears the cookie on ?__draft=off", () => { + const decision = prepareDraft(request("https://site.example/p?__draft=off", "abc@v1")); + expect(decision.clearCookie).toBe(true); + + const res = applyDraft(NextResponse.next(), decision); + const setCookie = res.headers.get("set-cookie") ?? ""; + // Deletion is a set with an immediate expiry. + expect(setCookie).toContain(DRAFT_COOKIE); + expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i); + // And the response is not treated as a draft render. + expect(res.headers.get("cache-control")).toBeNull(); + }); +}); diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts new file mode 100644 index 00000000..7e971767 --- /dev/null +++ b/packages/nextjs/src/draftMiddleware.ts @@ -0,0 +1,79 @@ +/** + * Draft preview — middleware half. + * + * Two composable calls, because sites already own their middleware and this + * must slot into it rather than replace it: + * + * ```ts + * // middleware.ts (Next <16) / proxy.ts (Next 16+) + * export function middleware(request: NextRequest) { + * const decision = prepareDraft(request); + * return applyDraft(NextResponse.next(), decision); + * } + * ``` + * + * The middleware owns only the cookie and the cache/indexing headers — a + * Server Component can read cookies but cannot set them, so that part has to + * live here. The pointer itself is read by the page. + * + * Lives on its own subpath so middleware (edge runtime) never imports the root + * barrel, which pulls in the client component graph. + */ +import { type NextRequest, NextResponse } from "next/server"; + +import { + DRAFT_COOKIE, + DRAFT_COOKIE_OPTIONS, + type DraftMiddlewareDecision, + decideDraft, +} from "./draft"; + +/** + * Compute the draft decision for a request. + * + * Only the cookie and the response headers are the middleware's business now — + * the page reads the pointer itself from `searchParams` + `cookies()`, so + * nothing has to be forwarded through the request. That also means a draft + * still works on routes this middleware never matches. + */ +export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { + return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null); +} + +/** + * Apply the cookie and the cache/indexing headers a draft response requires. + * + * The caching headers are the difference between a preview and a **leak**. + * With the pointer in a cookie, a draft response and a published one share an + * identical URL, so a CDN keyed on URL alone would happily serve unpublished + * content to a real visitor. `no-store` is what actually prevents that; + * `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the + * two; `X-Robots-Tag` keeps a leaked draft out of search results. + */ +export function applyDraft( + response: NextResponse, + decision: DraftMiddlewareDecision, +): NextResponse { + if (decision.clearCookie) { + response.cookies.delete(DRAFT_COOKIE); + } else if (decision.setCookie) { + response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS); + } + + if (decision.pointer) { + response.headers.set("cache-control", "no-store, private"); + response.headers.set("vary", "Cookie"); + response.headers.set("x-robots-tag", "noindex, nofollow"); + } + + return response; +} + +/** + * Convenience for sites with no middleware of their own: prepare, continue, + * apply. Sites that already have middleware should call the two halves + * directly so their own logic sits in between. + */ +export function draftMiddleware(request: NextRequest): NextResponse { + return applyDraft(NextResponse.next(), prepareDraft(request)); +} diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 5392a1d7..cb08f73e 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -8,6 +8,17 @@ export { export { DecoPageRenderer } from "./DecoPageRenderer"; export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout"; export { DeferredSectionBoundary } from "./DeferredSection"; +export { + DRAFT_COOKIE, + DRAFT_COOKIE_OPTIONS, + DRAFT_PARAM, + type DraftMiddlewareDecision, + type DraftSearchParams, + decideDraft, + ensureDraft, + registerDraftOverride, + selectDraftPointer, +} from "./draft"; export { decofileGET, decofilePOST, From b7dd119704698ab502a9089b226cbb94943cc333 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 19:59:11 -0300 Subject: [PATCH 2/7] feat(nextjs): divert drafted requests to a dynamic route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `rewriteToDraftRoute`, so a site can keep its real routes statically rendered while still serving drafts. `dynamic` and `revalidate` are STATIC route exports — a page cannot become dynamic for a single request. Without this, supporting drafts means making the real route dynamic, which costs every visitor their cached page and, worse, lets a statically rendered draft be cached and served to them. Rewriting sends only drafted requests to a route that is dynamic by construction; ordinary traffic gets null back and falls straight through, untouched. The prefix is `/_draft` at the URL level, but a site's directory for it must be URL-encoded (`app/%5Fdraft/...`). Next treats a literal `_` prefix as a private folder and excludes it from routing, so a rewrite to `/_draft/...` silently falls through to whatever catch-all follows and 404s. Verified the hard way. Documented on the constant so the next person doesn't repeat it. Guards against nesting the prefix if middleware re-runs on the rewritten URL, and does not rewrite when leaving draft mode. Co-Authored-By: Claude Opus 5 (1M context) --- packages/nextjs/src/draftMiddleware.test.ts | 38 ++++++++++++++++++++- packages/nextjs/src/draftMiddleware.ts | 38 +++++++++++++++++++++ packages/nextjs/src/index.ts | 6 ++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/draftMiddleware.test.ts b/packages/nextjs/src/draftMiddleware.test.ts index 9735d63a..71be2556 100644 --- a/packages/nextjs/src/draftMiddleware.test.ts +++ b/packages/nextjs/src/draftMiddleware.test.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { describe, expect, it } from "vitest"; import { DRAFT_COOKIE } from "./draft"; -import { applyDraft, prepareDraft } from "./draftMiddleware"; +import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware"; function request(url: string, cookie?: string): NextRequest { const req = new NextRequest(new URL(url), { @@ -79,3 +79,39 @@ describe("applyDraft", () => { expect(res.headers.get("cache-control")).toBeNull(); }); }); + +describe("rewriteToDraftRoute", () => { + it("rewrites a drafted request onto the dynamic draft route", () => { + const req = request("https://site.example/blog/hello?__draft=abc@v1"); + const res = rewriteToDraftRoute(req, prepareDraft(req)); + const target = new URL(res?.headers.get("x-middleware-rewrite") ?? ""); + expect(target.pathname).toBe("/_draft/blog/hello"); + // The pointer must survive: the draft page reads it from searchParams. + expect(target.searchParams.get("__draft")).toBe("abc@v1"); + }); + + it("rewrites a cookie-only navigation too", () => { + const req = request("https://site.example/blog/hello", "abc@v1"); + const res = rewriteToDraftRoute(req, prepareDraft(req)); + expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe( + "/_draft/blog/hello", + ); + }); + + it("leaves an ordinary request alone — ISR must not be disturbed", () => { + // The whole point: only drafted requests go dynamic. A shopper's request + // must reach the real, statically rendered route untouched. + const req = request("https://site.example/blog/hello"); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); + + it("never nests the prefix when middleware re-runs on the rewritten URL", () => { + const req = request("https://site.example/_draft/blog/hello?__draft=abc@v1"); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); + + it("does not rewrite when leaving draft mode", () => { + const req = request("https://site.example/blog/hello?__draft=off", "abc@v1"); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); +}); diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts index 7e971767..7fe84630 100644 --- a/packages/nextjs/src/draftMiddleware.ts +++ b/packages/nextjs/src/draftMiddleware.ts @@ -40,6 +40,44 @@ export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null); } +/** + * Route prefix a drafted request is rewritten onto. + * + * Deliberately a `_`-prefixed segment: Next treats those as private folders and + * excludes them from routing, so a site cannot accidentally expose it as a real + * page — it is reachable only through the middleware rewrite below. + */ +export const DRAFT_ROUTE_PREFIX = "/_draft"; + +/** + * Rewrite a drafted request onto the dynamic draft route. + * + * This exists because `dynamic` / `revalidate` are STATIC route exports: a + * statically rendered page cannot become dynamic for one request. Making the + * real route dynamic to support drafts would cost every shopper their cached + * page, and — worse — a statically rendered draft would be cached and served to + * them. Rewriting sends only drafted requests to a route that is dynamic by + * construction, leaving ordinary traffic's ISR completely untouched. + * + * The original path is preserved in the rewritten pathname, so the draft route + * can resolve exactly the page the visitor asked for. + * + * Returns null when this request is not drafted, so callers fall through to + * their normal response. + */ +export function rewriteToDraftRoute( + request: NextRequest, + decision: DraftMiddlewareDecision, +): NextResponse | null { + if (!decision.pointer) return null; + const url = request.nextUrl.clone(); + // Already rewritten (Next re-runs middleware on the rewritten URL in some + // configurations) — never nest the prefix. + if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null; + url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`; + return NextResponse.rewrite(url); +} + /** * Apply the cookie and the cache/indexing headers a draft response requires. * diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index cb08f73e..788a4ccf 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -19,6 +19,12 @@ export { registerDraftOverride, selectDraftPointer, } from "./draft"; +export { + applyDraft, + DRAFT_ROUTE_PREFIX, + prepareDraft, + rewriteToDraftRoute, +} from "./draftMiddleware"; export { decofileGET, decofilePOST, From 0538899010433781372e4320f77f6eedf6afdc52 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 21:38:56 -0300 Subject: [PATCH 3/7] fix(blocks): try every configured sandbox origin suffix, not only the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deployment can legitimately serve sandboxes from more than one origin (cluster and desktop-link), and a handle alone does not say which suffix it lives under. `resolveDraftDecofile` now tries the configured suffixes in order; a miss on one origin — unreachable, non-2xx, unparseable — falls through to the next instead of aborting the resolve. Previously only the first entry was consulted, silently, while the option read as a list. Also corrects the DRAFT_ROUTE_PREFIX doc, which claimed a literal `_draft` folder "cannot be exposed as a real page": true, but for the wrong reason — Next excludes `_`-prefixed folders from routing entirely, so the rewrite target must be mounted URL-encoded (`app/%5Fdraft/...`) or every drafted request 404s through the site's catch-all. Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/src/cms/draftSource.test.ts | 30 +++++++++- packages/blocks/src/cms/draftSource.ts | 62 ++++++++++++--------- packages/nextjs/src/draftMiddleware.ts | 10 ++-- 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/packages/blocks/src/cms/draftSource.test.ts b/packages/blocks/src/cms/draftSource.test.ts index ce994e77..18d66e55 100644 --- a/packages/blocks/src/cms/draftSource.test.ts +++ b/packages/blocks/src/cms/draftSource.test.ts @@ -57,17 +57,17 @@ describe("parseDraftPointer", () => { describe("buildDraftOrigin", () => { it("builds https from the configured suffix", () => { - expect(buildDraftOrigin("abc", [".preview-studio.decocms.com"])).toBe( + expect(buildDraftOrigin("abc", ".preview-studio.decocms.com")).toBe( "https://abc.preview-studio.decocms.com", ); }); it("uses http for a localhost suffix (local e2e)", () => { - expect(buildDraftOrigin("abc", [".localhost:3200"])).toBe("http://abc.localhost:3200"); + expect(buildDraftOrigin("abc", ".localhost:3200")).toBe("http://abc.localhost:3200"); }); it("returns null with no configured suffix — never guesses an origin", () => { - expect(buildDraftOrigin("abc", [])).toBeNull(); + expect(buildDraftOrigin("abc", "")).toBeNull(); }); }); @@ -152,6 +152,30 @@ describe("resolveDraftDecofile", () => { expect(fetches).toBe(5); }); + it("falls through the suffix list until one origin answers", async () => { + // A deployment can serve sandboxes from more than one origin (cluster and + // desktop-link); the handle doesn't say which. The first suffix here is + // unreachable — the resolver must try the next, not give up. + const calls: string[] = []; + const blocks = await resolveDraftDecofile({ + pointer: "abc@v1", + env: { + DECO_DRAFT_PREVIEW: "1", + DECO_SANDBOX_ORIGIN_SUFFIXES: ".dead.example, .alive.example", + }, + fetchImpl: (async (url: string) => { + calls.push(String(url)); + if (String(url).includes("dead")) throw new Error("ECONNREFUSED"); + return jsonResponse({ ok: true }); + }) as unknown as typeof fetch, + }); + expect(blocks).toEqual({ ok: true }); + expect(calls).toEqual([ + "https://abc.dead.example/_sandbox/decofile", + "https://abc.alive.example/_sandbox/decofile", + ]); + }); + it("degrades to published on a malformed pointer, without fetching", async () => { let called = false; const blocks = await resolveDraftDecofile({ diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts index 912d90cd..86c93d59 100644 --- a/packages/blocks/src/cms/draftSource.ts +++ b/packages/blocks/src/cms/draftSource.ts @@ -54,7 +54,13 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer return { handle, version }; } -/** Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. */ +/** + * Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. + * + * Tried in order until one answers — a deployment can legitimately serve + * sandboxes from more than one origin (cluster and desktop-link), and the + * handle alone does not say which one it lives under. + */ function readSuffixes(env: Record): string[] { return (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "") .split(",") @@ -63,14 +69,13 @@ function readSuffixes(env: Record): string[] { } /** - * Build the sandbox origin for a handle. + * Build the sandbox origin for a handle under ONE configured suffix. * - * The origin comes from CONFIGURED suffixes, never from caller input, so there - * is no SSRF surface to defend and no allowlist to keep correct. A `localhost` + * The origin comes from configuration, never from caller input, so there is no + * SSRF surface to defend and no allowlist to keep correct. A `localhost` * suffix (local e2e) speaks http; everything else is https. */ -export function buildDraftOrigin(handle: string, suffixes: string[]): string | null { - const suffix = suffixes[0]; +export function buildDraftOrigin(handle: string, suffix: string): string | null { if (!suffix) return null; if (!HANDLE_RE.test(handle)) return null; const scheme = suffix.includes("localhost") ? "http" : "https"; @@ -137,27 +142,34 @@ export async function resolveDraftDecofile( const cached = byVersion.get(parsed.version); if (cached) return cached; - const origin = buildDraftOrigin(parsed.handle, readSuffixes(env)); - if (!origin) return null; - const doFetch = options.fetchImpl ?? fetch; - let res: Response; - try { - res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" }); - } catch { - return null; + // Suffixes are tried in order; the first that answers with parseable JSON + // wins. A miss on one origin (unreachable, non-2xx, garbage) is expected — + // the handle only exists under one of them — so every failure falls through + // to the next rather than aborting the resolve. + for (const suffix of readSuffixes(env)) { + const origin = buildDraftOrigin(parsed.handle, suffix); + if (!origin) continue; + + let res: Response; + try { + res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" }); + } catch { + continue; + } + if (!res.ok) continue; + + let blocks: Record; + try { + blocks = (await res.json()) as Record; + } catch { + continue; + } + + cacheDraft(parsed.version, blocks); + return blocks; } - if (!res.ok) return null; - - let blocks: Record; - try { - blocks = (await res.json()) as Record; - } catch { - return null; - } - - cacheDraft(parsed.version, blocks); - return blocks; + return null; } /** True when the feature is switched on and configured. Inert otherwise. */ diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts index 7fe84630..db76cd25 100644 --- a/packages/nextjs/src/draftMiddleware.ts +++ b/packages/nextjs/src/draftMiddleware.ts @@ -41,11 +41,13 @@ export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { } /** - * Route prefix a drafted request is rewritten onto. + * URL prefix a drafted request is rewritten onto. * - * Deliberately a `_`-prefixed segment: Next treats those as private folders and - * excludes them from routing, so a site cannot accidentally expose it as a real - * page — it is reachable only through the middleware rewrite below. + * The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded. + * A literal `_draft/` directory is a Next "private folder", excluded from + * routing entirely, so the rewrite would fall through to whatever catch-all + * follows and 404. The encoded directory serves the same `/_draft` URL while + * staying routable. */ export const DRAFT_ROUTE_PREFIX = "/_draft"; From e029ef91b847139c09b54113379470b0db388269 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 22:05:20 -0300 Subject: [PATCH 4/7] feat!: gate draft preview by request host instead of a boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DECO_DRAFT_PREVIEW=1` becomes `DECO_DRAFT_PREVIEW_HOST=`. A boolean cannot express the common deployment shape: one build serving several domains, of which only the preview domain may render drafts. Host scoping makes the production domain structurally inert — `fila.com.br?__draft=` does nothing on the same build that previews on `fila.vtex.app` — while keeping the upgrade invariant: no host configured, feature completely inert, no dynamic API touched, static/ISR rendering untouched. The gate is enforced twice: middleware returns an inert decision on a non-allowed host (no cookie, no rewrite, no headers), and ensureDraft re-checks before binding — the header is read only once a pointer exists, so the check itself costs no dynamic rendering. Hosts match verbatim (port included) and case-insensitively against `x-forwarded-host ?? host ?? nextUrl.host`; the header is spoofable by direct-to-origin calls, but the sandbox handle remains the capability — host scoping bounds blast radius, it is not a secret. `DECO_SANDBOX_ORIGIN_SUFFIXES` now defaults to the deco-operated origins (.preview-studio.decocms.com, .local.studio.decocms.com, .localhost) and the env becomes an override. Shipping defaults adds no SSRF surface — the origin is still configuration, never caller input. BREAKING CHANGE: DECO_DRAFT_PREVIEW is removed; sites must set DECO_DRAFT_PREVIEW_HOST to enable draft preview. Beta-only surface. Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/src/cms/draftSource.test.ts | 49 +++++++++-- packages/blocks/src/cms/draftSource.ts | 84 +++++++++++++++---- packages/blocks/src/cms/index.ts | 2 + .../nextjs/src/createDecoPage.draft.test.tsx | 10 ++- packages/nextjs/src/createDecoPage.tsx | 3 +- packages/nextjs/src/draft.ts | 16 +++- packages/nextjs/src/draftMiddleware.test.ts | 18 +++- packages/nextjs/src/draftMiddleware.ts | 13 ++- 8 files changed, 163 insertions(+), 32 deletions(-) diff --git a/packages/blocks/src/cms/draftSource.test.ts b/packages/blocks/src/cms/draftSource.test.ts index 18d66e55..89499e05 100644 --- a/packages/blocks/src/cms/draftSource.test.ts +++ b/packages/blocks/src/cms/draftSource.test.ts @@ -3,13 +3,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { buildDraftOrigin, clearDraftCache, + DEFAULT_SANDBOX_ORIGIN_SUFFIXES, + isDraftHostAllowed, isDraftPreviewEnabled, parseDraftPointer, resolveDraftDecofile, } from "./draftSource"; const ENV_ON = { - DECO_DRAFT_PREVIEW: "1", + DECO_DRAFT_PREVIEW_HOST: "preview.example", DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com", }; @@ -72,9 +74,12 @@ describe("buildDraftOrigin", () => { }); describe("isDraftPreviewEnabled", () => { - it("needs both the flag and a suffix", () => { + it("is on iff an allowed host is configured — suffixes have defaults", () => { + // Inverted from the old two-key gate: suffixes now default to the + // deco-operated origins, so the single opt-in is the host allowlist. + // Upgrading the package with no host configured stays fully inert. expect(isDraftPreviewEnabled(ENV_ON)).toBe(true); - expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW: "1" })).toBe(false); + expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW_HOST: "a.example" })).toBe(true); expect( isDraftPreviewEnabled({ DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com", @@ -84,7 +89,41 @@ describe("isDraftPreviewEnabled", () => { }); }); +describe("isDraftHostAllowed", () => { + const env = { DECO_DRAFT_PREVIEW_HOST: "fila.vtex.app, localhost:3100" }; + + it("matches listed hosts verbatim, port included, case-insensitively", () => { + expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(true); + expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true); + expect(isDraftHostAllowed("localhost:3100", env)).toBe(true); + }); + + it("rejects everything else — the production domain on the same build", () => { + expect(isDraftHostAllowed("fila.com.br", env)).toBe(false); + expect(isDraftHostAllowed("localhost", env)).toBe(false); // port matters + expect(isDraftHostAllowed(null, env)).toBe(false); + expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false); + }); +}); + describe("resolveDraftDecofile", () => { + it("uses the default deco suffixes when none are configured", async () => { + const calls: string[] = []; + await resolveDraftDecofile({ + pointer: "abc@v1", + env: { DECO_DRAFT_PREVIEW_HOST: "preview.example" }, + fetchImpl: (async (url: string) => { + calls.push(String(url)); + throw new Error("unreachable"); + }) as unknown as typeof fetch, + }); + expect(calls).toEqual( + DEFAULT_SANDBOX_ORIGIN_SUFFIXES.map( + (sfx) => `${sfx.includes("localhost") ? "http" : "https"}://abc${sfx}/_sandbox/decofile`, + ), + ); + }); + it("fetches the sandbox decofile and returns it", async () => { const calls: string[] = []; const blocks = await resolveDraftDecofile({ @@ -100,7 +139,7 @@ describe("resolveDraftDecofile", () => { expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]); }); - it("is inert unless explicitly enabled — no fetch at all", async () => { + it("is inert without a host allowlist — no fetch at all", async () => { let called = false; const blocks = await resolveDraftDecofile({ pointer: "abc@v1", @@ -160,7 +199,7 @@ describe("resolveDraftDecofile", () => { const blocks = await resolveDraftDecofile({ pointer: "abc@v1", env: { - DECO_DRAFT_PREVIEW: "1", + DECO_DRAFT_PREVIEW_HOST: "preview.example", DECO_SANDBOX_ORIGIN_SUFFIXES: ".dead.example, .alive.example", }, fetchImpl: (async (url: string) => { diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts index 86c93d59..4fb6e5ea 100644 --- a/packages/blocks/src/cms/draftSource.ts +++ b/packages/blocks/src/cms/draftSource.ts @@ -14,9 +14,12 @@ * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its * zero-dependency direction. * - * Inert unless BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES` - * are set, mirroring Fast Deploy's opt-in: upgrading the package must never be - * enough to start fetching from the network and rendering unpublished content. + * Inert unless `DECO_DRAFT_PREVIEW_HOST` names the request's host: upgrading + * the package must never be enough to start fetching from the network and + * rendering unpublished content. Host-scoping (rather than a boolean) exists + * because one deployment commonly serves several domains — the preview domain + * may render drafts while the production domain, on the same build, must + * ignore a `?__draft=` entirely. */ /** A parsed `@` draft pointer. */ @@ -55,17 +58,64 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer } /** - * Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. - * - * Tried in order until one answers — a deployment can legitimately serve + * Default sandbox origin suffixes — deco-operated domains, so shipping them as + * defaults adds no SSRF surface: the origin is still configuration, never + * caller input. `DECO_SANDBOX_ORIGIN_SUFFIXES` overrides (e.g. to pin a local + * link port: `.localhost:60534`). + */ +export const DEFAULT_SANDBOX_ORIGIN_SUFFIXES = [ + ".preview-studio.decocms.com", + ".local.studio.decocms.com", + ".localhost", +]; + +/** + * Suffixes are tried in order until one answers — a deployment can serve * sandboxes from more than one origin (cluster and desktop-link), and the * handle alone does not say which one it lives under. */ function readSuffixes(env: Record): string[] { - return (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "") + const configured = (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); + return configured.length > 0 ? configured : DEFAULT_SANDBOX_ORIGIN_SUFFIXES; +} + +/** Hosts allowed to render drafts (`DECO_DRAFT_PREVIEW_HOST`, comma list). */ +function readAllowedHosts(env: Record): string[] { + return (env.DECO_DRAFT_PREVIEW_HOST ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Whether `host` (as seen on the request) may render drafts. + * + * Compared against `DECO_DRAFT_PREVIEW_HOST` verbatim, port included — local + * dev is `localhost:3100`, not `localhost`. The header is spoofable by a + * direct-to-origin request, but the sandbox handle is the actual capability; + * host-scoping bounds blast radius (production domains stay inert), it is not + * a secret. + */ +export function isDraftHostAllowed( + host: string | null | undefined, + env?: Record, +): boolean { + if (!host) return false; + const e = envOrProcess(env); + return readAllowedHosts(e).includes(host.trim().toLowerCase()); +} + +function envOrProcess( + env?: Record, +): Record { + return ( + env ?? + (globalThis as { process?: { env?: Record } }).process?.env ?? + {} + ); } /** @@ -130,11 +180,8 @@ export interface ResolveDraftOptions { export async function resolveDraftDecofile( options: ResolveDraftOptions, ): Promise | null> { - const env = - options.env ?? - (globalThis as { process?: { env?: Record } }).process?.env ?? - {}; - if (env.DECO_DRAFT_PREVIEW !== "1") return null; + const env = envOrProcess(options.env); + if (readAllowedHosts(env).length === 0) return null; const parsed = parseDraftPointer(options.pointer); if (!parsed) return null; @@ -172,13 +219,14 @@ export async function resolveDraftDecofile( return null; } -/** True when the feature is switched on and configured. Inert otherwise. */ +/** + * True when any host is allowed to preview. A plain env read — callers use it + * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an + * unconfigured site never loses static/ISR rendering. The per-request host + * match happens later, in `isDraftHostAllowed`. + */ export function isDraftPreviewEnabled(env?: Record): boolean { - const e = - env ?? - (globalThis as { process?: { env?: Record } }).process?.env ?? - {}; - return e.DECO_DRAFT_PREVIEW === "1" && readSuffixes(e).length > 0; + return readAllowedHosts(envOrProcess(env)).length > 0; } // --------------------------------------------------------------------------- diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 9643e324..f7b78bfc 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -16,7 +16,9 @@ export type { DraftPointer, ResolveDraftOptions } from "./draftSource"; export { buildDraftOrigin, clearDraftCache, + DEFAULT_SANDBOX_ORIGIN_SUFFIXES, getRequestDraftOverride, + isDraftHostAllowed, isDraftPreviewEnabled, parseDraftPointer, resolveDraftDecofile, diff --git a/packages/nextjs/src/createDecoPage.draft.test.tsx b/packages/nextjs/src/createDecoPage.draft.test.tsx index 50ddcb72..2633a75d 100644 --- a/packages/nextjs/src/createDecoPage.draft.test.tsx +++ b/packages/nextjs/src/createDecoPage.draft.test.tsx @@ -13,7 +13,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; // The gate must stop us reaching cookies() at all when the feature is off. const cookiesMock = vi.fn(async () => ({ get: () => undefined })); -vi.mock("next/headers", () => ({ cookies: () => cookiesMock() })); +vi.mock("next/headers", () => ({ + cookies: () => cookiesMock(), + headers: async () => new Headers(), +})); // `connection()` is Next's runtime opt-out from static rendering; outside a // request scope it throws, so it is stubbed to a no-op here. @@ -39,7 +42,7 @@ function seedPublishedHome() { afterEach(() => { cookiesMock.mockClear(); connectionMock.mockClear(); - delete process.env.DECO_DRAFT_PREVIEW; + delete process.env.DECO_DRAFT_PREVIEW_HOST; delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES; }); @@ -72,8 +75,7 @@ describe("createDecoPage — draft preview gate", () => { }); it("reads cookies() once the feature is fully configured", async () => { - process.env.DECO_DRAFT_PREVIEW = "1"; - process.env.DECO_SANDBOX_ORIGIN_SUFFIXES = ".preview-studio.decocms.com"; + process.env.DECO_DRAFT_PREVIEW_HOST = "preview.example"; seedPublishedHome(); const { default: Page } = createDecoPage({ siteName: "test-site" }); diff --git a/packages/nextjs/src/createDecoPage.tsx b/packages/nextjs/src/createDecoPage.tsx index f22808ba..ab965420 100644 --- a/packages/nextjs/src/createDecoPage.tsx +++ b/packages/nextjs/src/createDecoPage.tsx @@ -90,7 +90,8 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) { * first wins for both. So this must be awaited at the top of BOTH, not * just the page. `cache()` here makes the second call free. * - * Gated on `isDraftPreviewEnabled()` — a plain env read, not a dynamic API — + * Gated on `isDraftPreviewEnabled()` (DECO_DRAFT_PREVIEW_HOST non-empty) — a + * plain env read, not a dynamic API — * so sites that never opt in behave exactly as before. That gate is doing * real work: `cookies()` and `searchParams` are both dynamic, and touching * them unconditionally would opt EVERY page out of static/ISR rendering. diff --git a/packages/nextjs/src/draft.ts b/packages/nextjs/src/draft.ts index c9c6d341..cc6faa5f 100644 --- a/packages/nextjs/src/draft.ts +++ b/packages/nextjs/src/draft.ts @@ -22,8 +22,12 @@ * i.e. the page component. `ensureDraft()` exists so each site makes one call * instead of re-deriving that ordering rule. */ -import { resolveDraftDecofile, setDraftOverrideGetter } from "@decocms/blocks/cms"; -import { cookies } from "next/headers"; +import { + isDraftHostAllowed, + resolveDraftDecofile, + setDraftOverrideGetter, +} from "@decocms/blocks/cms"; +import { cookies, headers } from "next/headers"; import { cache } from "react"; /** Cookie that carries the pointer across in-preview navigation. */ @@ -116,6 +120,14 @@ export async function ensureDraft(searchParams?: DraftSearchParams): Promise { + // The middleware is host-gated; these tests run as the allowed host. + process.env.DECO_DRAFT_PREVIEW_HOST = "site.example"; +}); +afterEach(() => { + delete process.env.DECO_DRAFT_PREVIEW_HOST; +}); + function request(url: string, cookie?: string): NextRequest { const req = new NextRequest(new URL(url), { headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {}, @@ -20,6 +28,14 @@ describe("prepareDraft", () => { expect(prepareDraft(request("https://site.example/p", "abc@v1")).pointer).toBe("abc@v1"); }); + it("is inert on a host outside the allowlist — the production domain", () => { + // Same build, different Host: no cookie, no rewrite, nothing touched. + const req = request("https://fila.com.br/p?__draft=abc@v1"); + process.env.DECO_DRAFT_PREVIEW_HOST = "fila.vtex.app"; + expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false }); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); + it("ignores a client-supplied draft header — the page owns the decision", () => { // There is no request-header path any more; the page reads searchParams // and cookies itself, so a forged header is simply not an input. diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts index db76cd25..d940a034 100644 --- a/packages/nextjs/src/draftMiddleware.ts +++ b/packages/nextjs/src/draftMiddleware.ts @@ -19,8 +19,9 @@ * Lives on its own subpath so middleware (edge runtime) never imports the root * barrel, which pulls in the client component graph. */ -import { type NextRequest, NextResponse } from "next/server"; +import { isDraftHostAllowed } from "@decocms/blocks/cms"; +import { type NextRequest, NextResponse } from "next/server"; import { DRAFT_COOKIE, DRAFT_COOKIE_OPTIONS, @@ -36,7 +37,17 @@ import { * nothing has to be forwarded through the request. That also means a draft * still works on routes this middleware never matches. */ +const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false }; + export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { + // Host gate first: on a host not named in DECO_DRAFT_PREVIEW_HOST the + // decision is inert — no cookie written, no rewrite, no headers touched — + // so the production domain behaves as if the feature didn't exist. + const host = + request.headers.get("x-forwarded-host") ?? + request.headers.get("host") ?? + request.nextUrl.host; + if (!isDraftHostAllowed(host)) return INERT; return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null); } From e7f0e5249da215e22f405fe2de3042fd9f7f1274 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 22:24:13 -0300 Subject: [PATCH 5/7] feat!: carry the content-API authority in the draft token; rename the env pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token v2: `?__draft=@` — the token now names WHERE the draft lives, not just a bare label. Probing across configured suffixes is gone: the authority is validated against `DECO_PREVIEW_API_DOMAINS` (dot-prefixed suffix match, label-boundary safe) and the fetch goes to exactly one origin. The token still never carries a scheme or path — a full URL would be an SSRF vector — so `javascript:`/`file:`/URL-shaped tokens fail at parse time; the scheme is derived from the matched domain, and an explicit port is admitted only under localhost-ish domains, so a public-domain token cannot aim at odd ports. This also dissolves the desktop-link dynamic-port problem: Studio embeds the daemon origin's authority verbatim, no suffix override needed. The version charset is now validated too (it is a cache key). Env renames, detaching the sandbox vocabulary from the site-facing surface: DECO_DRAFT_PREVIEW_HOST → DECO_ALLOWED_PREVIEW_HOSTS DECO_SANDBOX_ORIGIN_SUFFIXES → DECO_PREVIEW_API_DOMAINS Semantics unchanged: the host allowlist is the single opt-in (unset = fully inert), the domains default to the deco-operated origins and an override replaces the list. `handle` is gone from the public surface; DraftPointer is `{ host, version }`. BREAKING CHANGE: token format and both env names change. Beta-only surface. Co-Authored-By: Claude Opus 5 (1M context) --- .../blocks/src/cms/applySectionConventions.ts | 20 +- packages/blocks/src/cms/blockSource.test.ts | 2 +- .../src/cms/client.browserBundle.test.ts | 4 +- packages/blocks/src/cms/client.ts | 10 +- packages/blocks/src/cms/draftSource.test.ts | 259 ++++++++---------- packages/blocks/src/cms/draftSource.ts | 222 ++++++++------- packages/blocks/src/cms/index.ts | 4 +- packages/blocks/src/cms/loader.test.ts | 16 +- packages/blocks/src/cms/registry.test.ts | 40 ++- packages/blocks/src/cms/registry.ts | 20 +- packages/blocks/src/cms/resolve.test.ts | 42 ++- packages/blocks/src/cms/resolve.ts | 33 +-- packages/blocks/src/cms/schema.ts | 5 +- packages/blocks/src/cms/sectionMixins.test.ts | 22 +- packages/blocks/src/cms/sectionMixins.ts | 4 +- packages/nextjs/src/DecoPageRenderer.test.tsx | 16 +- packages/nextjs/src/DecoPageRenderer.tsx | 18 +- packages/nextjs/src/DecoRootLayout.tsx | 2 +- packages/nextjs/src/DeferredSection.test.tsx | 2 +- packages/nextjs/src/DeferredSection.tsx | 2 +- packages/nextjs/src/SectionRenderer.test.tsx | 2 +- packages/nextjs/src/SectionRenderer.tsx | 8 +- .../nextjs/src/createDecoPage.draft.test.tsx | 4 +- packages/nextjs/src/createDecoPage.test.tsx | 6 +- packages/nextjs/src/createDecoPage.tsx | 2 +- packages/nextjs/src/draft.ts | 4 +- packages/nextjs/src/draftMiddleware.test.ts | 36 ++- packages/nextjs/src/draftMiddleware.ts | 6 +- 28 files changed, 380 insertions(+), 431 deletions(-) diff --git a/packages/blocks/src/cms/applySectionConventions.ts b/packages/blocks/src/cms/applySectionConventions.ts index 56dc2386..03115462 100644 --- a/packages/blocks/src/cms/applySectionConventions.ts +++ b/packages/blocks/src/cms/applySectionConventions.ts @@ -6,23 +6,20 @@ * registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections, * registerLayoutSections, registerSeoSections, and registerSection. */ -import { - registerSection, - registerSectionsSync, -} from "./registry"; -import { - type CacheableSectionInput, - registerCacheableSections, - registerLayoutSections, -} from "./sectionLoaders"; +import { registerSection, registerSectionsSync } from "./registry"; import { type AsyncRenderingConfig, + getAsyncRenderingConfig, registerEagerSections, registerNeverDeferSections, registerSeoSections, setAsyncRenderingConfig, - getAsyncRenderingConfig, } from "./resolve"; +import { + type CacheableSectionInput, + registerCacheableSections, + registerLayoutSections, +} from "./sectionLoaders"; export interface SectionMetaEntry { eager?: boolean; @@ -96,8 +93,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo // it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and // even editor-marked ⚡ sections render eagerly. (The default foldThreshold is // Infinity, so position-based deferral stays off unless a site opts in.) - const existing: Partial = - getAsyncRenderingConfig() ?? {}; + const existing: Partial = getAsyncRenderingConfig() ?? {}; setAsyncRenderingConfig({ ...existing, alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections], diff --git a/packages/blocks/src/cms/blockSource.test.ts b/packages/blocks/src/cms/blockSource.test.ts index 87701491..7f1351d1 100644 --- a/packages/blocks/src/cms/blockSource.test.ts +++ b/packages/blocks/src/cms/blockSource.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { djb2Hex } from "../sdk/djb2"; import { BundledBlockSource, computeRevision, @@ -8,7 +9,6 @@ import { revisionKey, snapshotKey, } from "./blockSource"; -import { djb2Hex } from "../sdk/djb2"; describe("computeRevision", () => { it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => { diff --git a/packages/blocks/src/cms/client.browserBundle.test.ts b/packages/blocks/src/cms/client.browserBundle.test.ts index 56747c93..add6e492 100644 --- a/packages/blocks/src/cms/client.browserBundle.test.ts +++ b/packages/blocks/src/cms/client.browserBundle.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; const here = dirname(fileURLToPath(import.meta.url)); // esbuild's JS API relies on `new TextEncoder().encode("") instanceof diff --git a/packages/blocks/src/cms/client.ts b/packages/blocks/src/cms/client.ts index a8d7d806..716cdb7c 100644 --- a/packages/blocks/src/cms/client.ts +++ b/packages/blocks/src/cms/client.ts @@ -49,8 +49,6 @@ export { registerSectionsSync, setResolvedComponent, } from "./registry"; -export type { SectionLoaderFn } from "./sectionLoaders"; -export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins"; export type { ActionConfig, AppSchemas, @@ -72,3 +70,11 @@ export { registerMatcherSchema, registerMatcherSchemas, } from "./schema"; +export type { SectionLoaderFn } from "./sectionLoaders"; +export { + compose, + withDevice, + withMobile, + withSearchParam, + withSectionLoader, +} from "./sectionMixins"; diff --git a/packages/blocks/src/cms/draftSource.test.ts b/packages/blocks/src/cms/draftSource.test.ts index 89499e05..91665b71 100644 --- a/packages/blocks/src/cms/draftSource.test.ts +++ b/packages/blocks/src/cms/draftSource.test.ts @@ -1,19 +1,16 @@ import { beforeEach, describe, expect, it } from "vitest"; import { - buildDraftOrigin, clearDraftCache, - DEFAULT_SANDBOX_ORIGIN_SUFFIXES, + DEFAULT_PREVIEW_API_DOMAINS, isDraftHostAllowed, isDraftPreviewEnabled, parseDraftPointer, + previewApiOriginForHost, resolveDraftDecofile, } from "./draftSource"; -const ENV_ON = { - DECO_DRAFT_PREVIEW_HOST: "preview.example", - DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com", -}; +const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" }; function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { @@ -28,106 +25,99 @@ beforeEach(() => { }); describe("parseDraftPointer", () => { - it("parses handle@version", () => { - expect(parseDraftPointer("gimenes-abc123@ff00")).toEqual({ - handle: "gimenes-abc123", - version: "ff00", + it("parses authority@version, lowercasing the authority", () => { + expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({ + host: "abc.preview-studio.decocms.com", + version: "FF00", + }); + }); + + it("keeps an explicit port on the authority", () => { + expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({ + host: "abc.localhost:60534", + version: "v1", }); }); it("rejects more than one @", () => { // A naive split("@") accepts this and silently uses the first two // segments — the exact hole found while spiking the fetch path. - expect(parseDraftPointer("a@b@c")).toBeNull(); + expect(parseDraftPointer("a.example@b@c")).toBeNull(); }); - it("rejects a handle that could escape the authority", () => { - expect(parseDraftPointer("evil.com/x@v1")).toBeNull(); - expect(parseDraftPointer("user:pw@v1")).toBeNull(); - expect(parseDraftPointer("a/../b@v1")).toBeNull(); - expect(parseDraftPointer(".leading-dot@v1")).toBeNull(); + it("rejects anything that could escape the authority", () => { + // No scheme, no path, no userinfo — the token carries an authority only, + // so `javascript:`/`file:`/full URLs fail structurally at parse time. + expect(parseDraftPointer("https://evil.example@v1")).toBeNull(); + expect(parseDraftPointer("evil.example/x@v1")).toBeNull(); + expect(parseDraftPointer("a.example:80:80@v1")).toBeNull(); + expect(parseDraftPointer("a.example:abc@v1")).toBeNull(); + expect(parseDraftPointer(".leading.dot@v1")).toBeNull(); + expect(parseDraftPointer("bare-label@v1")).toBeNull(); }); - it("rejects empty halves and missing input", () => { - expect(parseDraftPointer("@v1")).toBeNull(); - expect(parseDraftPointer("handle@")).toBeNull(); - expect(parseDraftPointer("handle")).toBeNull(); + it("validates the version charset — it becomes a cache key", () => { + expect(parseDraftPointer("a.example@")).toBeNull(); + expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull(); + expect(parseDraftPointer("a.example@v 1")).toBeNull(); expect(parseDraftPointer(null)).toBeNull(); - expect(parseDraftPointer("")).toBeNull(); }); }); -describe("buildDraftOrigin", () => { - it("builds https from the configured suffix", () => { - expect(buildDraftOrigin("abc", ".preview-studio.decocms.com")).toBe( +describe("previewApiOriginForHost", () => { + it("admits authorities under the default deco domains", () => { + expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe( "https://abc.preview-studio.decocms.com", ); + expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe( + "https://abc.local.studio.decocms.com", + ); + expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534"); + }); + + it("rejects hosts outside the domains — the token proposes, config disposes", () => { + expect(previewApiOriginForHost("evil.example", {})).toBeNull(); + // Dot-prefixed suffixes guarantee a label boundary: a lookalike domain + // that merely ends with the same characters cannot pass. + expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull(); + // The domain itself (no label in front) is not a draft host. + expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull(); }); - it("uses http for a localhost suffix (local e2e)", () => { - expect(buildDraftOrigin("abc", ".localhost:3200")).toBe("http://abc.localhost:3200"); + it("allows an explicit port only under localhost-ish domains", () => { + // A public-domain token must not steer the fetch at odd ports. + expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull(); }); - it("returns null with no configured suffix — never guesses an origin", () => { - expect(buildDraftOrigin("abc", "")).toBeNull(); + it("honours a configured override instead of the defaults", () => { + const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" }; + expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example"); + expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull(); }); }); -describe("isDraftPreviewEnabled", () => { - it("is on iff an allowed host is configured — suffixes have defaults", () => { - // Inverted from the old two-key gate: suffixes now default to the - // deco-operated origins, so the single opt-in is the host allowlist. - // Upgrading the package with no host configured stays fully inert. +describe("gating", () => { + it("is on iff an allowed host is configured — API domains have defaults", () => { expect(isDraftPreviewEnabled(ENV_ON)).toBe(true); - expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW_HOST: "a.example" })).toBe(true); - expect( - isDraftPreviewEnabled({ - DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com", - }), - ).toBe(false); expect(isDraftPreviewEnabled({})).toBe(false); }); -}); - -describe("isDraftHostAllowed", () => { - const env = { DECO_DRAFT_PREVIEW_HOST: "fila.vtex.app, localhost:3100" }; - it("matches listed hosts verbatim, port included, case-insensitively", () => { - expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(true); + it("matches request hosts verbatim, port included, case-insensitively", () => { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" }; expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true); expect(isDraftHostAllowed("localhost:3100", env)).toBe(true); - }); - - it("rejects everything else — the production domain on the same build", () => { expect(isDraftHostAllowed("fila.com.br", env)).toBe(false); - expect(isDraftHostAllowed("localhost", env)).toBe(false); // port matters + expect(isDraftHostAllowed("localhost", env)).toBe(false); expect(isDraftHostAllowed(null, env)).toBe(false); expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false); }); }); describe("resolveDraftDecofile", () => { - it("uses the default deco suffixes when none are configured", async () => { - const calls: string[] = []; - await resolveDraftDecofile({ - pointer: "abc@v1", - env: { DECO_DRAFT_PREVIEW_HOST: "preview.example" }, - fetchImpl: (async (url: string) => { - calls.push(String(url)); - throw new Error("unreachable"); - }) as unknown as typeof fetch, - }); - expect(calls).toEqual( - DEFAULT_SANDBOX_ORIGIN_SUFFIXES.map( - (sfx) => `${sfx.includes("localhost") ? "http" : "https"}://abc${sfx}/_sandbox/decofile`, - ), - ); - }); - - it("fetches the sandbox decofile and returns it", async () => { + it("fetches exactly the token's validated origin", async () => { const calls: string[] = []; const blocks = await resolveDraftDecofile({ - pointer: "abc@v1", + pointer: "abc.preview-studio.decocms.com@v1", env: ENV_ON, fetchImpl: (async (url: string) => { calls.push(String(url)); @@ -142,14 +132,27 @@ describe("resolveDraftDecofile", () => { it("is inert without a host allowlist — no fetch at all", async () => { let called = false; const blocks = await resolveDraftDecofile({ - pointer: "abc@v1", - env: { DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com" }, + pointer: "abc.preview-studio.decocms.com@v1", + env: {}, fetchImpl: (async () => { called = true; return jsonResponse({}); }) as unknown as typeof fetch, }); + expect(blocks).toBeNull(); + expect(called).toBe(false); + }); + it("refuses a parseable token whose origin no domain admits — no fetch", async () => { + let called = false; + const blocks = await resolveDraftDecofile({ + pointer: "abc.evil.example@v1", + env: ENV_ON, + fetchImpl: (async () => { + called = true; + return jsonResponse({}); + }) as unknown as typeof fetch, + }); expect(blocks).toBeNull(); expect(called).toBe(false); }); @@ -160,13 +163,14 @@ describe("resolveDraftDecofile", () => { fetches++; return jsonResponse({ n: fetches }); }) as unknown as typeof fetch; + const P = "abc.preview-studio.decocms.com"; - const a = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl }); - const b = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl }); + const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); + const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); expect(fetches).toBe(1); expect(b).toBe(a); - await resolveDraftDecofile({ pointer: "abc@v2", env: ENV_ON, fetchImpl }); + await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl }); expect(fetches).toBe(2); }); @@ -176,89 +180,50 @@ describe("resolveDraftDecofile", () => { fetches++; return jsonResponse({ n: fetches }); }) as unknown as typeof fetch; + const P = "abc.preview-studio.decocms.com"; for (const v of ["v1", "v2", "v3", "v4"]) { - await resolveDraftDecofile({ pointer: `abc@${v}`, env: ENV_ON, fetchImpl }); + await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl }); } expect(fetches).toBe(4); - - // v1 was evicted (cap is 3), so it must re-fetch rather than serve stale. - await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl }); - expect(fetches).toBe(5); - - // v4 is still resident. - await resolveDraftDecofile({ pointer: "abc@v4", env: ENV_ON, fetchImpl }); - expect(fetches).toBe(5); + await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); + expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale + await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl }); + expect(fetches).toBe(5); // v4 resident }); - it("falls through the suffix list until one origin answers", async () => { - // A deployment can serve sandboxes from more than one origin (cluster and - // desktop-link); the handle doesn't say which. The first suffix here is - // unreachable — the resolver must try the next, not give up. - const calls: string[] = []; - const blocks = await resolveDraftDecofile({ - pointer: "abc@v1", - env: { - DECO_DRAFT_PREVIEW_HOST: "preview.example", - DECO_SANDBOX_ORIGIN_SUFFIXES: ".dead.example, .alive.example", - }, - fetchImpl: (async (url: string) => { - calls.push(String(url)); - if (String(url).includes("dead")) throw new Error("ECONNREFUSED"); - return jsonResponse({ ok: true }); - }) as unknown as typeof fetch, - }); - expect(blocks).toEqual({ ok: true }); - expect(calls).toEqual([ - "https://abc.dead.example/_sandbox/decofile", - "https://abc.alive.example/_sandbox/decofile", - ]); - }); - - it("degrades to published on a malformed pointer, without fetching", async () => { - let called = false; - const blocks = await resolveDraftDecofile({ - pointer: "a@b@c", - env: ENV_ON, - fetchImpl: (async () => { - called = true; - return jsonResponse({}); - }) as unknown as typeof fetch, - }); - expect(blocks).toBeNull(); - expect(called).toBe(false); - }); - - it("degrades to published on a non-2xx sandbox", async () => { - const blocks = await resolveDraftDecofile({ - pointer: "abc@v1", - env: ENV_ON, - fetchImpl: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch, - }); - expect(blocks).toBeNull(); - }); - - it("degrades to published when the sandbox is unreachable", async () => { - const blocks = await resolveDraftDecofile({ - pointer: "abc@v1", - env: ENV_ON, - fetchImpl: (async () => { + it("degrades to published on unreachable / non-2xx / unparseable", async () => { + const P = "abc.preview-studio.decocms.com"; + for (const fetchImpl of [ + async () => { throw new Error("ECONNREFUSED"); - }) as unknown as typeof fetch, - }); - expect(blocks).toBeNull(); - }); - - it("degrades to published on an unparseable body", async () => { - const blocks = await resolveDraftDecofile({ - pointer: "abc@v1", - env: ENV_ON, - fetchImpl: (async () => + }, + async () => new Response("nope", { status: 404 }), + async () => new Response("not json", { status: 200, headers: { "content-type": "text/html" }, - })) as unknown as typeof fetch, - }); - expect(blocks).toBeNull(); + }), + ]) { + clearDraftCache(); + expect( + await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: ENV_ON, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).toBeNull(); + } + }); +}); + +// DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it. +describe("DEFAULT_PREVIEW_API_DOMAINS", () => { + it("ships the deco-operated origins, dot-prefixed", () => { + expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([ + ".preview-studio.decocms.com", + ".local.studio.decocms.com", + ".localhost", + ]); }); }); diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts index 4fb6e5ea..d62f74ff 100644 --- a/packages/blocks/src/cms/draftSource.ts +++ b/packages/blocks/src/cms/draftSource.ts @@ -1,20 +1,20 @@ /** * Draft preview — pull-based decofile override. * - * A Studio sandbox serves the working-tree draft at + * A Studio preview serves the working-tree draft at * `GET /_sandbox/decofile`; a production site pulls it and renders its * own real pages against it. This replaces pushing the decofile into a POST * body, which only deco's own runtime honours — Next.js and most frameworks * render on GET only. * - * This module is the framework-agnostic half: pointer parsing, origin - * construction, fetching, and version caching. Binding a resolved draft to a + * This module is the framework-agnostic half: token parsing, origin + * validation, fetching, and version caching. Binding a resolved draft to a * request is framework-specific (see `@decocms/nextjs`'s draft wiring) and * reaches this module through {@link setDraftOverrideGetter} — the same * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its * zero-dependency direction. * - * Inert unless `DECO_DRAFT_PREVIEW_HOST` names the request's host: upgrading + * Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` names the request's host: upgrading * the package must never be enough to start fetching from the network and * rendering unpublished content. Host-scoping (rather than a boolean) exists * because one deployment commonly serves several domains — the preview domain @@ -22,69 +22,95 @@ * ignore a `?__draft=` entirely. */ -/** A parsed `@` draft pointer. */ +/** + * A parsed `?__draft=` token: `@`. + * + * The token carries the AUTHORITY of the draft content API, never a scheme or + * path — a full URL would be an SSRF vector, and the scheme is derived from + * the matched domain instead. Reserved evolution: a future signed token uses a + * distinguishable prefix (e.g. `s1.`), so this strict two-part parse rejects + * it cleanly rather than half-reading it. + */ export interface DraftPointer { - /** Sandbox handle — the subdomain under a configured origin suffix. */ - handle: string; - /** Content version (the daemon's ETag). Immutable, so safe to cache on. */ + /** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */ + host: string; + /** Opaque content version (the server's ETag). Immutable → safe cache key. */ version: string; } -/** - * Sandbox handles are `[a-z0-9-]`, always leading with an alphanumeric. - * - * Validated BEFORE the handle is interpolated into an authority, so it cannot - * smuggle `/`, `@`, `:` or userinfo into the URL and redirect the fetch at some - * other host. - */ -const HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; +/** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */ +const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const PORT_RE = /^[0-9]{1,5}$/; +const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/; /** - * Parse `@`. - * - * Requires EXACTLY one `@`: a naive `split("@")` accepts `a@b@c` and silently - * uses the first two segments, which is how a malformed pointer sneaks past - * validation. Returns null on anything unexpected — callers fall back to - * published content. + * Parse `@`. Null on anything unexpected — callers fall + * back to published content. Requires EXACTLY one `@`: a naive split accepts + * `a@b@c` and silently uses the first two segments. */ export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null { if (!raw) return null; const parts = raw.split("@"); if (parts.length !== 2) return null; - const [handle, version] = parts; - if (!handle || !version) return null; - if (!HANDLE_RE.test(handle)) return null; - return { handle, version }; + const [authority, version] = parts; + if (!authority || !version || !VERSION_RE.test(version)) return null; + + const [host, port, extra] = authority.toLowerCase().split(":"); + if (extra !== undefined) return null; + if (!host || !HOST_RE.test(host)) return null; + if (port !== undefined && !PORT_RE.test(port)) return null; + + return { host: port === undefined ? host : `${host}:${port}`, version }; } /** - * Default sandbox origin suffixes — deco-operated domains, so shipping them as - * defaults adds no SSRF surface: the origin is still configuration, never - * caller input. `DECO_SANDBOX_ORIGIN_SUFFIXES` overrides (e.g. to pin a local - * link port: `.localhost:60534`). + * Domains the draft content API may live under — deco-operated, so shipping + * them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides + * the whole list when set. Entries are dot-prefixed suffixes, which guarantees + * a label boundary on match (`evil-preview-studio.decocms.com` cannot pass). */ -export const DEFAULT_SANDBOX_ORIGIN_SUFFIXES = [ +export const DEFAULT_PREVIEW_API_DOMAINS = [ ".preview-studio.decocms.com", ".local.studio.decocms.com", ".localhost", ]; -/** - * Suffixes are tried in order until one answers — a deployment can serve - * sandboxes from more than one origin (cluster and desktop-link), and the - * handle alone does not say which one it lives under. - */ -function readSuffixes(env: Record): string[] { - const configured = (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "") +function readApiDomains(env: Record): string[] { + const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "") .split(",") - .map((s) => s.trim()) + .map((s) => s.trim().toLowerCase()) .filter(Boolean); - return configured.length > 0 ? configured : DEFAULT_SANDBOX_ORIGIN_SUFFIXES; + return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS; } -/** Hosts allowed to render drafts (`DECO_DRAFT_PREVIEW_HOST`, comma list). */ +/** + * Validate the token's authority against the configured domains and derive the + * fetch origin, or null if no domain admits it. + * + * The token proposes, configuration disposes: only the hostname-suffix match + * decides, so a caller can steer WHICH label under your domains, never which + * domains. Scheme is derived — http for localhost-ish domains, https + * otherwise — and an explicit port is allowed only there, so a public-domain + * token cannot aim at odd ports. + */ +export function previewApiOriginForHost( + authority: string, + env?: Record, +): string | null { + const [host, port] = authority.toLowerCase().split(":"); + if (!host) return null; + const domain = readApiDomains(envOrProcess(env)).find( + (d) => host.length > d.length && host.endsWith(d), + ); + if (!domain) return null; + const local = domain.includes("localhost"); + if (port !== undefined && !local) return null; + return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`; +} + +/** Hosts allowed to render drafts (`DECO_ALLOWED_PREVIEW_HOSTS`, comma list). */ function readAllowedHosts(env: Record): string[] { - return (env.DECO_DRAFT_PREVIEW_HOST ?? "") + return (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); @@ -93,9 +119,9 @@ function readAllowedHosts(env: Record): string[] { /** * Whether `host` (as seen on the request) may render drafts. * - * Compared against `DECO_DRAFT_PREVIEW_HOST` verbatim, port included — local - * dev is `localhost:3100`, not `localhost`. The header is spoofable by a - * direct-to-origin request, but the sandbox handle is the actual capability; + * Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included — + * local dev is `localhost:3100`, not `localhost`. The header is spoofable by a + * direct-to-origin request, but the draft id is the actual capability; * host-scoping bounds blast radius (production domains stay inert), it is not * a secret. */ @@ -104,8 +130,17 @@ export function isDraftHostAllowed( env?: Record, ): boolean { if (!host) return false; - const e = envOrProcess(env); - return readAllowedHosts(e).includes(host.trim().toLowerCase()); + return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase()); +} + +/** + * True when any host is allowed to preview. A plain env read — callers use it + * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an + * unconfigured site never loses static/ISR rendering. The per-request host + * match happens later, in `isDraftHostAllowed`. + */ +export function isDraftPreviewEnabled(env?: Record): boolean { + return readAllowedHosts(envOrProcess(env)).length > 0; } function envOrProcess( @@ -119,31 +154,14 @@ function envOrProcess( } /** - * Build the sandbox origin for a handle under ONE configured suffix. - * - * The origin comes from configuration, never from caller input, so there is no - * SSRF surface to defend and no allowlist to keep correct. A `localhost` - * suffix (local e2e) speaks http; everything else is https. - */ -export function buildDraftOrigin(handle: string, suffix: string): string | null { - if (!suffix) return null; - if (!HANDLE_RE.test(handle)) return null; - const scheme = suffix.includes("localhost") ? "http" : "https"; - return `${scheme}://${handle}${suffix}`; -} - -/** - * Version cache. - * - * Bounded on purpose: a decofile is routinely multi-megabyte, so an unbounded - * map keyed by version would grow with every save until the process died. - * Keyed by version (content-addressed), so a hit is always correct. + * Version cache. Bounded on purpose: a decofile is routinely multi-megabyte, + * so an unbounded map keyed by version would grow with every save until the + * process died. Content-addressed, so a hit is always correct. */ const MAX_CACHED_VERSIONS = 3; const byVersion = new Map>(); function cacheDraft(version: string, blocks: Record): void { - // Re-insert to make this the most recently used key. byVersion.delete(version); byVersion.set(version, blocks); while (byVersion.size > MAX_CACHED_VERSIONS) { @@ -159,7 +177,7 @@ export function clearDraftCache(): void { } export interface ResolveDraftOptions { - /** Raw `@` pointer from the request. */ + /** Raw `@` token from the request. */ pointer: string | null | undefined; /** Defaults to `process.env`. */ env?: Record; @@ -168,14 +186,11 @@ export interface ResolveDraftOptions { } /** - * Resolve a draft pointer to a decofile, or null to render published content. + * Resolve a draft token to a decofile, or null to render published content. * - * Null on every failure path — disabled, malformed pointer, unreachable - * sandbox, non-2xx — because a draft that cannot be resolved must degrade to - * published rather than break the page. Callers that need to *tell the user* - * the draft failed should check {@link isDraftPreviewEnabled} and surface it - * themselves; silently showing published content while the user believes they - * are looking at a draft is the failure mode worth avoiding. + * Null on every failure path — disabled, malformed token, disallowed origin, + * unreachable, non-2xx — because a draft that cannot be resolved must degrade + * to published rather than break the page. */ export async function resolveDraftDecofile( options: ResolveDraftOptions, @@ -189,44 +204,27 @@ export async function resolveDraftDecofile( const cached = byVersion.get(parsed.version); if (cached) return cached; - const doFetch = options.fetchImpl ?? fetch; - // Suffixes are tried in order; the first that answers with parseable JSON - // wins. A miss on one origin (unreachable, non-2xx, garbage) is expected — - // the handle only exists under one of them — so every failure falls through - // to the next rather than aborting the resolve. - for (const suffix of readSuffixes(env)) { - const origin = buildDraftOrigin(parsed.handle, suffix); - if (!origin) continue; - - let res: Response; - try { - res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" }); - } catch { - continue; - } - if (!res.ok) continue; + const origin = previewApiOriginForHost(parsed.host, env); + if (!origin) return null; - let blocks: Record; - try { - blocks = (await res.json()) as Record; - } catch { - continue; - } + const doFetch = options.fetchImpl ?? fetch; + let res: Response; + try { + res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" }); + } catch { + return null; + } + if (!res.ok) return null; - cacheDraft(parsed.version, blocks); - return blocks; + let blocks: Record; + try { + blocks = (await res.json()) as Record; + } catch { + return null; } - return null; -} -/** - * True when any host is allowed to preview. A plain env read — callers use it - * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an - * unconfigured site never loses static/ISR rendering. The per-request host - * match happens later, in `isDraftHostAllowed`. - */ -export function isDraftPreviewEnabled(env?: Record): boolean { - return readAllowedHosts(envOrProcess(env)).length > 0; + cacheDraft(parsed.version, blocks); + return blocks; } // --------------------------------------------------------------------------- @@ -242,10 +240,8 @@ let getDraftOverride: DraftOverrideGetter = () => undefined; * * Binding a value to "the current request" is framework-specific and `blocks` * must not know about any framework: `@decocms/nextjs` backs this with React - * `cache()` (App Router has no AsyncLocalStorage request scope of its own — - * `RequestContext.run` is never entered there). Bindings that do have an ALS - * request scope can back it with that instead. Never called → returns - * undefined → `loadBlocks()` behaves exactly as before. + * `cache()` (App Router has no AsyncLocalStorage request scope of its own). + * Never called → returns undefined → `loadBlocks()` behaves exactly as before. */ export function setDraftOverrideGetter(getter: DraftOverrideGetter): void { getDraftOverride = getter; diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index f7b78bfc..1dde6fe4 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -14,13 +14,13 @@ export { } from "./blockSource"; export type { DraftPointer, ResolveDraftOptions } from "./draftSource"; export { - buildDraftOrigin, clearDraftCache, - DEFAULT_SANDBOX_ORIGIN_SUFFIXES, + DEFAULT_PREVIEW_API_DOMAINS, getRequestDraftOverride, isDraftHostAllowed, isDraftPreviewEnabled, parseDraftPointer, + previewApiOriginForHost, resolveDraftDecofile, setDraftOverrideGetter, } from "./draftSource"; diff --git a/packages/blocks/src/cms/loader.test.ts b/packages/blocks/src/cms/loader.test.ts index 33e0d493..f8faff2a 100644 --- a/packages/blocks/src/cms/loader.test.ts +++ b/packages/blocks/src/cms/loader.test.ts @@ -82,12 +82,12 @@ describe("matchPath", () => { }); it("matches with an optional prefix before a literal segment", () => { - expect( - matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023"), - ).toEqual({ "0": "destaques-2023" }); - expect( - matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"), - ).toEqual({ "0": "destaques-2023" }); + expect(matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023")).toEqual({ + "0": "destaques-2023", + }); + expect(matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023")).toEqual({ + "0": "destaques-2023", + }); }); it("matches an optional suffix group present and absent", () => { @@ -112,9 +112,7 @@ describe("matchPath", () => { // biome-ignore lint/performance/noDelete: restoring exact global state delete g.URLPattern; try { - expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow( - /URLPattern.*Node\.js >= 24/s, - ); + expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s); } finally { if (saved !== undefined) g.URLPattern = saved; } diff --git a/packages/blocks/src/cms/registry.test.ts b/packages/blocks/src/cms/registry.test.ts index 5461d3ce..977ba876 100644 --- a/packages/blocks/src/cms/registry.test.ts +++ b/packages/blocks/src/cms/registry.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { - registerSection, - registerSectionsSync, + getResolvedComponent, getSection, getSectionOptions, getSyncComponent, - getResolvedComponent, + registerSection, + registerSectionsSync, } from "./registry"; // Reset globalThis.__deco between tests to avoid cross-test pollution @@ -34,44 +34,36 @@ describe("registerSection + getSection", () => { describe("registerSection with options", () => { it("stores loadingFallback in section options", () => { const fallback = () => null; - registerSection( - "site/sections/Shelf.tsx", - async () => ({ default: () => null }), - { loadingFallback: fallback }, - ); + registerSection("site/sections/Shelf.tsx", async () => ({ default: () => null }), { + loadingFallback: fallback, + }); const opts = getSectionOptions("site/sections/Shelf.tsx"); expect(opts?.loadingFallback).toBe(fallback); }); it("stores clientOnly flag in section options", () => { - registerSection( - "site/sections/Analytics.tsx", - async () => ({ default: () => null }), - { clientOnly: true }, - ); + registerSection("site/sections/Analytics.tsx", async () => ({ default: () => null }), { + clientOnly: true, + }); const opts = getSectionOptions("site/sections/Analytics.tsx"); expect(opts?.clientOnly).toBe(true); }); it("clientOnly defaults to undefined when not set", () => { - registerSection( - "site/sections/Normal.tsx", - async () => ({ default: () => null }), - { loadingFallback: () => null }, - ); + registerSection("site/sections/Normal.tsx", async () => ({ default: () => null }), { + loadingFallback: () => null, + }); const opts = getSectionOptions("site/sections/Normal.tsx"); expect(opts?.clientOnly).toBeUndefined(); }); it("clientOnly false is preserved", () => { - registerSection( - "site/sections/Explicit.tsx", - async () => ({ default: () => null }), - { clientOnly: false }, - ); + registerSection("site/sections/Explicit.tsx", async () => ({ default: () => null }), { + clientOnly: false, + }); const opts = getSectionOptions("site/sections/Explicit.tsx"); expect(opts?.clientOnly).toBe(false); diff --git a/packages/blocks/src/cms/registry.ts b/packages/blocks/src/cms/registry.ts index beeb9c9d..42e63e61 100644 --- a/packages/blocks/src/cms/registry.ts +++ b/packages/blocks/src/cms/registry.ts @@ -1,8 +1,6 @@ import type { ComponentType } from "react"; -export type OnBeforeResolveProps = ( - props: Record, -) => Record; +export type OnBeforeResolveProps = (props: Record) => Record; export type SectionModule = { default: ComponentType; @@ -35,8 +33,7 @@ if (!G.__deco.sectionRegistry) G.__deco.sectionRegistry = {}; if (!G.__deco.sectionOptions) G.__deco.sectionOptions = {}; if (!G.__deco.resolvedComponents) G.__deco.resolvedComponents = {}; if (!G.__deco.syncComponents) G.__deco.syncComponents = {}; -if (!G.__deco.onBeforeResolvePropsRegistry) - G.__deco.onBeforeResolvePropsRegistry = {}; +if (!G.__deco.onBeforeResolvePropsRegistry) G.__deco.onBeforeResolvePropsRegistry = {}; const registry: Record = G.__deco.sectionRegistry; const sectionOptions: Record = G.__deco.sectionOptions; @@ -194,9 +191,7 @@ export function registerSectionsSync(sections: Record) ]; const component = typeof raw === "function" || - (raw != null && - typeof raw === "object" && - REACT_WRAPPERS.includes((raw as any).$$typeof)) + (raw != null && typeof raw === "object" && REACT_WRAPPERS.includes((raw as any).$$typeof)) ? raw : undefined; if (!component) { @@ -229,17 +224,12 @@ export function getSyncComponent(key: string): ComponentType | undefined { * BEFORE the resolution engine resolves them. Use to extract metadata from * resolvable structures that would be lost after resolution. */ -export function registerOnBeforeResolveProps( - sectionKey: string, - fn: OnBeforeResolveProps, -): void { +export function registerOnBeforeResolveProps(sectionKey: string, fn: OnBeforeResolveProps): void { onBeforeResolvePropsRegistry[sectionKey] = fn; } /** Get the registered onBeforeResolveProps for a section, if any. */ -export function getOnBeforeResolveProps( - sectionKey: string, -): OnBeforeResolveProps | undefined { +export function getOnBeforeResolveProps(sectionKey: string): OnBeforeResolveProps | undefined { return onBeforeResolvePropsRegistry[sectionKey]; } diff --git a/packages/blocks/src/cms/resolve.test.ts b/packages/blocks/src/cms/resolve.test.ts index 90f93852..a9722480 100644 --- a/packages/blocks/src/cms/resolve.test.ts +++ b/packages/blocks/src/cms/resolve.test.ts @@ -20,6 +20,10 @@ vi.mock("./registry", () => ({ getOnBeforeResolveProps: vi.fn(), })); +import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; +import { findPageByPath } from "./loader"; +import { getSection } from "./registry"; +import type { AsyncRenderingConfig, DeferredSection } from "./resolve"; import { clearCommerceLoaders, DEFAULT_FOLD_THRESHOLD, @@ -29,8 +33,8 @@ import { registerCommerceLoader, registerEagerSections, registerNeverDeferSections, - resolveDeferredSectionFull, resolveDecoPage, + resolveDeferredSectionFull, resolvePageSeoBlock, resolveSectionsList, resolveValue, @@ -39,10 +43,6 @@ import { WELL_KNOWN_TYPES, } from "./resolve"; import { runSingleSectionLoader } from "./sectionLoaders"; -import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; -import { findPageByPath } from "./loader"; -import { getSection } from "./registry"; -import type { AsyncRenderingConfig, DeferredSection } from "./resolve"; describe("resolveDeferredSectionFull", () => { it("resolves a deferred section and preserves index", async () => { @@ -217,14 +217,10 @@ describe("commerce loader auto-injects URL search params as props", () => { return null; }); - await resolveValue( - { __resolveType: KEY, slug: "sabonete" }, - undefined, - { - url: "https://store.com/produto/sabonete/p?skuId=12345&size=M", - path: "/produto/sabonete/p", - }, - ); + await resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, { + url: "https://store.com/produto/sabonete/p?skuId=12345&size=M", + path: "/produto/sabonete/p", + }); expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ @@ -243,11 +239,10 @@ describe("commerce loader auto-injects URL search params as props", () => { return null; }); - await resolveValue( - { __resolveType: KEY, skuId: "cms-locked-sku" }, - undefined, - { url: "https://store.com/p?skuId=url-value", path: "/p" }, - ); + await resolveValue({ __resolveType: KEY, skuId: "cms-locked-sku" }, undefined, { + url: "https://store.com/p?skuId=url-value", + path: "/p", + }); expect(calls[0]?.skuId).toBe("cms-locked-sku"); }); @@ -358,7 +353,10 @@ describe("commerce loader resolves legacy .ts-suffixed resolveType", () => { await resolveValue( { __resolveType: "shopify/loaders/ProductDetailsPage.ts", slug: "oversize-t-shirt-123" }, undefined, - { url: "https://store.com/products/oversize-t-shirt-123", path: "/products/oversize-t-shirt-123" }, + { + url: "https://store.com/products/oversize-t-shirt-123", + path: "/products/oversize-t-shirt-123", + }, ); expect(calls).toHaveLength(1); @@ -509,9 +507,9 @@ describe("isEagerRequest — programmatic fetch detection", () => { }); it("falls back to matcherCtx.headers when no Request is present", () => { - expect( - isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } }), - ).toBe(true); + expect(isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } })).toBe( + true, + ); }); it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => { diff --git a/packages/blocks/src/cms/resolve.ts b/packages/blocks/src/cms/resolve.ts index 51140f30..c3b39270 100644 --- a/packages/blocks/src/cms/resolve.ts +++ b/packages/blocks/src/cms/resolve.ts @@ -1,10 +1,3 @@ -import { - type ActionConfig, - inferLoaderTags, - type LoaderConfig, - registerActionSchemas, - registerLoaderSchemas, -} from "./schema"; import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override"; import { getMeter, MetricNames, withTracing } from "../middleware/observability"; import { djb2Hex } from "../sdk/djb2"; @@ -13,6 +6,13 @@ import { withInflightTimeout } from "../sdk/inflightTimeout"; import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; import { findPageByPath, loadBlocks } from "./loader"; import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry"; +import { + type ActionConfig, + inferLoaderTags, + type LoaderConfig, + registerActionSchemas, + registerLoaderSchemas, +} from "./schema"; import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders"; // globalThis-backed: share state across Vite server function split modules @@ -227,7 +227,7 @@ function isEagerSection(key: string): boolean { * during hydration (search filters, configurators, etc.). */ export function registerNeverDeferSections(keys: string[]): void { - const set: Set = G.__deco.neverDeferSectionKeys ??= new Set(); + const set: Set = (G.__deco.neverDeferSectionKeys ??= new Set()); for (const k of keys) set.add(k); } @@ -342,8 +342,7 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean { */ function isProgrammaticFetch(ctx?: MatcherContext): boolean { if (ctx?.isClientNavigation) return false; - const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? - ctx?.headers?.["sec-fetch-dest"]; + const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? ctx?.headers?.["sec-fetch-dest"]; return dest === "empty"; } @@ -354,8 +353,7 @@ function isProgrammaticFetch(ctx?: MatcherContext): boolean { * to gate both section deferral and page-SEO commerce resolution. */ export function isEagerRequest(ctx?: MatcherContext): boolean { - return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || - isProgrammaticFetch(ctx); + return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || isProgrammaticFetch(ctx); } /** @@ -735,7 +733,9 @@ function evaluateVariantRule( const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct); if (already) return already.value; - const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find((f) => f.name === meta.name); + const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find( + (f) => f.name === meta.name, + ); // pct === -1 marks a classic-deco segment without a fingerprint — honor it // (stay sticky) instead of re-rolling. A stale fingerprint re-rolls. const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct); @@ -1264,9 +1264,7 @@ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean { * serialized into the HTML. Lightweight literal props (title, description, * canonical, …) are preserved. */ -function stripCommerceLoaderProps( - rawProps: Record, -): Record { +function stripCommerceLoaderProps(rawProps: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(rawProps)) { if (resolvesToCommerceLoader(v)) continue; @@ -1785,8 +1783,7 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record - typeof fn === "function" && - (fn as { __requestDependent?: boolean }).__requestDependent === true; + typeof fn === "function" && (fn as { __requestDependent?: boolean }).__requestDependent === true; const makeReq = (url = "https://store.example/foo?q=hello") => new Request(url, { headers: { "user-agent": "vitest" } }); @@ -87,12 +86,10 @@ describe("withSectionLoader", () => { it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => { const seen: Array> = []; - const sectionLoader = vi.fn( - async (props: Record) => { - seen.push({ ...props }); - return { ...props, sectionLoaderRan: true }; - }, - ); + const sectionLoader = vi.fn(async (props: Record) => { + seen.push({ ...props }); + return { ...props, sectionLoaderRan: true }; + }); const composed = compose( withSearchParam(), @@ -154,7 +151,14 @@ describe("request-dependent tagging (#206)", () => { }); it("compose does NOT set the flag when no input is request-dependent", () => { - expect(isRequestDependent(compose(async (p) => p, async (p) => p))).toBe(false); + expect( + isRequestDependent( + compose( + async (p) => p, + async (p) => p, + ), + ), + ).toBe(false); }); it("empty compose() is not request-dependent", () => { diff --git a/packages/blocks/src/cms/sectionMixins.ts b/packages/blocks/src/cms/sectionMixins.ts index dd2eb18c..d67543e9 100644 --- a/packages/blocks/src/cms/sectionMixins.ts +++ b/packages/blocks/src/cms/sectionMixins.ts @@ -135,9 +135,7 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn { * }); * ``` */ -export function withSectionLoader( - modImport: () => Promise, -): SectionLoaderFn { +export function withSectionLoader(modImport: () => Promise): SectionLoaderFn { return async (props, req, ctx) => { const mod = (await modImport()) as { loader?: unknown } | undefined; const loader = mod?.loader; diff --git a/packages/nextjs/src/DecoPageRenderer.test.tsx b/packages/nextjs/src/DecoPageRenderer.test.tsx index f2ffa492..1f524379 100644 --- a/packages/nextjs/src/DecoPageRenderer.test.tsx +++ b/packages/nextjs/src/DecoPageRenderer.test.tsx @@ -1,6 +1,6 @@ +import { registerSectionsSync } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { registerSectionsSync } from "@decocms/blocks/cms"; import { DecoPageRenderer } from "./DecoPageRenderer"; function Hero({ label }: { label?: string }) { @@ -16,8 +16,18 @@ describe("DecoPageRenderer (next)", () => { const element = await DecoPageRenderer({ sections: [ - { key: "site/sections/NextPageA.tsx", component: "site/sections/NextPageA.tsx", props: { label: "first" }, index: 0 } as any, - { key: "site/sections/NextPageB.tsx", component: "site/sections/NextPageB.tsx", props: { label: "second" }, index: 1 } as any, + { + key: "site/sections/NextPageA.tsx", + component: "site/sections/NextPageA.tsx", + props: { label: "first" }, + index: 0, + } as any, + { + key: "site/sections/NextPageB.tsx", + component: "site/sections/NextPageB.tsx", + props: { label: "second" }, + index: 1, + } as any, ], pagePath: "/", }); diff --git a/packages/nextjs/src/DecoPageRenderer.tsx b/packages/nextjs/src/DecoPageRenderer.tsx index 37f8452a..77d5f9d8 100644 --- a/packages/nextjs/src/DecoPageRenderer.tsx +++ b/packages/nextjs/src/DecoPageRenderer.tsx @@ -1,8 +1,8 @@ import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms"; import { resolveDeferredSection } from "@decocms/blocks/cms"; -import { SectionRenderer } from "./SectionRenderer"; -import { DeferredSectionBoundary } from "./DeferredSection"; import { cloneElement, type ReactElement, type ReactNode } from "react"; +import { DeferredSectionBoundary } from "./DeferredSection"; +import { SectionRenderer } from "./SectionRenderer"; interface DecoPageRendererProps { sections: ResolvedSection[]; @@ -19,7 +19,9 @@ type PageItem = function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] { const items: PageItem[] = []; - resolved.forEach((section, i) => items.push({ type: "eager", section, sort: section.index ?? i })); + resolved.forEach((section, i) => + items.push({ type: "eager", section, sort: section.index ?? i }), + ); for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index }); items.sort((a, b) => a.sort - b.sort); return items; @@ -54,10 +56,12 @@ export async function DecoPageRenderer({ for (const d of deferredSections) { deferredPromises.set( d.index, - resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then((resolved) => { - if (!resolved) return null; - return { ...resolved, index: d.index }; - }), + resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then( + (resolved) => { + if (!resolved) return null; + return { ...resolved, index: d.index }; + }, + ), ); } diff --git a/packages/nextjs/src/DecoRootLayout.tsx b/packages/nextjs/src/DecoRootLayout.tsx index 22205fdc..159304ce 100644 --- a/packages/nextjs/src/DecoRootLayout.tsx +++ b/packages/nextjs/src/DecoRootLayout.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from "react"; import { LiveControls } from "@decocms/blocks/hooks"; import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics"; +import type { ReactNode } from "react"; function buildDecoEventsBootstrap(account?: string): string { const accountJson = JSON.stringify(account ?? ""); diff --git a/packages/nextjs/src/DeferredSection.test.tsx b/packages/nextjs/src/DeferredSection.test.tsx index e7ced8e7..a6deabc8 100644 --- a/packages/nextjs/src/DeferredSection.test.tsx +++ b/packages/nextjs/src/DeferredSection.test.tsx @@ -1,6 +1,6 @@ +import { registerSectionsSync } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { registerSectionsSync } from "@decocms/blocks/cms"; import { DeferredSectionBoundary } from "./DeferredSection"; function Hero({ label }: { label?: string }) { diff --git a/packages/nextjs/src/DeferredSection.tsx b/packages/nextjs/src/DeferredSection.tsx index 87b1b508..d0929477 100644 --- a/packages/nextjs/src/DeferredSection.tsx +++ b/packages/nextjs/src/DeferredSection.tsx @@ -1,6 +1,6 @@ -import { Suspense, type ReactNode } from "react"; import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms"; import { SectionErrorBoundary } from "@decocms/blocks/hooks"; +import { type ReactNode, Suspense } from "react"; import { SectionRenderer } from "./SectionRenderer"; interface DeferredSectionBoundaryProps { diff --git a/packages/nextjs/src/SectionRenderer.test.tsx b/packages/nextjs/src/SectionRenderer.test.tsx index fcae14d5..e6ac0d15 100644 --- a/packages/nextjs/src/SectionRenderer.test.tsx +++ b/packages/nextjs/src/SectionRenderer.test.tsx @@ -1,6 +1,6 @@ +import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms"; import { SectionRenderer } from "./SectionRenderer"; function Hero({ label }: { label?: string }) { diff --git a/packages/nextjs/src/SectionRenderer.tsx b/packages/nextjs/src/SectionRenderer.tsx index 206ac650..040e270c 100644 --- a/packages/nextjs/src/SectionRenderer.tsx +++ b/packages/nextjs/src/SectionRenderer.tsx @@ -1,11 +1,7 @@ -import { createElement, type ReactNode } from "react"; -import { - getSection, - getSectionOptions, - getSyncComponent, -} from "@decocms/blocks/cms"; import type { ResolvedSection } from "@decocms/blocks/cms"; +import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms"; import { SectionErrorBoundary } from "@decocms/blocks/hooks"; +import { createElement, type ReactNode } from "react"; import { ClientOnlySection } from "./ClientOnlySection"; interface SectionRendererProps { diff --git a/packages/nextjs/src/createDecoPage.draft.test.tsx b/packages/nextjs/src/createDecoPage.draft.test.tsx index 2633a75d..63740689 100644 --- a/packages/nextjs/src/createDecoPage.draft.test.tsx +++ b/packages/nextjs/src/createDecoPage.draft.test.tsx @@ -42,7 +42,7 @@ function seedPublishedHome() { afterEach(() => { cookiesMock.mockClear(); connectionMock.mockClear(); - delete process.env.DECO_DRAFT_PREVIEW_HOST; + delete process.env.DECO_ALLOWED_PREVIEW_HOSTS; delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES; }); @@ -75,7 +75,7 @@ describe("createDecoPage — draft preview gate", () => { }); it("reads cookies() once the feature is fully configured", async () => { - process.env.DECO_DRAFT_PREVIEW_HOST = "preview.example"; + process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example"; seedPublishedHome(); const { default: Page } = createDecoPage({ siteName: "test-site" }); diff --git a/packages/nextjs/src/createDecoPage.test.tsx b/packages/nextjs/src/createDecoPage.test.tsx index 19c10295..5bd1c0f6 100644 --- a/packages/nextjs/src/createDecoPage.test.tsx +++ b/packages/nextjs/src/createDecoPage.test.tsx @@ -1,6 +1,6 @@ +import { registerSections, setBlocks } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { registerSections, setBlocks } from "@decocms/blocks/cms"; import { createDecoPage } from "./createDecoPage"; function Hero({ label }: { label?: string }) { @@ -58,8 +58,6 @@ describe("createDecoPage (next)", () => { const { default: Page } = createDecoPage({ siteName: "test-site" }); - await expect( - Page({ params: Promise.resolve({ slug: ["missing"] }) }), - ).rejects.toThrow(); + await expect(Page({ params: Promise.resolve({ slug: ["missing"] }) })).rejects.toThrow(); }); }); diff --git a/packages/nextjs/src/createDecoPage.tsx b/packages/nextjs/src/createDecoPage.tsx index ab965420..b04887f7 100644 --- a/packages/nextjs/src/createDecoPage.tsx +++ b/packages/nextjs/src/createDecoPage.tsx @@ -90,7 +90,7 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) { * first wins for both. So this must be awaited at the top of BOTH, not * just the page. `cache()` here makes the second call free. * - * Gated on `isDraftPreviewEnabled()` (DECO_DRAFT_PREVIEW_HOST non-empty) — a + * Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a * plain env read, not a dynamic API — * so sites that never opt in behave exactly as before. That gate is doing * real work: `cookies()` and `searchParams` are both dynamic, and touching diff --git a/packages/nextjs/src/draft.ts b/packages/nextjs/src/draft.ts index cc6faa5f..89123464 100644 --- a/packages/nextjs/src/draft.ts +++ b/packages/nextjs/src/draft.ts @@ -106,7 +106,7 @@ export function selectDraftPointer( * `cookies()`. There is deliberately no request header in the path: the page * owns the decision, so a draft keeps working on routes the middleware matcher * never sees, and there is one less forgeable input to reason about. (The - * pointer was never a secret — the sandbox handle is the capability — so a + * pointer was never a secret — the draft id (the token's authority) is the capability — so a * client supplying one directly is equivalent to typing the query param.) * * Returns whether a draft was bound, so callers can surface an explicit @@ -122,7 +122,7 @@ export async function ensureDraft(searchParams?: DraftSearchParams): Promise { // The middleware is host-gated; these tests run as the allowed host. - process.env.DECO_DRAFT_PREVIEW_HOST = "site.example"; + process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example"; }); afterEach(() => { - delete process.env.DECO_DRAFT_PREVIEW_HOST; + delete process.env.DECO_ALLOWED_PREVIEW_HOSTS; }); function request(url: string, cookie?: string): NextRequest { @@ -21,17 +21,21 @@ function request(url: string, cookie?: string): NextRequest { describe("prepareDraft", () => { it("reads the pointer from the param", () => { - expect(prepareDraft(request("https://site.example/p?__draft=abc@v1")).pointer).toBe("abc@v1"); + expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe( + "abc.localhost@v1", + ); }); it("reads the pointer from the cookie on a plain navigation", () => { - expect(prepareDraft(request("https://site.example/p", "abc@v1")).pointer).toBe("abc@v1"); + expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe( + "abc.localhost@v1", + ); }); it("is inert on a host outside the allowlist — the production domain", () => { // Same build, different Host: no cookie, no rewrite, nothing touched. const req = request("https://fila.com.br/p?__draft=abc@v1"); - process.env.DECO_DRAFT_PREVIEW_HOST = "fila.vtex.app"; + process.env.DECO_ALLOWED_PREVIEW_HOSTS = "fila.vtex.app"; expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false }); expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); }); @@ -40,7 +44,7 @@ describe("prepareDraft", () => { // There is no request-header path any more; the page reads searchParams // and cookies itself, so a forged header is simply not an input. const req = new NextRequest(new URL("https://site.example/p"), { - headers: { "x-deco-draft": "attacker@v1" }, + headers: { "x-deco-draft": "attacker.localhost@v1" }, }); expect(prepareDraft(req).pointer).toBeNull(); }); @@ -48,11 +52,11 @@ describe("prepareDraft", () => { describe("applyDraft", () => { it("sets a partitioned cross-site cookie when entering draft mode", () => { - const decision = prepareDraft(request("https://site.example/p?__draft=abc@v1")); + const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")); const res = applyDraft(NextResponse.next(), decision); const setCookie = res.headers.get("set-cookie") ?? ""; - expect(setCookie).toContain(`${DRAFT_COOKIE}=abc%40v1`); + expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`); // The preview iframe is cross-site, so these attributes are what make the // cookie survive at all — not hardening extras. expect(setCookie).toMatch(/SameSite=None/i); @@ -64,7 +68,7 @@ describe("applyDraft", () => { it("marks a draft response uncacheable and unindexable", () => { // With the pointer in a cookie, draft and published share a URL — a CDN // keyed on URL alone would serve unpublished content to a real visitor. - const decision = prepareDraft(request("https://site.example/p", "abc@v1")); + const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1")); const res = applyDraft(NextResponse.next(), decision); expect(res.headers.get("cache-control")).toBe("no-store, private"); @@ -83,7 +87,9 @@ describe("applyDraft", () => { }); it("clears the cookie on ?__draft=off", () => { - const decision = prepareDraft(request("https://site.example/p?__draft=off", "abc@v1")); + const decision = prepareDraft( + request("https://site.example/p?__draft=off", "abc.localhost@v1"), + ); expect(decision.clearCookie).toBe(true); const res = applyDraft(NextResponse.next(), decision); @@ -98,16 +104,16 @@ describe("applyDraft", () => { describe("rewriteToDraftRoute", () => { it("rewrites a drafted request onto the dynamic draft route", () => { - const req = request("https://site.example/blog/hello?__draft=abc@v1"); + const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1"); const res = rewriteToDraftRoute(req, prepareDraft(req)); const target = new URL(res?.headers.get("x-middleware-rewrite") ?? ""); expect(target.pathname).toBe("/_draft/blog/hello"); // The pointer must survive: the draft page reads it from searchParams. - expect(target.searchParams.get("__draft")).toBe("abc@v1"); + expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1"); }); it("rewrites a cookie-only navigation too", () => { - const req = request("https://site.example/blog/hello", "abc@v1"); + const req = request("https://site.example/blog/hello", "abc.localhost@v1"); const res = rewriteToDraftRoute(req, prepareDraft(req)); expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe( "/_draft/blog/hello", @@ -122,12 +128,12 @@ describe("rewriteToDraftRoute", () => { }); it("never nests the prefix when middleware re-runs on the rewritten URL", () => { - const req = request("https://site.example/_draft/blog/hello?__draft=abc@v1"); + const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1"); expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); }); it("does not rewrite when leaving draft mode", () => { - const req = request("https://site.example/blog/hello?__draft=off", "abc@v1"); + const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1"); expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); }); }); diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts index d940a034..46479769 100644 --- a/packages/nextjs/src/draftMiddleware.ts +++ b/packages/nextjs/src/draftMiddleware.ts @@ -40,13 +40,11 @@ import { const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false }; export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { - // Host gate first: on a host not named in DECO_DRAFT_PREVIEW_HOST the + // Host gate first: on a host not named in DECO_ALLOWED_PREVIEW_HOSTS the // decision is inert — no cookie written, no rewrite, no headers touched — // so the production domain behaves as if the feature didn't exist. const host = - request.headers.get("x-forwarded-host") ?? - request.headers.get("host") ?? - request.nextUrl.host; + request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host; if (!isDraftHostAllowed(host)) return INERT; return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null); } From 38c327654be1f63859b74394bb1570fa5ca2189b Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 22:38:10 -0300 Subject: [PATCH 6/7] feat(blocks): source the preview-host allowlist from the site block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in moves into the repo: the global `site` block may declare `previewHosts` (e.g. ["fila.vtex.app", "localhost:3100"]), read once at setup from the BASE blocks and installed via setDraftPreviewHosts. Reviewed in a PR, versioned with branches, and shared with Studio — which reads the same block through the decofile it already fetches — so the value exists exactly once. Read at setup time on purpose, never via loadBlocks() at request time: the request path merges the draft override, and an allowlist readable through the override could be rewritten by the very draft it gates. DECO_ALLOWED_PREVIEW_HOSTS demotes to an operational escape hatch: when set it REPLACES the block hosts (kill a bad value without a deploy, add a machine-specific port); unset, the block is the source; neither → inert, so upgrading still enables nothing. Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/src/cms/draftSource.test.ts | 29 ++++++++++++++++++++ packages/blocks/src/cms/draftSource.ts | 30 +++++++++++++++++++-- packages/blocks/src/cms/index.ts | 1 + packages/nextjs/src/setup.ts | 13 ++++++++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/blocks/src/cms/draftSource.test.ts b/packages/blocks/src/cms/draftSource.test.ts index 91665b71..45b243e4 100644 --- a/packages/blocks/src/cms/draftSource.test.ts +++ b/packages/blocks/src/cms/draftSource.test.ts @@ -8,6 +8,7 @@ import { parseDraftPointer, previewApiOriginForHost, resolveDraftDecofile, + setDraftPreviewHosts, } from "./draftSource"; const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" }; @@ -227,3 +228,31 @@ describe("DEFAULT_PREVIEW_API_DOMAINS", () => { ]); }); }); + +describe("site-block preview hosts", () => { + it("enables the feature from the site block alone — no env needed", () => { + setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]); + try { + expect(isDraftPreviewEnabled({})).toBe(true); + expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true); + // Sanitized: lowercased, non-strings and blanks dropped. + expect(isDraftHostAllowed("localhost:3100", {})).toBe(true); + expect(isDraftHostAllowed("evil.example", {})).toBe(false); + } finally { + setDraftPreviewHosts([]); + } + }); + + it("env REPLACES the block hosts when set — the operational escape hatch", () => { + setDraftPreviewHosts(["fila.vtex.app"]); + try { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" }; + expect(isDraftHostAllowed("other.example", env)).toBe(true); + // Not merged: env is a kill switch / override, so the block value must + // not survive alongside it. + expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false); + } finally { + setDraftPreviewHosts([]); + } + }); +}); diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts index d62f74ff..4dabf9d7 100644 --- a/packages/blocks/src/cms/draftSource.ts +++ b/packages/blocks/src/cms/draftSource.ts @@ -108,12 +108,38 @@ export function previewApiOriginForHost( return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`; } -/** Hosts allowed to render drafts (`DECO_ALLOWED_PREVIEW_HOSTS`, comma list). */ +/** + * Hosts declared by the site itself (the global `site` block's `previewHosts`), + * installed once at setup time by the framework binding. + * + * MUST be fed from the setup-time base blocks, never from `loadBlocks()` at + * request time: the request path merges the draft override, and an allowlist + * readable through the override could be rewritten by the very draft it gates. + */ +let sitePreviewHosts: string[] = []; + +/** Install the site-declared preview hosts. Called by the framework binding at setup. */ +export function setDraftPreviewHosts(hosts: readonly unknown[]): void { + sitePreviewHosts = hosts + .filter((h): h is string => typeof h === "string") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Hosts allowed to render drafts. + * + * The site block is the expected source — the opt-in lives in the repo, + * reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS` + * REPLACES it when set: an operational escape hatch (kill a bad value without + * a deploy, add a machine-specific port) — not the primary configuration. + */ function readAllowedHosts(env: Record): string[] { - return (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") + const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); + return fromEnv.length > 0 ? fromEnv : sitePreviewHosts; } /** diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 1dde6fe4..94a323b5 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -23,6 +23,7 @@ export { previewApiOriginForHost, resolveDraftDecofile, setDraftOverrideGetter, + setDraftPreviewHosts, } from "./draftSource"; export type { DecoPage, Resolvable } from "./loader"; export { diff --git a/packages/nextjs/src/setup.ts b/packages/nextjs/src/setup.ts index bdf75942..14589a5d 100644 --- a/packages/nextjs/src/setup.ts +++ b/packages/nextjs/src/setup.ts @@ -38,7 +38,7 @@ * deploys must ship the directory alongside the server bundle). */ import type { ApplySectionConventionsInput } from "@decocms/blocks/cms"; -import { applySectionConventions, loadBlocks } from "@decocms/blocks/cms"; +import { applySectionConventions, loadBlocks, setDraftPreviewHosts } from "@decocms/blocks/cms"; import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory"; import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup"; @@ -121,6 +121,17 @@ export function createNextSetup(options: NextSetupOptions): () => Promise : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks"); const blocks = { ...dirBlocks, ...options.blocks }; + // Draft preview opt-in from the repo itself: the global `site` block may + // declare `previewHosts` (e.g. ["fila.vtex.app", "localhost:3100"]). + // Read here, from the SETUP-TIME base blocks — never via loadBlocks() + // at request time, where the draft override is merged in: an allowlist + // readable through the override could be rewritten by the very draft it + // gates. DECO_ALLOWED_PREVIEW_HOSTS remains an operational override. + const siteBlock = blocks.site as { previewHosts?: unknown } | undefined; + if (Array.isArray(siteBlock?.previewHosts)) { + setDraftPreviewHosts(siteBlock.previewHosts); + } + createSiteSetup({ sections: options.sections, blocks, From a8e96e79b33869b826bd4ae87502b8f3d48ac7e0 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 28 Jul 2026 22:55:00 -0300 Subject: [PATCH 7/7] fix(blocks): survive the middleware/server module-graph split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut installed the site-block hosts lazily (inside ensureSetup) into plain module state — and the wire test failed both ways: pages call ensureDraft BEFORE they resolve CMS content, so the gate ran ahead of the install; and the middleware bundle is a separate module graph/runtime that never runs setup at all, so its copy of the allowlist stayed empty forever. Three-part fix, one lesson (module state does not travel): - hosts live on globalThis, the same pattern the block loader already uses against bundler-duplicated module instances; - createNextSetup installs them synchronously at module evaluation (the blocksDir mode still re-installs after its fs read); - prepareDraft hard-gates only when DECO_ALLOWED_PREVIEW_HOSTS is set — the env override keeps full kill-switch power — and otherwise passes through, leaving the page-side ensureDraft as the authoritative gate. Worst case on a non-preview host is a cookie the page gate then ignores. Verified on the wire with zero env: draft renders on the block-listed host, fila.com.br stays published on the same build. Co-Authored-By: Claude Opus 5 (1M context) --- packages/blocks/src/cms/draftSource.ts | 11 +++++-- packages/nextjs/src/draftMiddleware.test.ts | 8 +++++ packages/nextjs/src/draftMiddleware.ts | 20 +++++++++---- packages/nextjs/src/setup.ts | 33 ++++++++++++++------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts index 4dabf9d7..288ab8b4 100644 --- a/packages/blocks/src/cms/draftSource.ts +++ b/packages/blocks/src/cms/draftSource.ts @@ -116,11 +116,16 @@ export function previewApiOriginForHost( * request time: the request path merges the draft override, and an allowlist * readable through the override could be rewritten by the very draft it gates. */ -let sitePreviewHosts: string[] = []; +// globalThis-backed, like the block loader itself: bundlers can duplicate +// this module across graphs, and a plain module variable set in one instance +// is invisible to the others. (The MIDDLEWARE runtime is a separate world +// even so — which is why the page-side gate is the authoritative one and the +// middleware only hard-gates when the env override is present.) +const G = globalThis as { __decoDraftHosts?: string[] }; /** Install the site-declared preview hosts. Called by the framework binding at setup. */ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { - sitePreviewHosts = hosts + G.__decoDraftHosts = hosts .filter((h): h is string => typeof h === "string") .map((h) => h.trim().toLowerCase()) .filter(Boolean); @@ -139,7 +144,7 @@ function readAllowedHosts(env: Record): string[] { .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); - return fromEnv.length > 0 ? fromEnv : sitePreviewHosts; + return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); } /** diff --git a/packages/nextjs/src/draftMiddleware.test.ts b/packages/nextjs/src/draftMiddleware.test.ts index f27e5a10..de4d4ed2 100644 --- a/packages/nextjs/src/draftMiddleware.test.ts +++ b/packages/nextjs/src/draftMiddleware.test.ts @@ -32,6 +32,14 @@ describe("prepareDraft", () => { ); }); + it("passes through when no env override is set — the page gate is authoritative", () => { + // The site-block hosts live in the server-components graph; middleware + // can't see them, so without the env kill switch it must not block. + delete process.env.DECO_ALLOWED_PREVIEW_HOSTS; + const req = request("https://anything.example/p?__draft=abc.localhost@v1"); + expect(prepareDraft(req).pointer).toBe("abc.localhost@v1"); + }); + it("is inert on a host outside the allowlist — the production domain", () => { // Same build, different Host: no cookie, no rewrite, nothing touched. const req = request("https://fila.com.br/p?__draft=abc@v1"); diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts index 46479769..7b008ad7 100644 --- a/packages/nextjs/src/draftMiddleware.ts +++ b/packages/nextjs/src/draftMiddleware.ts @@ -40,12 +40,20 @@ import { const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false }; export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { - // Host gate first: on a host not named in DECO_ALLOWED_PREVIEW_HOSTS the - // decision is inert — no cookie written, no rewrite, no headers touched — - // so the production domain behaves as if the feature didn't exist. - const host = - request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host; - if (!isDraftHostAllowed(host)) return INERT; + // The AUTHORITATIVE host gate lives page-side (`ensureDraft`): the site + // block's `previewHosts` are installed in the server-components graph, and + // middleware runs in a separate module graph/runtime that never sees them. + // Middleware therefore hard-gates only on the env override — keeping + // DECO_ALLOWED_PREVIEW_HOSTS a full kill switch — and otherwise passes the + // decision through. Worst case on a non-preview host is a cookie the + // page-side gate then ignores; drafts never render there. + if (process.env.DECO_ALLOWED_PREVIEW_HOSTS) { + const host = + request.headers.get("x-forwarded-host") ?? + request.headers.get("host") ?? + request.nextUrl.host; + if (!isDraftHostAllowed(host)) return INERT; + } return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null); } diff --git a/packages/nextjs/src/setup.ts b/packages/nextjs/src/setup.ts index 14589a5d..388c8e92 100644 --- a/packages/nextjs/src/setup.ts +++ b/packages/nextjs/src/setup.ts @@ -110,9 +110,29 @@ export interface NextSetupOptions { * instead, nothing imports the JSON, so edits invalidate nothing and dev * serves stale content until a full restart. */ +/** + * Install the site block's `previewHosts` as the draft-preview allowlist. + * + * Reads the BASE blocks handed to setup — never `loadBlocks()` at request + * time, where the draft override is merged in: an allowlist readable through + * the override could be rewritten by the very draft it gates. + * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces + * this list when set. + */ +function installPreviewHosts(blocks: Record | undefined): void { + const site = blocks?.site as { previewHosts?: unknown } | undefined; + if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts); +} + export function createNextSetup(options: NextSetupOptions): () => Promise { let setupPromise: Promise | null = null; + // Draft preview opt-in from the repo: read SYNCHRONOUSLY at createNextSetup + // time (module evaluation), not inside the lazy ensureSetup — pages call + // `ensureDraft` BEFORE they resolve CMS content, so hosts installed lazily + // would arrive after the gate already said no on the first request. + installPreviewHosts(options.blocks); + return function ensureSetup(): Promise { setupPromise ??= (async () => { const dirBlocks = @@ -120,17 +140,8 @@ export function createNextSetup(options: NextSetupOptions): () => Promise ? {} : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks"); const blocks = { ...dirBlocks, ...options.blocks }; - - // Draft preview opt-in from the repo itself: the global `site` block may - // declare `previewHosts` (e.g. ["fila.vtex.app", "localhost:3100"]). - // Read here, from the SETUP-TIME base blocks — never via loadBlocks() - // at request time, where the draft override is merged in: an allowlist - // readable through the override could be rewritten by the very draft it - // gates. DECO_ALLOWED_PREVIEW_HOSTS remains an operational override. - const siteBlock = blocks.site as { previewHosts?: unknown } | undefined; - if (Array.isArray(siteBlock?.previewHosts)) { - setDraftPreviewHosts(siteBlock.previewHosts); - } + // Covers the blocksDir mode, where blocks only exist after the fs read. + installPreviewHosts(blocks); createSiteSetup({ sections: options.sections,