From 96a9a93918a663ac137b588c0d164ff2e365ad3f Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Tue, 4 Aug 2026 13:15:28 -0300 Subject: [PATCH 01/27] feat(fetch): record outgoing fetch duration by external host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This wrapper already measured the duration of every outgoing fetch — it just discarded it unless a logger happened to be installed, and `logger` is null in production: const start = logger && performance.now(); So there was no metric answering "is the external API slow, or are we making too many calls to it?". The only alternative was `otel_traces`, which is tail-sampled at ~1.7% and carries no client spans for these calls at all. In practice that meant a `load-data` span of 157s could not be attributed to anything, and diagnosis fell back to guessing. Adds an `outgoing_fetch_duration` histogram, dimensioned by external host and status class. Notes on the design, both copied from what already works in this repo: - `unit: "ms"`. The meter provider in `observability/otel/metrics.ts` selects bucket boundaries by unit, so "ms" picks up `[10, 100, 500, 1000, 5000, 10000, 15000]` automatically. Recording seconds would put every observation in the first bucket — which is exactly the bug the @decocms/start runtime currently has on its four duration metrics (99.8%-99.95% in bucket 1, measured). - Low cardinality by construction: `server.address` is the host, never the path, and status is bucketed into a class rather than the raw code. Measured on a large VTEX storefront, a site talks to 6 distinct hosts, so this is ~6 x 5 series per site. For comparison, `loader_cache` reaches 3684 distinct label values on a single site because it uses the full resolver chain as a label. - Failures are recorded, not dropped. A call that hangs for 60s and then aborts is the sample you most want and the one a success-only path loses. The error is re-thrown untouched. - `hostOf` returns null instead of throwing on a malformed input, and a null host skips the sample. A metric must not be able to break the fetch path. The logger behaviour is unchanged. Verified: `deno check runtime/fetch/fetchLog.ts` clean. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/fetch/fetchLog.ts | 77 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/runtime/fetch/fetchLog.ts b/runtime/fetch/fetchLog.ts index cf7cf3c5f..75866c625 100644 --- a/runtime/fetch/fetchLog.ts +++ b/runtime/fetch/fetchLog.ts @@ -1,18 +1,89 @@ +import { ValueType } from "../../deps.ts"; +import { meter } from "../../observability/otel/metrics.ts"; import { formatOutgoingFetch } from "../../utils/log.ts"; let logger: null | ((_: string) => void) = null; export const setLogger = (loggerLike: typeof logger) => logger = loggerLike; +/** + * Duration of every outgoing fetch, dimensioned by the external host. + * + * This wrapper already measured the duration — it just threw it away unless a + * logger happened to be installed, and the logger is null in production. So + * there was no way to answer "is the external API slow, or are we making too + * many calls to it?" from metrics; the only alternative was `otel_traces`, + * which is tail-sampled and does not carry client spans for these calls at all. + * + * `unit: "ms"` is deliberate: the meter provider in + * `observability/otel/metrics.ts` selects bucket boundaries by unit, so + * declaring "ms" picks up `[10, 100, 500, 1000, 5000, 10000, 15000]` + * automatically. Recording seconds here would land every observation in the + * first bucket. + * + * Cardinality: `server.address` is the host, never the path — a storefront + * talks to a handful of hosts (measured: 6 on a large VTEX store). Status is + * bucketed into a class rather than the raw code, keeping this at roughly + * 6 hosts x 5 classes per site. + */ +const outgoingFetchDuration = meter.createHistogram( + "outgoing_fetch_duration", + { + description: "duration of outgoing fetch calls, by external host", + unit: "ms", + valueType: ValueType.DOUBLE, + }, +); + +const statusClass = (status: number): string => + status >= 500 ? "5xx" : status >= 400 ? "4xx" : status >= 300 ? "3xx" : "2xx"; + +/** + * Host of the request, or null when it cannot be derived. Returning null keeps + * a malformed input from turning into a metric label — and from throwing inside + * the fetch path, which would be a far worse failure than a missing sample. + */ +const hostOf = (input: string | Request | URL): string | null => { + try { + if (typeof input === "string") return new URL(input).host; + if (input instanceof URL) return input.host; + return new URL(input.url).host; + } catch { + return null; + } +}; + export const createFetch = (fetcher: typeof fetch): typeof fetch => async function fetch( input: string | Request | URL, init?: RequestInit, ) { - const start = logger && performance.now(); - const response = await fetcher(input, init); + const start = performance.now(); + const host = hostOf(input); + + const record = (status: string) => { + if (host === null) return; + outgoingFetchDuration.record(Math.round(performance.now() - start), { + "server.address": host, + "http.response.status_class": status, + }); + }; + + let response: Response; + try { + response = await fetcher(input, init); + } catch (error) { + // A throw here is an abort, a timeout or a transport failure. Those are + // the samples worth having most — a call that hangs for 60s and then + // aborts is invisible if only successful responses are recorded — so the + // duration is kept and the error is re-thrown untouched. + record("error"); + throw error; + } + + record(statusClass(response.status)); - if (logger && start) { + if (logger) { logger( formatOutgoingFetch( new Request(input, init), From d24f22ac00c73da1bb05921a8a9de16182f93da4 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Tue, 4 Aug 2026 13:31:29 -0300 Subject: [PATCH 02/27] fix(fetch): use hostname instead of host for server.address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `URL.host` appends a non-default port, so `example.com:8080` and `example.com` would become two distinct `server.address` label values for the same host — inflating exactly the cardinality this metric is careful about, and undercutting the "6 hosts x 5 classes per site" claim in its own docstring. `hostname` also matches semconv, where `server.address` is the address alone and `server.port` is a separate attribute. Caught by CodeRabbit on #1218. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/fetch/fetchLog.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/runtime/fetch/fetchLog.ts b/runtime/fetch/fetchLog.ts index 75866c625..961baedee 100644 --- a/runtime/fetch/fetchLog.ts +++ b/runtime/fetch/fetchLog.ts @@ -21,7 +21,7 @@ export const setLogger = (loggerLike: typeof logger) => logger = loggerLike; * automatically. Recording seconds here would land every observation in the * first bucket. * - * Cardinality: `server.address` is the host, never the path — a storefront + * Cardinality: `server.address` is the hostname, never the path — a storefront * talks to a handful of hosts (measured: 6 on a large VTEX store). Status is * bucketed into a class rather than the raw code, keeping this at roughly * 6 hosts x 5 classes per site. @@ -39,15 +39,22 @@ const statusClass = (status: number): string => status >= 500 ? "5xx" : status >= 400 ? "4xx" : status >= 300 ? "3xx" : "2xx"; /** - * Host of the request, or null when it cannot be derived. Returning null keeps - * a malformed input from turning into a metric label — and from throwing inside - * the fetch path, which would be a far worse failure than a missing sample. + * Hostname of the request, or null when it cannot be derived. Returning null + * keeps a malformed input from turning into a metric label — and from throwing + * inside the fetch path, which would be a far worse failure than a missing + * sample. + * + * `hostname`, not `host`: `host` appends a non-default port, so + * `example.com:8080` and `example.com` would become two labels for one host and + * inflate the very cardinality this metric is careful about. It also matches + * semconv, where `server.address` is the address alone and `server.port` is a + * separate attribute. */ const hostOf = (input: string | Request | URL): string | null => { try { - if (typeof input === "string") return new URL(input).host; - if (input instanceof URL) return input.host; - return new URL(input.url).host; + if (typeof input === "string") return new URL(input).hostname; + if (input instanceof URL) return input.hostname; + return new URL(input.url).hostname; } catch { return null; } From 972ebe346078c08a44ccc047f7c88193664baf56 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Tue, 4 Aug 2026 13:53:10 -0300 Subject: [PATCH 03/27] fix(fetch): fold empty hostname into null in hostOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authority-less schemes — `data:`, `blob:`, `file:` — parse fine but have no hostname, so they returned "" and `record` only skipped on null. That would have created a meaningless `server.address=""` series for calls that never crossed the network. Also collapses the three-branch return into a single URL construction. Verified: https://a.com/x -> a.com https://a.com:8080/x -> a.com data:text/plain,hi -> null file:///tmp/x -> null nonsense -> null Caught by cubic on #1218. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/fetch/fetchLog.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/runtime/fetch/fetchLog.ts b/runtime/fetch/fetchLog.ts index 961baedee..c9b87d253 100644 --- a/runtime/fetch/fetchLog.ts +++ b/runtime/fetch/fetchLog.ts @@ -49,12 +49,20 @@ const statusClass = (status: number): string => * inflate the very cardinality this metric is careful about. It also matches * semconv, where `server.address` is the address alone and `server.port` is a * separate attribute. + * + * The empty-string case is folded into null on purpose. Authority-less schemes + * — `data:`, `blob:`, `file:` — parse fine but have no hostname, and recording + * them would create a meaningless `server.address=""` series instead of simply + * not sampling a call that never crossed the network. */ const hostOf = (input: string | Request | URL): string | null => { try { - if (typeof input === "string") return new URL(input).hostname; - if (input instanceof URL) return input.hostname; - return new URL(input.url).hostname; + const url = typeof input === "string" + ? new URL(input) + : input instanceof URL + ? input + : new URL(input.url); + return url.hostname || null; } catch { return null; } From bc1912f0f309b59264287be98af89ff1e2fd2b90 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 12 Aug 2026 16:43:23 -0300 Subject: [PATCH 04/27] feat(preview): sandbox-less Fast Preview via pull-based draft decofile (#1222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(preview): sandbox-less Fast Preview via pull-based draft decofile Port of decocms/blocks#463 to the Deno runtime. A production site can render a draft by PULLING the draft decofile from Studio's decofile API and rendering its own real pages against it, as a request-scoped snapshot — no sandbox daemon. - engine/decofile/draft.ts: `?__draft=?token=…@` pointer parsing (last-`@` split), authority validation against preview API domains (scheme derived, no SSRF), host gating (DECO_ALLOWED_PREVIEW_HOSTS / site-declared previewHosts), version-bounded cache, cookie helper. - runtime/mod.ts (prepareState): the single per-request seam covering all routes. When a draft resolves, swap `state.release` for `fromJSON(draft)` + rebind the resolver (`resolver.with({release})`) — snapshot semantics for free (deletions work). Force no-store + noindex; persist the pointer cookie so /deco/render and /deco/invoke partials carry the same draft. - engine/mod.ts: re-export the public API. - engine/decofile/draft.test.ts: 9 tests / 31 steps mirroring the Node suite. Verified end-to-end against a real Fresh site (demo-linkedin): draft renders the pulled snapshot, gates (host/origin/off/cookie-nav) all behave, published traffic is untouched. Co-Authored-By: Claude Opus 4.8 * fix(preview): key draft cache by host+path+version; harden cookie decode Addresses review of #1222: - draftSource cache was keyed by `version` alone; a runtime pointed at more than one draft source that reuse a version label (`v1`, a branch id) could serve the wrong snapshot. Key on `@` so a hit is always the right source. `version` is a sha in production, but the type accepts any short label — so this closes a real collision class. - `readCookieValue` now degrades a malformed `%`-sequence cookie to null instead of letting `decodeURIComponent` throw at direct callers of the exported `draftPointerFromRequest`. - Tests: cross-source cache non-collision + malformed-cookie null (33 steps). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- engine/decofile/draft.test.ts | 531 ++++++++++++++++++++++++++++++++++ engine/decofile/draft.ts | 444 ++++++++++++++++++++++++++++ engine/mod.ts | 21 ++ runtime/mod.ts | 41 ++- 4 files changed, 1035 insertions(+), 2 deletions(-) create mode 100644 engine/decofile/draft.test.ts create mode 100644 engine/decofile/draft.ts diff --git a/engine/decofile/draft.test.ts b/engine/decofile/draft.test.ts new file mode 100644 index 000000000..b6277ef64 --- /dev/null +++ b/engine/decofile/draft.test.ts @@ -0,0 +1,531 @@ +import { assert, assertEquals } from "@std/assert"; +import { + applyDraftCookie, + clearDraftCache, + DEFAULT_PREVIEW_API_DOMAINS, + draftPointerFromRequest, + isDraftHostAllowed, + isDraftPreviewEnabled, + parseDraftPointer, + previewApiOriginForHost, + resolveDraftDecofile, + resolveDraftForRequest, + setDraftPreviewHosts, +} from "./draft.ts"; + +const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" }; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +// Canonical Studio decofile-API pointer prefix (authority + path, no version). +const P = "studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc"; + +Deno.test("parseDraftPointer", async (t) => { + await t.step( + "parses @, lowercasing authority", + () => { + assertEquals( + parseDraftPointer( + "Studio.decocms.com/api/fila/decofile/vm-1/main?token=Tok.abc@8c1d44e", + ), + { + host: "studio.decocms.com", + path: "/api/fila/decofile/vm-1/main?token=Tok.abc", + version: "8c1d44e", + }, + ); + }, + ); + + await t.step("keeps an explicit port, incl. bare localhost", () => { + assertEquals(parseDraftPointer("localhost:4000/api/o/decofile/m/b@v1"), { + host: "localhost:4000", + path: "/api/o/decofile/m/b", + version: "v1", + }); + }); + + await t.step( + "splits on the LAST @ — earlier @ fails, never half-reads", + () => { + assertEquals(parseDraftPointer("a.example/x@y/z@v1"), null); + assertEquals(parseDraftPointer("a@b@c"), null); + }, + ); + + await t.step( + "requires a rooted path — authority-only tokens are gone", + () => { + assertEquals( + parseDraftPointer("abc.preview-studio.decocms.com@v1"), + null, + ); + assertEquals(parseDraftPointer("a.example@v1"), null); + }, + ); + + await t.step("rejects anything escaping the authority or path", () => { + assertEquals(parseDraftPointer("https://evil.example/x@v1"), null); + assertEquals(parseDraftPointer("a.example:80:80/x@v1"), null); + assertEquals(parseDraftPointer("a.example:abc/x@v1"), null); + assertEquals(parseDraftPointer(".leading.dot/x@v1"), null); + assertEquals(parseDraftPointer("a.example/x#frag@v1"), null); + assertEquals(parseDraftPointer("a.example/x y@v1"), null); + }); + + await t.step("validates the version charset — it becomes a cache key", () => { + assertEquals(parseDraftPointer("a.example/x@"), null); + assertEquals(parseDraftPointer(`a.example/x@${"x".repeat(65)}`), null); + assertEquals(parseDraftPointer("a.example/x@v 1"), null); + assertEquals(parseDraftPointer(null), null); + }); +}); + +Deno.test("previewApiOriginForHost", async (t) => { + await t.step("admits authorities under the default deco domains", () => { + assertEquals( + previewApiOriginForHost("studio.decocms.com", {}), + "https://studio.decocms.com", + ); + assertEquals( + previewApiOriginForHost("abc.preview-studio.decocms.com", {}), + "https://abc.preview-studio.decocms.com", + ); + assertEquals( + previewApiOriginForHost("localhost:4000", {}), + "http://localhost:4000", + ); + assertEquals( + previewApiOriginForHost("local.studio.decocms.com:4420", {}), + "https://local.studio.decocms.com:4420", + ); + assertEquals( + previewApiOriginForHost("abc.localhost:60534", {}), + "http://abc.localhost:60534", + ); + }); + + await t.step("rejects hosts outside the domains", () => { + assertEquals(previewApiOriginForHost("evil.example", {}), null); + assertEquals(previewApiOriginForHost("evil-decocms.com", {}), null); + assertEquals(previewApiOriginForHost("decocms.com", {}), null); + }); + + await t.step("allows an explicit port only for local entries", () => { + assertEquals(previewApiOriginForHost("studio.decocms.com:8500", {}), null); + assertEquals( + previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {}), + null, + ); + }); + + await t.step("honours a configured override instead of the defaults", () => { + const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" }; + assertEquals( + previewApiOriginForHost("abc.staging.example", env), + "https://abc.staging.example", + ); + assertEquals(previewApiOriginForHost("studio.decocms.com", env), null); + }); +}); + +Deno.test("gating", async (t) => { + await t.step("on iff an allowed host is configured", () => { + assertEquals(isDraftPreviewEnabled(ENV_ON), true); + assertEquals(isDraftPreviewEnabled({}), false); + }); + + await t.step("matches request hosts verbatim, port + case", () => { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" }; + assertEquals(isDraftHostAllowed("FILA.VTEX.APP", env), true); + assertEquals(isDraftHostAllowed("localhost:3100", env), true); + assertEquals(isDraftHostAllowed("fila.com.br", env), false); + assertEquals(isDraftHostAllowed("localhost", env), false); + assertEquals(isDraftHostAllowed(null, env), false); + assertEquals(isDraftHostAllowed("fila.vtex.app", {}), false); + }); +}); + +Deno.test("resolveDraftDecofile", async (t) => { + await t.step("fetches the token's path on its validated origin", async () => { + clearDraftCache(); + const calls: string[] = []; + const blocks = await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: ENV_ON, + fetchImpl: ((url: string) => { + calls.push(String(url)); + return Promise.resolve( + jsonResponse({ "pages-home": { title: "draft" } }), + ); + }) as unknown as typeof fetch, + }); + assertEquals(blocks, { "pages-home": { title: "draft" } }); + assertEquals(calls, [ + "https://studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc&v=v1", + ]); + }); + + await t.step("is inert without a host allowlist — no fetch", async () => { + clearDraftCache(); + let called = false; + const blocks = await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: {}, + fetchImpl: (() => { + called = true; + return Promise.resolve(jsonResponse({})); + }) as unknown as typeof fetch, + }); + assertEquals(blocks, null); + assertEquals(called, false); + }); + + await t.step("origin validation runs before the cache", async () => { + clearDraftCache(); + let called = false; + const fetchImpl = (() => { + called = true; + return Promise.resolve(jsonResponse({})); + }) as unknown as typeof fetch; + assertEquals( + await resolveDraftDecofile({ + pointer: "abc.evil.example/x@v1", + env: ENV_ON, + fetchImpl, + }), + null, + ); + assertEquals(called, false); + await resolveDraftDecofile({ pointer: `${P}@vX`, env: ENV_ON, fetchImpl }); + assertEquals(called, true); + assertEquals( + await resolveDraftDecofile({ + pointer: "abc.evil.example/x@vX", + env: ENV_ON, + fetchImpl, + }), + null, + ); + }); + + await t.step("caches by version — one fetch per version", async () => { + clearDraftCache(); + let fetches = 0; + const fetchImpl = (() => { + fetches++; + return Promise.resolve(jsonResponse({ n: fetches })); + }) as unknown as typeof fetch; + const a = await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: ENV_ON, + fetchImpl, + }); + const b = await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: ENV_ON, + fetchImpl, + }); + assertEquals(fetches, 1); + assert(b === a); + await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl }); + assertEquals(fetches, 2); + }); + + await t.step( + "does not collide two sources sharing a version label", + async () => { + clearDraftCache(); + const bodies: Record = { + "studio.decocms.com/api/a/decofile/vm/main": { src: "a" }, + "studio.decocms.com/api/b/decofile/vm/main": { src: "b" }, + }; + const fetchImpl = ((url: string) => { + const u = new URL(String(url)); + return Promise.resolve(jsonResponse(bodies[`${u.host}${u.pathname}`])); + }) as unknown as typeof fetch; + const a = await resolveDraftDecofile({ + pointer: "studio.decocms.com/api/a/decofile/vm/main@v1", + env: ENV_ON, + fetchImpl, + }); + const b = await resolveDraftDecofile({ + pointer: "studio.decocms.com/api/b/decofile/vm/main@v1", + env: ENV_ON, + fetchImpl, + }); + // Same version label "v1", different path → must NOT serve a's cache for b. + assertEquals(a, { src: "a" }); + assertEquals(b, { src: "b" }); + }, + ); + + await t.step("bounds the cache (cap 3)", async () => { + clearDraftCache(); + let fetches = 0; + const fetchImpl = (() => { + fetches++; + return Promise.resolve(jsonResponse({ n: fetches })); + }) as unknown as typeof fetch; + for (const v of ["v1", "v2", "v3", "v4"]) { + await resolveDraftDecofile({ + pointer: `${P}@${v}`, + env: ENV_ON, + fetchImpl, + }); + } + assertEquals(fetches, 4); + await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); + assertEquals(fetches, 5); // v1 evicted — re-fetch, never stale + await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl }); + assertEquals(fetches, 5); // v4 resident + }); + + await t.step( + "degrades to published on unreachable / non-2xx / bad json", + async () => { + for ( + const fetchImpl of [ + () => Promise.reject(new Error("ECONNREFUSED")), + () => Promise.resolve(new Response("nope", { status: 404 })), + () => + Promise.resolve( + new Response("not json", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ] + ) { + clearDraftCache(); + assertEquals( + await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: ENV_ON, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + null, + ); + } + }, + ); +}); + +Deno.test("DEFAULT_PREVIEW_API_DOMAINS is pinned", () => { + assertEquals(DEFAULT_PREVIEW_API_DOMAINS, [ + "local.studio.decocms.com", + "localhost", + "127.0.0.1", + ".localhost", + ".decocms.com", + ]); +}); + +Deno.test("site-block preview hosts", async (t) => { + await t.step("enables the feature from the site block alone", () => { + setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]); + try { + assertEquals(isDraftPreviewEnabled({}), true); + assertEquals(isDraftHostAllowed("fila.vtex.app", {}), true); + assertEquals(isDraftHostAllowed("localhost:3100", {}), true); + assertEquals(isDraftHostAllowed("evil.example", {}), false); + } finally { + setDraftPreviewHosts([]); + } + }); + + await t.step("env REPLACES the block hosts when set", () => { + setDraftPreviewHosts(["fila.vtex.app"]); + try { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" }; + assertEquals(isDraftHostAllowed("other.example", env), true); + assertEquals(isDraftHostAllowed("fila.vtex.app", env), false); + } finally { + setDraftPreviewHosts([]); + } + }); +}); + +Deno.test("draftPointerFromRequest", async (t) => { + await t.step("reads the __deco_draft cookie", () => { + const req = new Request( + "https://preview.example/deco/invoke/site/loaders/x.ts", + { + headers: { + cookie: "a=1; __deco_draft=abc.preview-studio.decocms.com@v1; b=2", + }, + }, + ); + assertEquals( + draftPointerFromRequest(req), + "abc.preview-studio.decocms.com@v1", + ); + }); + + await t.step("lets ?__draft= win over the cookie", () => { + const req = new Request("https://preview.example/p?__draft=h@v2", { + headers: { cookie: "__deco_draft=h@v1" }, + }); + assertEquals(draftPointerFromRequest(req), "h@v2"); + }); + + await t.step("returns null on ?__draft=off even with a cookie", () => { + const req = new Request("https://preview.example/p?__draft=off", { + headers: { cookie: "__deco_draft=h@v1" }, + }); + assertEquals(draftPointerFromRequest(req), null); + }); + + await t.step("returns null with neither param nor cookie", () => { + assertEquals( + draftPointerFromRequest(new Request("https://preview.example/p")), + null, + ); + }); + + await t.step( + "returns null (never throws) on a malformed cookie value", + () => { + const req = new Request("https://preview.example/p", { + headers: { cookie: "__deco_draft=%E0%A4%A" }, + }); + assertEquals(draftPointerFromRequest(req), null); + }, + ); +}); + +Deno.test("resolveDraftForRequest", async (t) => { + const ENV = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" }; + function invokeReq(host = "preview.example"): Request { + return new Request( + "https://preview.example/deco/invoke/site/loaders/x.ts", + { + method: "POST", + headers: { + "x-forwarded-host": host, + cookie: `__deco_draft=${encodeURIComponent(`${P}@v1`)}`, + }, + }, + ); + } + + await t.step( + "binds the draft when host allowed and pointer resolves", + async () => { + clearDraftCache(); + const fetchImpl = (() => + Promise.resolve( + jsonResponse({ "site/x": { value: "draft" } }), + )) as unknown as typeof fetch; + assertEquals( + await resolveDraftForRequest(invokeReq(), { env: ENV, fetchImpl }), + { + "site/x": { value: "draft" }, + }, + ); + }, + ); + + await t.step( + "is inert with no allowlist — never touches the network", + async () => { + clearDraftCache(); + let called = false; + const fetchImpl = (() => { + called = true; + return Promise.resolve(jsonResponse({})); + }) as unknown as typeof fetch; + assertEquals( + await resolveDraftForRequest(invokeReq(), { env: {}, fetchImpl }), + null, + ); + assertEquals(called, false); + }, + ); + + await t.step("refuses a disallowed request host", async () => { + clearDraftCache(); + let called = false; + const fetchImpl = (() => { + called = true; + return Promise.resolve(jsonResponse({})); + }) as unknown as typeof fetch; + assertEquals( + await resolveDraftForRequest(invokeReq("evil.example"), { + env: ENV, + fetchImpl, + }), + null, + ); + assertEquals(called, false); + }); +}); + +Deno.test("applyDraftCookie", async (t) => { + await t.step("sets the __deco_draft cookie for an allowed host", () => { + const headers = new Headers(); + applyDraftCookie( + new Request( + `https://preview.example/p?__draft=${encodeURIComponent(`${P}@v1`)}`, + { + headers: { host: "preview.example" }, + }, + ), + headers, + ENV_ON, + ); + const setCookie = headers.get("set-cookie") ?? ""; + assert(setCookie.includes("__deco_draft=")); + assert(setCookie.includes("HttpOnly")); + }); + + await t.step("clears the cookie on ?__draft=off, on any host", () => { + const headers = new Headers(); + applyDraftCookie( + new Request("https://prod.example/p?__draft=off", { + headers: { host: "prod.example" }, + }), + headers, + ENV_ON, + ); + const setCookie = headers.get("set-cookie") ?? ""; + assert(setCookie.includes("__deco_draft=")); + assert( + setCookie.toLowerCase().includes("max-age=0") || + setCookie.includes("Expires"), + ); + }); + + await t.step("does nothing without a __draft param", () => { + const headers = new Headers(); + applyDraftCookie( + new Request("https://preview.example/p", { + headers: { host: "preview.example" }, + }), + headers, + ENV_ON, + ); + assertEquals(headers.get("set-cookie"), null); + }); + + await t.step("does not set a cookie on a non-allowed host", () => { + const headers = new Headers(); + applyDraftCookie( + new Request( + `https://evil.example/p?__draft=${encodeURIComponent(`${P}@v1`)}`, + { + headers: { host: "evil.example" }, + }, + ), + headers, + ENV_ON, + ); + assertEquals(headers.get("set-cookie"), null); + }); +}); diff --git a/engine/decofile/draft.ts b/engine/decofile/draft.ts new file mode 100644 index 000000000..c078cd0e3 --- /dev/null +++ b/engine/decofile/draft.ts @@ -0,0 +1,444 @@ +import { deleteCookie, setCookie } from "@std/http"; + +/** + * Fast Preview — pull-based draft decofile. + * + * Studio serves the draft decofile (the merged `.deco/blocks/*.json` at the + * branch head) from its GitHub-backed decofile API + * (`GET /api//decofile//?token=…`); a + * production site pulls it and renders its own real pages against it, as a + * request-scoped SNAPSHOT of the release. This replaces the sandbox daemon + * that used to sync the draft into a folder the site watched: no separate + * process, no `/_sandbox/decofile`, just an HTTP pull. + * + * This module is the framework-agnostic half: token parsing, origin + * validation, fetching, and version caching. Binding a resolved draft to a + * request is done by the runtime (see `runtime/mod.ts`'s `prepareState`, which + * swaps `state.release` for a `fromJSON`-backed provider of the pulled draft). + * + * Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` (or the site-declared 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 may render drafts while the + * production domain, on the same build, must ignore a `?__draft=` entirely. + */ + +/** + * A parsed `?__draft=` token: `@`. + * + * The token carries the AUTHORITY + PATH of the draft content API, never a + * scheme — a full URL would be an SSRF vector, and the scheme is derived from + * the matched domain instead. The path is REQUIRED and typically addresses + * Studio's decofile API (`/api//decofile//?token=…`); + * its query carries the signed draft grant. + */ +export interface DraftPointer { + /** Content-API authority, e.g. `studio.decocms.com` or `localhost:4000`. */ + host: string; + /** Path (+ query) on that authority serving the decofile JSON. */ + path: string; + /** Opaque content version (the branch head sha / server ETag). Immutable → safe cache key. */ + version: string; +} + +/** Lowercase DNS hostname. A single label is allowed — exact-host domain + * entries (`localhost`, `local.studio.decocms.com`) can admit it. */ +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}$/; +/** Rooted path with an optional query; conservative charset, no `@`/`#`/space. */ +const PATH_RE = /^\/[A-Za-z0-9/_.%~=&?-]*$/; + +/** + * Parse `@`. Null on anything unexpected — callers + * fall back to published content. Splits on the LAST `@` (neither the path + * charset nor a signed token may contain one, so a stray `@` fails validation + * rather than being half-read). + */ +export function parseDraftPointer( + raw: string | null | undefined, +): DraftPointer | null { + if (!raw) return null; + const at = raw.lastIndexOf("@"); + if (at <= 0 || at === raw.length - 1) return null; + const authorityAndPath = raw.slice(0, at); + const version = raw.slice(at + 1); + if (!VERSION_RE.test(version)) return null; + + const slash = authorityAndPath.indexOf("/"); + if (slash === -1) return null; + const authority = authorityAndPath.slice(0, slash).toLowerCase(); + const path = authorityAndPath.slice(slash); + if (!PATH_RE.test(path)) return null; + + const [host, port, extra] = authority.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}`, + path, + version, + }; +} + +/** + * 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. + * + * Two entry shapes: a dot-prefixed entry is a suffix match with a guaranteed + * label boundary (`evil-decocms.com` cannot pass `.decocms.com`); a bare entry + * is an exact-host match (needed for `localhost` and dev origins, which no + * suffix can admit). Order matters only for the local/port rule: the first + * matching entry decides whether a port and `http` are allowed. + */ +export const DEFAULT_PREVIEW_API_DOMAINS = [ + "local.studio.decocms.com", // native/web dev origin (http, explicit port) + "localhost", + "127.0.0.1", // loopback dev — `localhost` can resolve to a different (IPv6) server + ".localhost", + ".decocms.com", // hosted Studio (decofile API) + preview daemons +]; + +type EnvLike = Record; + +/** Read a single env var, tolerating a missing `--allow-env` permission. */ +function safeEnvGet(name: string): string | undefined { + try { + return Deno.env.get(name); + } catch { + return undefined; + } +} + +/** + * Resolve the env source: an explicit object (tests / callers) or a lazy view + * over the specific `Deno.env` vars this module reads. Materialising only the + * keys we need avoids requiring blanket env access. + */ +function envOrDeno(env?: EnvLike): EnvLike { + if (env) return env; + return { + DECO_PREVIEW_API_DOMAINS: safeEnvGet("DECO_PREVIEW_API_DOMAINS"), + DECO_ALLOWED_PREVIEW_HOSTS: safeEnvGet("DECO_ALLOWED_PREVIEW_HOSTS"), + }; +} + +function readApiDomains(env: EnvLike): string[] { + const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS; +} + +/** + * 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?: EnvLike, +): string | null { + const [host, port] = authority.toLowerCase().split(":"); + if (!host) return null; + const domain = readApiDomains(envOrDeno(env)).find((d) => + d.startsWith(".") ? host.length > d.length && host.endsWith(d) : host === d + ); + if (!domain) return null; + // Local entries may carry an explicit port; public domains may not (a + // public-domain token must not steer the fetch at odd ports). + const local = domain === "localhost" || + domain === "127.0.0.1" || + domain.endsWith(".localhost") || + domain === "local.studio.decocms.com"; + if (port !== undefined && !local) return null; + // Scheme is derived, never taken from the token. Plain-loopback dev hosts + // are http; local.studio.decocms.com is the native app's TLS dev origin + // (locally-trusted cert), so it — like every public domain — is https. + const insecure = domain === "localhost" || + domain === "127.0.0.1" || + domain.endsWith(".localhost"); + return `${insecure ? "http" : "https"}://${host}${ + port === undefined ? "" : `:${port}` + }`; +} + +/** + * Hosts declared by the site itself (the global `site` block's `previewHosts`), + * installed once at setup time. + * + * MUST be fed from the setup-time base blocks, never from the request-time + * (possibly drafted) release: an allowlist readable through the draft could be + * rewritten by the very draft it gates. + */ +const G = globalThis as { __decoDraftHosts?: string[] }; + +/** Install the site-declared preview hosts. Called at setup by the site app. */ +export function setDraftPreviewHosts(hosts: readonly unknown[]): void { + G.__decoDraftHosts = 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: EnvLike): string[] { + const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); +} + +/** + * Whether `host` (as seen on the request) may render drafts. + * + * Compared 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. + */ +export function isDraftHostAllowed( + host: string | null | undefined, + env?: EnvLike, +): boolean { + if (!host) return false; + return readAllowedHosts(envOrDeno(env)).includes(host.trim().toLowerCase()); +} + +/** + * True when any host is allowed to preview. A cheap read callers use to gate + * BEFORE touching the network, so an unconfigured site is fully inert. The + * per-request host match happens later, in `isDraftHostAllowed`. + */ +export function isDraftPreviewEnabled(env?: EnvLike): boolean { + return readAllowedHosts(envOrDeno(env)).length > 0; +} + +/** + * Draft cache. Bounded on purpose: a decofile is routinely multi-megabyte, so + * an unbounded map would grow with every save until the process died. + * + * Keyed by the FULL source identity (`@`), not the version + * alone: `version` is a branch-head sha in production (globally unique), but the + * type accepts any short label (`v1`, a branch id) and one runtime can be + * pointed at more than one draft source. Keying on host+path+version makes a + * hit correct even when two sources reuse a version label. + */ +const MAX_CACHED_DRAFTS = 3; +const byKey = new Map>(); + +function draftCacheKey(p: DraftPointer): string { + return `${p.host}${p.path}@${p.version}`; +} + +function cacheDraft(key: string, blocks: Record): void { + byKey.delete(key); + byKey.set(key, blocks); + while (byKey.size > MAX_CACHED_DRAFTS) { + const oldest = byKey.keys().next().value; + if (oldest === undefined) break; + byKey.delete(oldest); + } +} + +/** Test seam — drops every cached draft. */ +export function clearDraftCache(): void { + byKey.clear(); +} + +export interface ResolveDraftOptions { + /** Raw `@` token from the request. */ + pointer: string | null | undefined; + /** Defaults to `Deno.env`. */ + env?: EnvLike; + /** Defaults to global `fetch`. Injected in tests. */ + fetchImpl?: typeof fetch; +} + +/** + * Resolve a draft token to a decofile, or null to render published content. + * + * 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, +): Promise | null> { + const env = envOrDeno(options.env); + if (readAllowedHosts(env).length === 0) return null; + + const parsed = parseDraftPointer(options.pointer); + if (!parsed) return null; + + // Origin validation BEFORE the cache: a cached version must never be served + // for a pointer whose authority the configuration would reject. + const origin = previewApiOriginForHost(parsed.host, env); + if (!origin) return null; + + const key = draftCacheKey(parsed); + const cached = byKey.get(key); + if (cached) return cached; + + const doFetch = options.fetchImpl ?? fetch; + // The pointer's version rides along as `v=`, making the fetch URL fully + // content-addressed (path + token + version). Today the server serves it + // no-store either way — token-protected drafts are deliberately NOT + // shared-cacheable. The param still earns its place: version-tagged access + // logs, and it is the prerequisite for edge-validated caching later. + const url = new URL(parsed.path, origin); + url.searchParams.append("v", parsed.version); + let res: Response; + try { + res = await doFetch(url.toString(), { 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(key, blocks); + return blocks; +} + +// --------------------------------------------------------------------------- +// Request-level resolution +// --------------------------------------------------------------------------- + +/** + * Cookie the draft pointer travels in across navigation and secondary requests + * (client-fetched sections hitting `/deco/render` / `/deco/invoke`). Set on + * entry when a `?__draft=` param arrives; read back off the raw Request here. + */ +export const DRAFT_COOKIE_NAME = "__deco_draft"; +/** Query param that enters draft mode; `off` leaves it. */ +export const DRAFT_QUERY_PARAM = "__draft"; + +/** Read one cookie value out of a raw `Cookie:` header. */ +function readCookieValue( + cookieHeader: string | null | undefined, + name: string, +): string | null { + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) { + const raw = part.slice(eq + 1).trim(); + // A malformed `%` sequence makes `decodeURIComponent` throw; a bad cookie + // must degrade to "no draft", never surface as an error to direct + // callers of the exported reader. + try { + return decodeURIComponent(raw); + } catch { + return null; + } + } + } + return null; +} + +/** + * The draft pointer a raw Request is carrying: `?__draft=` wins, the cookie + * carries navigation, `off` exits. + */ +export function draftPointerFromRequest(request: Request): string | null { + const url = new URL(request.url); + const param = url.searchParams.get(DRAFT_QUERY_PARAM); + if (param === "off") return null; + if (param) return param; + return readCookieValue(request.headers.get("cookie"), DRAFT_COOKIE_NAME); +} + +/** The request host to gate on, honouring the standard forwarding header. */ +export function draftHostFromRequest(request: Request): string | null { + return request.headers.get("x-forwarded-host") ?? + request.headers.get("host"); +} + +/** + * Persist the draft pointer into the `__deco_draft` cookie so subsequent + * navigation and client-fetched sections (`/deco/render`, `/deco/invoke`) + * carry the same draft — those are separate HTTP requests and the swap lives + * in per-request state that does not travel across the hop. + * + * `?__draft=off` always clears the cookie (an escape hatch that must work on + * any host). Setting the cookie is gated on the same allowlist as rendering: + * a stray `?__draft=` on a non-preview host leaves nothing behind. + */ +export function applyDraftCookie( + request: Request, + headers: Headers, + env?: EnvLike, +): void { + const url = new URL(request.url); + const param = url.searchParams.get(DRAFT_QUERY_PARAM); + if (param === null) return; // navigation via cookie — nothing to change. + if (param === "off") { + deleteCookie(headers, DRAFT_COOKIE_NAME, { path: "/" }); + return; + } + const e = envOrDeno(env); + if (readAllowedHosts(e).length === 0) return; + if (!isDraftHostAllowed(draftHostFromRequest(request), e)) return; + setCookie(headers, { + name: DRAFT_COOKIE_NAME, + // Encoded so `readCookieValue`'s decode round-trips a pointer whose query + // carries `&`/`=`; keeps the cookie octet-clean regardless of the grant. + value: encodeURIComponent(param), + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: url.protocol === "https:", + }); +} + +export interface ResolveDraftForRequestOptions { + env?: EnvLike; + fetchImpl?: typeof fetch; +} + +/** + * Resolve the draft decofile a raw Request is asking for, or null to fall back + * to the published release. + * + * Applies the full gate — allowlist non-empty, request host allowed, pointer + * valid, origin allowed, fetch OK — so the runtime can swap the release for + * exactly the draft the request is asking for. Null means "render published". + */ +export async function resolveDraftForRequest( + request: Request, + options: ResolveDraftForRequestOptions = {}, +): Promise | null> { + const env = envOrDeno(options.env); + if (readAllowedHosts(env).length === 0) return null; + const pointer = draftPointerFromRequest(request); + if (!pointer) return null; + if (!isDraftHostAllowed(draftHostFromRequest(request), env)) return null; + return resolveDraftDecofile({ pointer, env, fetchImpl: options.fetchImpl }); +} diff --git a/engine/mod.ts b/engine/mod.ts index 218210a01..fe3f065e8 100644 --- a/engine/mod.ts +++ b/engine/mod.ts @@ -3,3 +3,24 @@ export type { ImportMapResolver } from "./importmap/builder.ts"; export type { ParsedSource } from "./schema/deps.ts"; export { initLoader, parsePath } from "./schema/parser.ts"; export { fromEndpoint, fromJSON } from "./decofile/fetcher.ts"; +export type { + DraftPointer, + ResolveDraftForRequestOptions, + ResolveDraftOptions, +} from "./decofile/draft.ts"; +export { + applyDraftCookie, + clearDraftCache, + DEFAULT_PREVIEW_API_DOMAINS, + DRAFT_COOKIE_NAME, + DRAFT_QUERY_PARAM, + draftHostFromRequest, + draftPointerFromRequest, + isDraftHostAllowed, + isDraftPreviewEnabled, + parseDraftPointer, + previewApiOriginForHost, + resolveDraftDecofile, + resolveDraftForRequest, + setDraftPreviewHosts, +} from "./decofile/draft.ts"; diff --git a/runtime/mod.ts b/runtime/mod.ts index 45a5ffda9..2c5ac7284 100644 --- a/runtime/mod.ts +++ b/runtime/mod.ts @@ -11,6 +11,12 @@ import { type DecofileProvider, getProvider, } from "../engine/decofile/provider.ts"; +import { + applyDraftCookie, + DRAFT_QUERY_PARAM, + resolveDraftForRequest, +} from "../engine/decofile/draft.ts"; +import { fromJSON } from "../engine/decofile/fetcher.ts"; import { siteNameFromEnv } from "../engine/manifest/manifest.ts"; import { randomSiteName } from "../engine/manifest/utils.ts"; import { newContext, type PreactComponent, type Resolvable } from "../mod.ts"; @@ -239,7 +245,38 @@ export class Deco { state.global = state; const { resolver } = await this.ctx.runtime!; - const ctxResolver = resolver + // --- Fast Preview: request-scoped draft snapshot --------------------- + // If this request carries a valid `?__draft=` pointer (or the cookie that + // carries it across navigation) from an allowed host, pull the draft + // decofile from Studio's decofile API and render THIS request against it, + // instead of the published release. Replacing the release wholesale gives + // snapshot semantics for free: a block the draft omits is gone, not + // resurrected by the base. Inert (no network, no allocation) on any + // request without a pointer or on a non-preview host. + let activeResolver = resolver; + const draftDecofile = await resolveDraftForRequest(request).catch(() => + null + ); + if (draftDecofile) { + const draftProvider = fromJSON(draftDecofile); + state.release = draftProvider; + activeResolver = resolver.with({ release: draftProvider }); + // A draft shares the published URL — it must never be cached or indexed. + // `shouldCache = false` drives the framework's cache decision to + // `no-store` (see applyPageCacheDecision); noindex keeps a leaked draft + // out of search results. + state.vary.shouldCache = false; + response.headers.set("x-robots-tag", "noindex, nofollow"); + } + // Persist the pointer into a cookie so client-fetched sections + // (`/deco/render`, `/deco/invoke`) carry the same draft; `off` clears it. + // Only relevant when a `?__draft=` param is present — skip the work (and a + // second URL parse) for the vast majority of requests that have none. + if (state.url.searchParams.has(DRAFT_QUERY_PARAM)) { + applyDraftCookie(request, response.headers); + } + + const ctxResolver = activeResolver .resolverFor( { context: new Proxy(context, { @@ -259,7 +296,7 @@ export class Deco { monitoring: state.monitoring, }, ) - .bind(resolver); + .bind(activeResolver); state.resolve = ctxResolver; state.invoke = buildInvokeFunc( From d141fe54c8625a0147347b2c7b59b7c94e77deec Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 12 Aug 2026 19:43:35 +0000 Subject: [PATCH 05/27] Update version to 1.204.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index bea970c8e..8fb4a5968 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.203.0", + "version": "1.204.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 7ff4ccfbe..5e71037b7 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.203.0", + "version": "1.204.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 5f7f1a3b6..85a6c0bd4 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.203.0", + "version": "1.204.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 6ceaa9cbcec292c6f322eee04fa1fda59cc6ee29 Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 13 Aug 2026 14:11:10 -0300 Subject: [PATCH 06/27] feat(preview): draft preview badge (#1223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(preview): draft badge — always-on "you are viewing a draft" signal Follow-up to Fast Preview (#1222). Fast Preview carries the draft pointer in a cookie across navigation, so after the first click the URL no longer shows `?__draft=` — a reviewer can forget they're on unpublished content. This adds a floating badge whenever a draft is bound, with "leave preview" and "copy a link to this exact draft version". - runtime/draftBadge.ts: self-contained HTML+JS snippet (inline styles, no site CSS/island dependency, very high z-index), on-brand (deco lime/green). Renders nothing-first and reveals only once confirmed unframed — hidden inside Studio's own preview iframe, never flashes. Pointer embedded only in the copy-link handler (script-escaped), never as raw HTML. - runtime/mod.ts (prepareState): stash the active pointer in the request bag (server-side; the cookie is HttpOnly) when a draft resolves. - runtime/middleware.ts: inject the badge before on draft-bound HTML responses, alongside the existing cookie-script injection (single body buffer). - runtime/draftBadge.test.ts: builder + injector unit tests (escaping, iframe gate, first- injection). Verified in-browser against demo-linkedin: badge floats bottom-center on a draft render, absent on published traffic. Co-Authored-By: Claude Opus 4.8 * feat(preview): match the Node draft badge exactly (pill + popover, deco mark) Rework the badge to be a faithful vanilla port of @decocms/blocks's React DraftPreviewBadge instead of the ad-hoc pill: - bottom-left "Preview mode" pill with the inlined deco mark (runtime/decoMark.ts, self-contained data URI — no asset pipeline / 404 risk). - click opens a white popover above it with "Exit preview" (arrow icon) and "Share preview" (three-node icon, → "Copied!"), matching colours, paddings, radii and shadows. - outside-click / Escape dismissal; exit → ?__draft=off; share copies ?__draft= (prompt fallback when clipboard is blocked). - English copy, same as Node. Verified in-browser against demo-linkedin: pill bottom-left, popover on click. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- runtime/decoMark.ts | 11 +++ runtime/draftBadge.test.ts | 62 ++++++++++++ runtime/draftBadge.ts | 190 +++++++++++++++++++++++++++++++++++++ runtime/middleware.ts | 41 +++++--- runtime/mod.ts | 8 ++ 5 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 runtime/decoMark.ts create mode 100644 runtime/draftBadge.test.ts create mode 100644 runtime/draftBadge.ts diff --git a/runtime/decoMark.ts b/runtime/decoMark.ts new file mode 100644 index 000000000..8b75dca03 --- /dev/null +++ b/runtime/decoMark.ts @@ -0,0 +1,11 @@ +/** + * Deco brand mark, inlined as a data URI so the draft-preview badge is fully + * self-contained: it renders inside an arbitrary consumer site with no asset + * pipeline, no `` that could 404, and no dependency on the site + * shipping the logo. Only loaded when a draft is active (the badge is inert + * otherwise), so the ~21KB cost never touches ordinary production traffic. + * + * Source: the deco "d↗" logo (pos-d-logo). Swap this constant to rebrand. + */ +export const DECO_MARK_DATA_URI = + "data:image/png;base64,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"; diff --git a/runtime/draftBadge.test.ts b/runtime/draftBadge.test.ts new file mode 100644 index 000000000..99a967500 --- /dev/null +++ b/runtime/draftBadge.test.ts @@ -0,0 +1,62 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { buildDraftBadge, injectBeforeBodyEnd } from "./draftBadge.ts"; + +Deno.test("buildDraftBadge", async (t) => { + const P = "studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc@v1"; + + await t.step("renders the pill, deco mark, and menu controls", () => { + const out = buildDraftBadge(P); + assertStringIncludes(out, 'id="__deco-draft-badge"'); + assertStringIncludes(out, "Preview mode"); // pill label + assertStringIncludes(out, "data:image/png;base64,"); // deco mark + assertStringIncludes(out, "Exit preview"); + assertStringIncludes(out, "Share preview"); + assertStringIncludes(out, "data-exit"); + assertStringIncludes(out, "data-share"); + }); + + await t.step("exit sets ?__draft=off; share pins the pointer", () => { + const out = buildDraftBadge(P); + assertStringIncludes(out, '"__draft","off"'); // exit + assertStringIncludes(out, 'searchParams.set("__draft",p)'); // share + }); + + await t.step("starts hidden and reveals only when unframed", () => { + const out = buildDraftBadge(P); + // display:none in the container; the script flips it and bails inside iframes. + assertStringIncludes(out, "display:none"); + assertStringIncludes(out, "window.top!==window.self"); + }); + + await t.step("embeds the pointer script-escaped, never as raw HTML", () => { + const evil = "a.example/x?token=@v1"; + const out = buildDraftBadge(evil); + // The raw closing tag must not appear — `<` is escaped to \u003c. + assert(!out.includes(" { + await t.step("injects before the first ", () => { + assertEquals( + injectBeforeBodyEnd("hi", "X"), + "hiX", + ); + }); + + await t.step("falls back to then append", () => { + assertEquals( + injectBeforeBodyEnd("hi", "X"), + "hiX", + ); + assertEquals(injectBeforeBodyEnd("plain", "X"), "plainX"); + }); + + await t.step("uses the FIRST (nested content can't shift it)", () => { + assertEquals( + injectBeforeBodyEnd("ab", "X"), + "aXb", + ); + }); +}); diff --git a/runtime/draftBadge.ts b/runtime/draftBadge.ts new file mode 100644 index 000000000..d6b34278b --- /dev/null +++ b/runtime/draftBadge.ts @@ -0,0 +1,190 @@ +/** + * Draft preview badge — the always-on "you are viewing a draft" signal. + * + * Fast Preview carries the draft pointer in a cookie across in-preview + * navigation, so after the first click the URL no longer shows `?__draft=` — a + * reviewer can forget they are looking at unpublished content and mistake it + * for what is live. This badge floats over the page whenever a draft is bound + * and offers the two things a reviewer needs: leave preview, or copy a link + * that hands the exact draft version to someone else. + * + * A faithful vanilla port of `@decocms/blocks`'s React `DraftPreviewBadge`: + * same bottom-left "Preview mode" pill (deco mark + label), same click-to-open + * popover with "Exit preview" / "Share preview", same colours, paddings and + * shadows. Injected as a self-contained HTML+JS snippet (no island, no site + * CSS/Tailwind dependency, inline styles, very high z-index) so it renders the + * same on any consumer site — the same delivery mechanism as the framework + * cookie script (see runtime/clientCookies.ts). Only emitted when a draft is + * active, so it never costs ordinary traffic. + * + * Hidden inside an iframe: Studio's own preview surface embeds this exact draft + * render in an iframe that already has its own chrome, so a second in-frame + * badge would be redundant clutter. Renders nothing-first and reveals via a + * client effect only once confirmed unframed — fails closed, never flashes + * inside Studio's frame. + */ + +import { DECO_MARK_DATA_URI } from "./decoMark.ts"; + +/** + * Bag key carrying the active draft pointer from `prepareState` to the + * middleware. A plain `Symbol` (what `createBagKey` produces) — inlined so this + * client-snippet builder stays free of the heavy `blocks/utils` graph. + */ +export const DRAFT_PREVIEW_KEY: symbol = Symbol("draft-preview"); + +/** Deco brand palette — dark green text on the lime pill, for contrast. */ +const DECO_GREEN = "#0b3d1e"; +const DECO_LIME = "#d0ec1a"; + +/** Escape for safe embedding inside an inline ``; + + return html + script; +}; + +/** + * Inject `snippet` right before the first `` (the badge is body content, + * not head). Falls back to ``, then to append. Uses `indexOf` (first + * occurrence) so embedded/nested HTML cannot shift the injection point. + */ +export const injectBeforeBodyEnd = (html: string, snippet: string): string => { + const idxBody = html.indexOf(""); + if (idxBody >= 0) { + return html.slice(0, idxBody) + snippet + html.slice(idxBody); + } + const idxHtml = html.indexOf(""); + if (idxHtml >= 0) { + return html.slice(0, idxHtml) + snippet + html.slice(idxHtml); + } + return html + snippet; +}; diff --git a/runtime/middleware.ts b/runtime/middleware.ts index 5c7b8ffef..85c01065d 100644 --- a/runtime/middleware.ts +++ b/runtime/middleware.ts @@ -33,6 +33,11 @@ import { injectScriptIntoHtml, stripFrameworkSetCookies, } from "./clientCookies.ts"; +import { + buildDraftBadge, + DRAFT_PREVIEW_KEY, + injectBeforeBodyEnd, +} from "./draftBadge.ts"; import { setLogger } from "./fetch/fetchLog.ts"; import { liveness } from "./middlewares/liveness.ts"; import type { Deco, State } from "./mod.ts"; @@ -524,24 +529,38 @@ export const middlewareFor = ( // stickiness survives CDN-stripped headers. Gated on status=200 + HTML // so we never inject into redirects or error pages (which may carry // reflected input). - const cookieScript = (responseStatus === 200 && isHtmlResponse) + const injectable = responseStatus === 200 && isHtmlResponse; + const cookieScript = injectable ? buildClientCookieScript(newHeaders) : null; + // Draft badge: the always-on "you are viewing a draft" signal, injected + // only when this request is bound to a draft (the pointer is stashed in + // the bag by prepareState). Self-contained HTML+JS, hidden in iframes. + const draftPointer = injectable + ? ctx.var.bag?.get(DRAFT_PREVIEW_KEY) + : undefined; + const draftBadge = typeof draftPointer === "string" + ? buildDraftBadge(draftPointer) + : null; // for some reason hono deletes content-type when response is not fresh. // which means that sometimes it will fail as headers are immutable. // so I'm first setting it to undefined and just then set the entire response again ctx.res = undefined; - if (cookieScript) { - const html = await initialResponse.text(); - // Script captured the framework cookies; remove the now-redundant - // Set-Cookie headers so CDNs under `respect_origin` cache mode treat - // the response as non-personalized and cache cold-visit responses. - // The Deco-Cache-Vary-Cookies hint header (set by applyPageCacheDecision - // above) is preserved so operators still know which cookies belong in - // the custom cache key. - stripFrameworkSetCookies(newHeaders); - ctx.res = new Response(injectScriptIntoHtml(html, cookieScript), { + if (cookieScript || draftBadge) { + let html = await initialResponse.text(); + if (cookieScript) { + // Script captured the framework cookies; remove the now-redundant + // Set-Cookie headers so CDNs under `respect_origin` cache mode treat + // the response as non-personalized and cache cold-visit responses. + // The Deco-Cache-Vary-Cookies hint header (set by + // applyPageCacheDecision above) is preserved so operators still know + // which cookies belong in the custom cache key. + stripFrameworkSetCookies(newHeaders); + html = injectScriptIntoHtml(html, cookieScript); + } + if (draftBadge) html = injectBeforeBodyEnd(html, draftBadge); + ctx.res = new Response(html, { status: responseStatus, headers: newHeaders, }); diff --git a/runtime/mod.ts b/runtime/mod.ts index 2c5ac7284..d3d0b8e15 100644 --- a/runtime/mod.ts +++ b/runtime/mod.ts @@ -14,9 +14,11 @@ import { import { applyDraftCookie, DRAFT_QUERY_PARAM, + draftPointerFromRequest, resolveDraftForRequest, } from "../engine/decofile/draft.ts"; import { fromJSON } from "../engine/decofile/fetcher.ts"; +import { DRAFT_PREVIEW_KEY } from "./draftBadge.ts"; import { siteNameFromEnv } from "../engine/manifest/manifest.ts"; import { randomSiteName } from "../engine/manifest/utils.ts"; import { newContext, type PreactComponent, type Resolvable } from "../mod.ts"; @@ -267,6 +269,12 @@ export class Deco { // out of search results. state.vary.shouldCache = false; response.headers.set("x-robots-tag", "noindex, nofollow"); + // Carry the active pointer to the middleware so it can inject the draft + // badge into the HTML response. Bag, not a header: the pointer stays + // server-side (the cookie is HttpOnly) and never touches the wire except + // inside the badge's own copy-link handler. + const activePointer = draftPointerFromRequest(request); + if (activePointer) state.bag.set(DRAFT_PREVIEW_KEY, activePointer); } // Persist the pointer into a cookie so client-fetched sections // (`/deco/render`, `/deco/invoke`) carry the same draft; `off` clears it. From 4664bfcf3b7f8f193b13c8118516a24e84fe2fa1 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 13 Aug 2026 17:11:22 +0000 Subject: [PATCH 07/27] Update version to 1.204.1 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 8fb4a5968..8936a5ac3 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.204.0", + "version": "1.204.1", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 5e71037b7..5af2de36e 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.204.0", + "version": "1.204.1", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 85a6c0bd4..ebe7954db 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.204.0", + "version": "1.204.1", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 4ecf4b97d9bfe71fae06ae15a91d831d0f00bd0e Mon Sep 17 00:00:00 2001 From: decobot Date: Mon, 17 Aug 2026 12:34:09 +0000 Subject: [PATCH 08/27] Update version to 1.205.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 8936a5ac3..cd9d8198e 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.204.1", + "version": "1.205.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 5af2de36e..b495acfe8 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.204.1", + "version": "1.205.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index ebe7954db..385688b7b 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.204.1", + "version": "1.205.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 659bbdf399555b2faf4b27caaf7361289f2b51f0 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 19 Aug 2026 10:36:07 -0300 Subject: [PATCH 09/27] fix(preview): carry draft pointer through lazy /deco/render (iframe) (#1225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast Preview renders the page shell against the draft, but deferred sections are lazy-loaded client-side via a separate `/deco/render` fetch that resolves its draft from the `__deco_draft` cookie. That cookie is `SameSite=Lax`, so inside Studio's cross-site preview iframe the browser does not attach it to the subrequest — the section renders against the PUBLISHED release while the shell rendered the draft, producing duplicated or stale sections (a removed section reappears). Opening the same URL in a top-level tab works because the cookie is sent in first-party context. `useSection` runs server-side and the runtime already stashes the active pointer in the request bag during `prepareState`. Carry it forward as an explicit top-level `?__draft=` param on the render URL: `draftPointerFromRequest` prefers the param over the cookie, so lazy rendering stays on the draft regardless of frame context. The param is only appended when a draft is actually bound, so ordinary traffic is untouched. Co-authored-by: Claude Opus 4.8 --- hooks/useSection.test.tsx | 65 +++++++++++++++++++++++++++++++++++++++ hooks/useSection.ts | 20 ++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 hooks/useSection.test.tsx diff --git a/hooks/useSection.test.tsx b/hooks/useSection.test.tsx new file mode 100644 index 000000000..0424cb302 --- /dev/null +++ b/hooks/useSection.test.tsx @@ -0,0 +1,65 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource preact */ +import { assertEquals } from "@std/assert"; +import { renderToString } from "preact-render-to-string"; +import { SectionContext } from "../components/section.tsx"; +import { DRAFT_PREVIEW_KEY } from "../runtime/draftBadge.ts"; +import { useSection } from "./useSection.ts"; + +const POINTER = + "studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc@v1"; + +// Minimal SectionContext double — only the fields `useSection` reads. +// deno-lint-ignore no-explicit-any +const makeCtx = (bag: WeakMap): SectionContext => + ({ + revision: "rev-1", + renderSalt: "salt-1", + deploymentId: "dep-1", + resolveChain: [], + request: new Request("https://shop.example.com/"), + context: { + state: { + vary: { build: () => "" }, + pathTemplate: "/", + bag, + }, + }, + }) as unknown as SectionContext; + +// Render a probe that captures the URL `useSection` builds under `ctx`. +// deno-lint-ignore no-explicit-any +const renderUrl = (bag: WeakMap): string => { + let url = ""; + const Probe = () => { + url = useSection({ props: { __resolveType: "site/sections/X.tsx" } }); + return null; + }; + renderToString( + + + , + ); + return url; +}; + +Deno.test("useSection draft pointer propagation", async (t) => { + await t.step( + "carries the active pointer as a top-level ?__draft= when a draft is bound", + () => { + const bag = new WeakMap(); + bag.set(DRAFT_PREVIEW_KEY, POINTER); + const url = renderUrl(bag); + const params = new URL(url, "http://localhost").searchParams; + // Read off the top-level param (what the server actually resolves from), + // not nested inside `href` — that is the whole point of the fix. + assertEquals(params.get("__draft"), POINTER); + }, + ); + + await t.step("omits __draft on ordinary (non-draft) traffic", () => { + const url = renderUrl(new WeakMap()); + const params = new URL(url, "http://localhost").searchParams; + assertEquals(params.get("__draft"), null); + }); +}); diff --git a/hooks/useSection.ts b/hooks/useSection.ts index cfbe5d0f6..f7bb484d9 100644 --- a/hooks/useSection.ts +++ b/hooks/useSection.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "preact"; import { useContext } from "preact/hooks"; import { SectionContext } from "../components/section.tsx"; import { FieldResolver } from "../engine/core/resolver.ts"; +import { DRAFT_QUERY_PARAM } from "../engine/decofile/draft.ts"; +import { DRAFT_PREVIEW_KEY } from "../runtime/draftBadge.ts"; import { Murmurhash3 } from "../deps.ts"; @@ -117,6 +119,20 @@ export const useSection =

( const vary = ctx?.context.state.vary.build(); const { request, renderSalt, context: { state: { pathTemplate } } } = ctx; + // Fast Preview: a deferred section is lazy-loaded client-side via a separate + // `/deco/render` fetch, which resolves its draft from the `__deco_draft` + // cookie. That cookie is `SameSite=Lax`, so inside Studio's cross-site + // preview iframe the browser does NOT attach it to the subrequest — the + // section would render against the PUBLISHED release while the page shell + // rendered the draft, producing duplicated/stale sections. If this render is + // bound to a draft (the runtime stashed the active pointer in the bag during + // prepareState), carry it forward as an explicit `?__draft=` param: the + // server prefers the param over the cookie, so lazy rendering stays on the + // draft regardless of frame context. Absent on ordinary (non-draft) traffic. + const draftPointer = ctx?.context.state.bag?.get(DRAFT_PREVIEW_KEY) as + | string + | undefined; + const hrefParam = href ?? request.url; const stableHref = createStableHref(hrefParam); const cbString = [ @@ -144,5 +160,9 @@ export const useSection =

( ); } + if (draftPointer) { + params.set(DRAFT_QUERY_PARAM, draftPointer); + } + return `/deco/render?${params}`; }; From d56b1b1c979ec7b8f3ac492eff4d7d86a9654db8 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 19 Aug 2026 10:45:19 -0300 Subject: [PATCH 10/27] feat(preview): infer .deco.site as an allowed draft preview host (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(preview): infer .deco.site as an allowed draft preview host The draft-preview allowlist (DECO_ALLOWED_PREVIEW_HOSTS / site-block previewHosts) required per-site opt-in, but DECO_SITE_NAME isn't always in the env. Derive the deco-hosted preview domain from the site name the runtime already resolves (opts.site ?? DECO_SITE_NAME ?? …) and register it via setDecoSiteHost at Deco.init. .deco.site is merged ON TOP of the env/site-block list (never replacing it) so a signed draft grant can preview on deco-operated infra out of the box. The random dev fallback registers nothing, and a custom production domain is never inferred — it stays inert. Co-Authored-By: Claude Opus 4.8 * feat(preview): add kill switch and document threat model for inferred host Address review of the `.deco.site` inference: - `DECO_ALLOWED_PREVIEW_HOSTS=none` is now a kill switch that disables preview entirely (inferred host + site block included), restoring the env var's "stop a bad rollout without a deploy" escape hatch — which the merge-on-top had removed for the inferred host. - Document the post-change threat model: a named site is no longer inert by default, the request host is spoofable, so the signed `?__draft=` grant is the sole remaining gate; host-scoping only bounds blast radius. - Fix the now-stale `isDraftPreviewEnabled` docstring. - Tests: kill switch, and undefined (random dev fallback) registering no host. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- engine/decofile/draft.test.ts | 75 +++++++++++++++++++++++++++++++++++ engine/decofile/draft.ts | 44 ++++++++++++++++++-- engine/mod.ts | 1 + runtime/mod.ts | 8 +++- 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/engine/decofile/draft.test.ts b/engine/decofile/draft.test.ts index b6277ef64..7f098f2d3 100644 --- a/engine/decofile/draft.test.ts +++ b/engine/decofile/draft.test.ts @@ -10,6 +10,7 @@ import { previewApiOriginForHost, resolveDraftDecofile, resolveDraftForRequest, + setDecoSiteHost, setDraftPreviewHosts, } from "./draft.ts"; @@ -352,6 +353,80 @@ Deno.test("site-block preview hosts", async (t) => { }); }); +Deno.test("deco-hosted preview domain (setDecoSiteHost)", async (t) => { + await t.step("infers .deco.site and enables the feature", () => { + setDecoSiteHost("als-storefront"); + try { + assertEquals(isDraftPreviewEnabled({}), true); + assertEquals(isDraftHostAllowed("als-storefront.deco.site", {}), true); + assertEquals(isDraftHostAllowed("other.deco.site", {}), false); + // A custom production domain is never inferred. + assertEquals(isDraftHostAllowed("www.als-storefront.com", {}), false); + } finally { + setDecoSiteHost(null); + } + }); + + await t.step("is merged ON TOP of the site block, not replacing it", () => { + setDraftPreviewHosts(["fila.vtex.app"]); + setDecoSiteHost("als-storefront"); + try { + assertEquals(isDraftHostAllowed("fila.vtex.app", {}), true); + assertEquals(isDraftHostAllowed("als-storefront.deco.site", {}), true); + } finally { + setDraftPreviewHosts([]); + setDecoSiteHost(null); + } + }); + + await t.step("is merged ON TOP of the env escape hatch too", () => { + setDecoSiteHost("als-storefront"); + try { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" }; + assertEquals(isDraftHostAllowed("other.example", env), true); + assertEquals(isDraftHostAllowed("als-storefront.deco.site", env), true); + } finally { + setDecoSiteHost(null); + } + }); + + await t.step("a blank/null site name registers no host", () => { + setDecoSiteHost(" "); + try { + assertEquals(isDraftPreviewEnabled({}), false); + } finally { + setDecoSiteHost(null); + } + }); + + await t.step("undefined (the random dev fallback) registers no host", () => { + // runtime/mod.ts passes `resolvedSite` (undefined when the site name falls + // back to randomSiteName) straight through — an unnamed site is not armed. + setDecoSiteHost(undefined); + try { + assertEquals(isDraftPreviewEnabled({}), false); + } finally { + setDecoSiteHost(null); + } + }); + + await t.step("DECO_ALLOWED_PREVIEW_HOSTS=none kills the inferred host", () => { + setDraftPreviewHosts(["fila.vtex.app"]); + setDecoSiteHost("als-storefront"); + try { + // The kill switch wins over the inferred host AND the site block, so a + // bad rollout can be stopped without a deploy. + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" }; + assertEquals(isDraftPreviewEnabled(env), false); + assertEquals(isDraftHostAllowed("als-storefront.deco.site", env), false); + assertEquals(isDraftHostAllowed("fila.vtex.app", env), false); + } finally { + setDraftPreviewHosts([]); + setDecoSiteHost(null); + } + }); +}); + Deno.test("draftPointerFromRequest", async (t) => { await t.step("reads the __deco_draft cookie", () => { const req = new Request( diff --git a/engine/decofile/draft.ts b/engine/decofile/draft.ts index c078cd0e3..207c6806e 100644 --- a/engine/decofile/draft.ts +++ b/engine/decofile/draft.ts @@ -182,7 +182,7 @@ export function previewApiOriginForHost( * (possibly drafted) release: an allowlist readable through the draft could be * rewritten by the very draft it gates. */ -const G = globalThis as { __decoDraftHosts?: string[] }; +const G = globalThis as { __decoDraftHosts?: string[]; __decoSiteHost?: string }; /** Install the site-declared preview hosts. Called at setup by the site app. */ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { @@ -192,6 +192,27 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { .filter(Boolean); } +/** + * Register the deco-hosted preview domain, derived from the resolved site name + * (`opts.site ?? DECO_SITE_NAME ?? …`, resolved by the runtime at setup). + * + * MERGED with the site-block/env list rather than replacing it: `.deco.site` + * is deco-operated infra, so a signed draft grant can preview there out of the + * box, while a custom production domain — never inferred here — stays inert. + * Fed from the trusted setup-time site name, never from the request or a draft. + * + * Threat model, now that a named site is no longer inert by default: the + * request host is spoofable on a direct-to-origin request (the edge is trusted + * to set `x-forwarded-host`), so for a named site the SIGNED `?__draft=` grant + * is the sole remaining gate — host-scoping only bounds blast radius. Set + * `DECO_ALLOWED_PREVIEW_HOSTS=none` to kill preview entirely, including this + * inferred host, without a deploy. + */ +export function setDecoSiteHost(site: string | null | undefined): void { + const s = (site ?? "").trim().toLowerCase(); + G.__decoSiteHost = s ? `${s}.deco.site` : undefined; +} + /** * Hosts allowed to render drafts. * @@ -199,13 +220,26 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { * 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. + * + * The deco-hosted preview domain (`.deco.site`, via `setDecoSiteHost`) + * is always ADDED on top, so a signed draft grant can preview on deco-operated + * infra without any per-site config. + * + * The sentinel `DECO_ALLOWED_PREVIEW_HOSTS=none` is a KILL SWITCH: it disables + * preview entirely — including the inferred host and the site block — so a bad + * rollout can be stopped without a deploy. It must win over every other source. */ function readAllowedHosts(env: EnvLike): string[] { const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); - return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); + if (fromEnv.includes("none")) return []; + const configured = fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); + const siteHost = G.__decoSiteHost; + return siteHost && !configured.includes(siteHost) + ? [...configured, siteHost] + : configured; } /** @@ -226,8 +260,10 @@ export function isDraftHostAllowed( /** * True when any host is allowed to preview. A cheap read callers use to gate - * BEFORE touching the network, so an unconfigured site is fully inert. The - * per-request host match happens later, in `isDraftHostAllowed`. + * BEFORE touching the network. A site with no config but a resolved name is now + * enabled here (its inferred `.deco.site` host); `DECO_ALLOWED_PREVIEW_HOSTS=none` + * forces it back to fully inert. The per-request host match happens later, in + * `isDraftHostAllowed`. */ export function isDraftPreviewEnabled(env?: EnvLike): boolean { return readAllowedHosts(envOrDeno(env)).length > 0; diff --git a/engine/mod.ts b/engine/mod.ts index fe3f065e8..8caf73849 100644 --- a/engine/mod.ts +++ b/engine/mod.ts @@ -22,5 +22,6 @@ export { previewApiOriginForHost, resolveDraftDecofile, resolveDraftForRequest, + setDecoSiteHost, setDraftPreviewHosts, } from "./decofile/draft.ts"; diff --git a/runtime/mod.ts b/runtime/mod.ts index d3d0b8e15..b25f568bf 100644 --- a/runtime/mod.ts +++ b/runtime/mod.ts @@ -16,6 +16,7 @@ import { DRAFT_QUERY_PARAM, draftPointerFromRequest, resolveDraftForRequest, + setDecoSiteHost, } from "../engine/decofile/draft.ts"; import { fromJSON } from "../engine/decofile/fetcher.ts"; import { DRAFT_PREVIEW_KEY } from "./draftBadge.ts"; @@ -86,7 +87,12 @@ export class Deco { static async init( opts?: DecoOptions, ): Promise> { - const site = opts?.site ?? siteNameFromEnv() ?? randomSiteName(); + const resolvedSite = opts?.site ?? siteNameFromEnv(); + const site = resolvedSite ?? randomSiteName(); + // Allow drafts to preview on the deco-hosted `.deco.site` domain out + // of the box. Skipped for the random dev fallback (an unnamed site is not a + // preview host); a custom production domain is never inferred. + setDecoSiteHost(resolvedSite); const decofile = opts?.decofile ?? (await getProvider()); const manifest = opts?.manifest ?? (await import(toFileUrl(join(Deno.cwd(), "manifest.gen.ts")).href).then( From 8d45119f1ead31f2957b380d4fd1e4d5ff0ef37b Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 19 Aug 2026 13:45:33 +0000 Subject: [PATCH 11/27] Update version to 1.206.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index cd9d8198e..ab4988d30 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.205.0", + "version": "1.206.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index b495acfe8..f9eee6919 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.205.0", + "version": "1.206.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 385688b7b..408305115 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.205.0", + "version": "1.206.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 8c61d132c4b9199b9b5a5f46060f3f53da1a9b6b Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 19 Aug 2026 19:12:51 -0300 Subject: [PATCH 12/27] fix(device): prioritize deviceHint query param over cf-device-type (#1227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `deviceHint` query param is an explicit override, only ever present when a tool (e.g. the Studio preview device toggle) forces a device. Real client traffic never carries it, yet `deviceOf` evaluated `cf-device-type` first — so behind Cloudflare the requesting browser's real device (desktop) always won and the forced `deviceHint=mobile` was silently ignored, both in sandbox preview and Fast Preview. Move `deviceHint` to the top of the resolution chain so the override is honored when present; production traffic (no param) still falls through to cf-device-type / user-agent unchanged. Adds utils/userAgent.test.ts covering the priority order. --- utils/userAgent.test.ts | 60 +++++++++++++++++++++++++++++++++++++++++ utils/userAgent.ts | 15 +++++++---- 2 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 utils/userAgent.test.ts diff --git a/utils/userAgent.test.ts b/utils/userAgent.test.ts new file mode 100644 index 000000000..b478053d9 --- /dev/null +++ b/utils/userAgent.test.ts @@ -0,0 +1,60 @@ +import { assertEquals } from "@std/assert"; +import { deviceOf } from "./userAgent.ts"; + +const MOBILE_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; +const DESKTOP_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +const req = ({ + deviceHint, + cf, + ua, +}: { + deviceHint?: string; + cf?: string; + ua?: string; +}) => { + const url = deviceHint + ? `https://example.com/?deviceHint=${deviceHint}` + : "https://example.com/"; + const headers = new Headers(); + if (cf) headers.set("cf-device-type", cf); + if (ua) headers.set("user-agent", ua); + return new Request(url, { headers }); +}; + +Deno.test("deviceOf", async (t) => { + await t.step("deviceHint overrides cf-device-type (Cloudflare)", () => { + // The Studio preview forces `deviceHint=mobile` while the requesting + // browser is desktop; behind Cloudflare `cf-device-type` used to win. + assertEquals( + deviceOf(req({ deviceHint: "mobile", cf: "desktop", ua: DESKTOP_UA })), + "mobile", + ); + }); + + await t.step("deviceHint overrides the user-agent device", () => { + assertEquals( + deviceOf(req({ deviceHint: "tablet", ua: MOBILE_UA })), + "tablet", + ); + }); + + await t.step("falls back to cf-device-type when no deviceHint", () => { + assertEquals(deviceOf(req({ cf: "mobile", ua: DESKTOP_UA })), "mobile"); + }); + + await t.step("falls back to user-agent when no deviceHint nor cf", () => { + assertEquals(deviceOf(req({ ua: MOBILE_UA })), "mobile"); + }); + + await t.step("defaults to desktop", () => { + assertEquals(deviceOf(req({ ua: DESKTOP_UA })), "desktop"); + assertEquals(deviceOf(req({})), "desktop"); + }); + + await t.step("normalizes unsupported device types to desktop", () => { + assertEquals(deviceOf(req({ deviceHint: "smarttv" })), "desktop"); + }); +}); diff --git a/utils/userAgent.ts b/utils/userAgent.ts index aa4e67f36..7e2e09836 100644 --- a/utils/userAgent.ts +++ b/utils/userAgent.ts @@ -19,12 +19,17 @@ export const deviceOf = (request: Request) => { const url = new URL(request.url); const ua: string | null = request.headers.get("user-agent") || ""; // use cf hint at first and then fallback to user-agent parser. - const cfDeviceHint: string | null = request.headers.get("cf-device-type") || - ""; - - const device = cfDeviceHint || - (ua && new UAParser(ua).getDevice().type) || + const cfDeviceHint: string | null = + request.headers.get("cf-device-type") || ""; + + // `deviceHint` is an explicit override, only ever present when a tool (e.g. + // the Studio preview) forces a device. Real client traffic never carries it, + // so it takes priority: otherwise `cf-device-type` (the requesting browser's + // real device) wins behind Cloudflare and the override is silently ignored. + const device = url.searchParams.get("deviceHint") || + cfDeviceHint || + (ua && new UAParser(ua).getDevice().type) || "desktop"; // console, mobile, tablet, smarttv, wearable, embedded const normalizedDevice = ideviceToDevice[device] ?? "desktop"; From 48a432457530dbf9eb01f98ac5812c81dae39c73 Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 19 Aug 2026 22:13:07 +0000 Subject: [PATCH 13/27] Update version to 1.206.1 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index ab4988d30..a7b16184c 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.206.0", + "version": "1.206.1", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index f9eee6919..ccd9e77df 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.206.0", + "version": "1.206.1", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 408305115..bcd91a620 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.206.0", + "version": "1.206.1", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 2bf0281172acb7a1b67fe528dd8a1cf80096e449 Mon Sep 17 00:00:00 2001 From: guitavano Date: Thu, 20 Aug 2026 09:07:48 -0300 Subject: [PATCH 14/27] feat(preview): infer envs---.decocdn.com as an allowed draft preview host (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the deco-hosted preview inference (.deco.site, #1224) to the per-deploy preview CDN. Unlike the stable .deco.site domain, the deploy URL carries a fresh label every deploy (envs-als-storefront--4l18ts.decocdn.com), so it can't be a fixed allowlist entry — it's matched as a PATTERN instead. - setDecoSiteHost now stores the raw site name and derives both deco-operated hosts from it: .deco.site (exact) and envs---.decocdn.com (pattern). - previewConfig centralises the exact-host list, the deploy-preview prefix, and the kill switch in one read. - matchesDeployPreview constrains to a SINGLE DNS label (no dots), so nothing under an attacker-controlled *.decocdn.com subdomain (envs---x.evil.decocdn.com) can widen the match. Merged ON TOP of the env/site-block list, killed by DECO_ALLOWED_PREVIEW_HOSTS=none, and only inferred for a named site — same guarantees as the .deco.site host. Co-authored-by: Claude Opus 4.8 --- engine/decofile/draft.test.ts | 62 ++++++++++++++++++++ engine/decofile/draft.ts | 107 ++++++++++++++++++++++++---------- 2 files changed, 138 insertions(+), 31 deletions(-) diff --git a/engine/decofile/draft.test.ts b/engine/decofile/draft.test.ts index 7f098f2d3..ca5a9d086 100644 --- a/engine/decofile/draft.test.ts +++ b/engine/decofile/draft.test.ts @@ -367,6 +367,68 @@ Deno.test("deco-hosted preview domain (setDecoSiteHost)", async (t) => { } }); + await t.step("infers the per-deploy envs---.decocdn.com host", () => { + setDecoSiteHost("als-storefront"); + try { + assertEquals(isDraftPreviewEnabled({}), true); + // The label changes every deploy — matched as a pattern. + assertEquals( + isDraftHostAllowed("envs-als-storefront--4l18ts.decocdn.com", {}), + true, + ); + assertEquals( + isDraftHostAllowed("envs-als-storefront--abc123.decocdn.com", {}), + true, + ); + // Wrong site prefix. + assertEquals( + isDraftHostAllowed("envs-other-site--4l18ts.decocdn.com", {}), + false, + ); + // Missing the `envs-` prefix / the `--` separator. + assertEquals( + isDraftHostAllowed("als-storefront--4l18ts.decocdn.com", {}), + false, + ); + assertEquals( + isDraftHostAllowed("envs-als-storefront-4l18ts.decocdn.com", {}), + false, + ); + // Empty hash. + assertEquals( + isDraftHostAllowed("envs-als-storefront--.decocdn.com", {}), + false, + ); + // The hash must be a single label — no sneaking a nested subdomain under + // an attacker-controlled *.decocdn.com. + assertEquals( + isDraftHostAllowed("envs-als-storefront--x.evil.decocdn.com", {}), + false, + ); + // Wrong apex. + assertEquals( + isDraftHostAllowed("envs-als-storefront--4l18ts.example.com", {}), + false, + ); + } finally { + setDecoSiteHost(null); + } + }); + + await t.step("the kill switch also disables the per-deploy preview host", () => { + setDecoSiteHost("als-storefront"); + try { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" }; + assertEquals(isDraftPreviewEnabled(env), false); + assertEquals( + isDraftHostAllowed("envs-als-storefront--4l18ts.decocdn.com", env), + false, + ); + } finally { + setDecoSiteHost(null); + } + }); + await t.step("is merged ON TOP of the site block, not replacing it", () => { setDraftPreviewHosts(["fila.vtex.app"]); setDecoSiteHost("als-storefront"); diff --git a/engine/decofile/draft.ts b/engine/decofile/draft.ts index 207c6806e..7c781664f 100644 --- a/engine/decofile/draft.ts +++ b/engine/decofile/draft.ts @@ -182,7 +182,7 @@ export function previewApiOriginForHost( * (possibly drafted) release: an allowlist readable through the draft could be * rewritten by the very draft it gates. */ -const G = globalThis as { __decoDraftHosts?: string[]; __decoSiteHost?: string }; +const G = globalThis as { __decoDraftHosts?: string[]; __decoSite?: string }; /** Install the site-declared preview hosts. Called at setup by the site app. */ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { @@ -193,11 +193,16 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { } /** - * Register the deco-hosted preview domain, derived from the resolved site name - * (`opts.site ?? DECO_SITE_NAME ?? …`, resolved by the runtime at setup). + * Register the resolved site name (`opts.site ?? DECO_SITE_NAME ?? …`, resolved + * by the runtime at setup). deco-operated preview domains are derived from it: * - * MERGED with the site-block/env list rather than replacing it: `.deco.site` - * is deco-operated infra, so a signed draft grant can preview there out of the + * - `.deco.site` — the stable deco-hosted domain (exact host). + * - `envs---.decocdn.com` — the per-deploy preview URL, whose + * `` label changes every deploy (so it is matched as a PATTERN, never + * a fixed allowlist entry). + * + * Both are MERGED with the site-block/env list rather than replacing it: they + * are deco-operated infra, so a signed draft grant can preview there out of the * box, while a custom production domain — never inferred here — stays inert. * Fed from the trusted setup-time site name, never from the request or a draft. * @@ -205,68 +210,108 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { * request host is spoofable on a direct-to-origin request (the edge is trusted * to set `x-forwarded-host`), so for a named site the SIGNED `?__draft=` grant * is the sole remaining gate — host-scoping only bounds blast radius. Set - * `DECO_ALLOWED_PREVIEW_HOSTS=none` to kill preview entirely, including this - * inferred host, without a deploy. + * `DECO_ALLOWED_PREVIEW_HOSTS=none` to kill preview entirely, including these + * inferred hosts, without a deploy. */ export function setDecoSiteHost(site: string | null | undefined): void { const s = (site ?? "").trim().toLowerCase(); - G.__decoSiteHost = s ? `${s}.deco.site` : undefined; + G.__decoSite = s || undefined; } +/** Suffix of deco's per-deploy preview CDN (`envs---.decocdn.com`). */ +const DEPLOY_PREVIEW_SUFFIX = ".decocdn.com"; +/** A single DNS label — the per-deploy ``, which carries no dots. */ +const DEPLOY_HASH_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + /** - * Hosts allowed to render drafts. + * The resolved preview gate: the exact-match host list, the per-deploy preview + * pattern, and the kill switch — all in one read so callers see a consistent + * view. * - * 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. + * The site block is the expected source of `hosts` — 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. * - * The deco-hosted preview domain (`.deco.site`, via `setDecoSiteHost`) - * is always ADDED on top, so a signed draft grant can preview on deco-operated + * The deco-hosted domains inferred from the site name (`.deco.site` as an + * exact host, `envs---.decocdn.com` as `deployPreviewPrefix`) are + * always ADDED on top, so a signed draft grant can preview on deco-operated * infra without any per-site config. * * The sentinel `DECO_ALLOWED_PREVIEW_HOSTS=none` is a KILL SWITCH: it disables - * preview entirely — including the inferred host and the site block — so a bad + * preview entirely — the inferred hosts and the site block included — so a bad * rollout can be stopped without a deploy. It must win over every other source. */ -function readAllowedHosts(env: EnvLike): string[] { +function previewConfig(env: EnvLike): { + hosts: string[]; + deployPreviewPrefix: string | null; +} { const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); - if (fromEnv.includes("none")) return []; + if (fromEnv.includes("none")) return { hosts: [], deployPreviewPrefix: null }; const configured = fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); - const siteHost = G.__decoSiteHost; - return siteHost && !configured.includes(siteHost) - ? [...configured, siteHost] - : configured; + const site = G.__decoSite; + if (!site) return { hosts: configured, deployPreviewPrefix: null }; + const decoSite = `${site}.deco.site`; + return { + hosts: configured.includes(decoSite) ? configured : [...configured, decoSite], + deployPreviewPrefix: `envs-${site}--`, + }; +} + +/** + * Whether `host` matches the per-deploy preview URL + * `envs---.decocdn.com`. The `` is a fresh label every deploy, + * so it can't be a fixed allowlist entry — but it is constrained to a SINGLE DNS + * label (no dots), so nothing under an attacker-controlled `*.decocdn.com` + * subdomain (`envs---x.evil.decocdn.com`) can widen the match. + */ +function matchesDeployPreview(host: string, prefix: string | null): boolean { + if (!prefix) return false; + if (!host.startsWith(prefix) || !host.endsWith(DEPLOY_PREVIEW_SUFFIX)) { + return false; + } + const hash = host.slice(prefix.length, -DEPLOY_PREVIEW_SUFFIX.length); + return DEPLOY_HASH_RE.test(hash); +} + +/** Hosts allowed to render drafts by an exact match. */ +function readAllowedHosts(env: EnvLike): string[] { + return previewConfig(env).hosts; } /** * Whether `host` (as seen on the request) may render drafts. * - * Compared 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. + * Exact-matched against the allowlist (port included — local dev is + * `localhost:3100`, not `localhost`), then against the per-deploy preview + * pattern. 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. */ export function isDraftHostAllowed( host: string | null | undefined, env?: EnvLike, ): boolean { if (!host) return false; - return readAllowedHosts(envOrDeno(env)).includes(host.trim().toLowerCase()); + const h = host.trim().toLowerCase(); + const cfg = previewConfig(envOrDeno(env)); + return cfg.hosts.includes(h) || + matchesDeployPreview(h, cfg.deployPreviewPrefix); } /** * True when any host is allowed to preview. A cheap read callers use to gate * BEFORE touching the network. A site with no config but a resolved name is now - * enabled here (its inferred `.deco.site` host); `DECO_ALLOWED_PREVIEW_HOSTS=none` - * forces it back to fully inert. The per-request host match happens later, in - * `isDraftHostAllowed`. + * enabled here (its inferred `.deco.site` / `envs---*.decocdn.com` + * hosts); `DECO_ALLOWED_PREVIEW_HOSTS=none` forces it back to fully inert. The + * per-request host match happens later, in `isDraftHostAllowed`. */ export function isDraftPreviewEnabled(env?: EnvLike): boolean { - return readAllowedHosts(envOrDeno(env)).length > 0; + const cfg = previewConfig(envOrDeno(env)); + return cfg.hosts.length > 0 || cfg.deployPreviewPrefix !== null; } /** From f5a30cf7d910f94de7b8941d6a80be8d447e4183 Mon Sep 17 00:00:00 2001 From: decobot Date: Thu, 20 Aug 2026 12:07:59 +0000 Subject: [PATCH 15/27] Update version to 1.207.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index a7b16184c..396920ffe 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.206.1", + "version": "1.207.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index ccd9e77df..196d3263e 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.206.1", + "version": "1.207.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index bcd91a620..bd8a4553c 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.206.1", + "version": "1.207.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 8731e6b16ee4db0f24c7cce00ed53c0472064768 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 21 Aug 2026 11:31:36 -0300 Subject: [PATCH 16/27] fix(engine): don't let an aborted read poison the shared resolvable memo (#1230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolvables (named decofile blocks) are memoized per request by key in `context.memo`. The cached value is the *promise* returned by the first consumer to resolve that key, and every other reference to the block awaits that same promise. `website/sections/Rendering/Lazy.tsx` resolves its inner section under an already-aborted `AbortController` to render a loading fallback fast. If a section resolved this way is a named block that is also referenced elsewhere on the page, the aborted resolution's rejected promise gets cached and served to those other references — they all fail with `AbortError` and the surrounding render collapses to empty. This surfaces on multivariate pages (`website/flags/multivariate.ts`): adding a second page variant is enough to change resolution ordering/concurrency so the aborted lazy read wins the memo race, and the page renders blank ~80% of the time (flaky, since it's a race). The abort belongs to one consumer, not to the block, so it must not be cached. Fix: when a memoized resolvable rejects with an abort, evict the cache entry so later reads re-resolve; and if a consumer whose own signal is still live inherited a co-scheduled aborted promise, re-resolve fresh under that live caller. Consumers that are themselves aborted still reject, as before. Adds sequential and concurrent regression tests. Co-authored-by: Claude Opus 4.8 --- engine/core/mod.test.ts | 78 +++++++++++++++++++++++++++++++++++++++++ engine/core/resolver.ts | 41 ++++++++++++++++++++-- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/engine/core/mod.test.ts b/engine/core/mod.test.ts index b58280c0e..2b07cd421 100644 --- a/engine/core/mod.test.ts +++ b/engine/core/mod.test.ts @@ -7,6 +7,7 @@ import { type ResolverMap, } from "../../engine/core/resolver.ts"; import defaults from "../manifest/fresh.ts"; +import { RequestContext } from "../../deco.ts"; Deno.test("resolve", async (t) => { const context: BaseContext = { @@ -342,3 +343,80 @@ Deno.test("resolve", async (t) => { assertEquals(result, { foo: "hello", bar: { value: 10 } }); }); }); + +Deno.test("aborted reads must not poison the shared memo", async (t) => { + // A loader-like resolver that fails when the active RequestContext signal is + // aborted (mirrors `blocks/loader.ts` calling `signal.throwIfAborted()`) and + // otherwise returns its resolved props unchanged. + const flakyLoader = (props: unknown): unknown => { + RequestContext?.signal?.throwIfAborted(); + return props; + }; + + const makeContext = (): BaseContext => ({ + revision: "", + resolveChain: [], + resolveId: "1", + resolverId: "unknown", + // A shared *named* block (top-level decofile key): resolution is memoized + // by key in `context.memo`, so every reference shares one promise. + resolvables: { + SharedBlock: { __resolveType: "flakyLoader", title: "hello" }, + }, + resolvers: { ...defaults, flakyLoader } as unknown as ResolverMap, + resolveHints: {}, + memo: {}, + runOnce: (_key, f) => f(), + resolve: (data: unknown) => data as T, + }); + + const tryResolve = async (ctx: BaseContext) => { + try { + return await resolve<{ title: string }>("SharedBlock", ctx); + } catch (e) { + return `threw:${(e as Error).name}`; + } + }; + + await t.step( + "sequential: a live consumer re-resolves after an aborted one", + async () => { + const ctx = makeContext(); + const aborted = new AbortController(); + aborted.abort(); + + // Consumer A resolves the shared block under an already-aborted signal + // (as `website/sections/Rendering/Lazy.tsx` does to render a fallback). + const a = await RequestContext.bind( + { signal: aborted.signal }, + () => tryResolve(ctx), + )(); + // Consumer B (the real render) has a live signal and must get the value. + const b = await tryResolve(ctx); + + assertEquals(a, "threw:AbortError"); + assertEquals(b, { title: "hello" }); + }, + ); + + await t.step( + "concurrent: a live consumer does not inherit a co-scheduled abort", + async () => { + const ctx = makeContext(); + const aborted = new AbortController(); + aborted.abort(); + + // A (aborted) and B (live) await the same in-flight memo promise. + const [a, b] = await Promise.all([ + RequestContext.bind( + { signal: aborted.signal }, + () => tryResolve(ctx), + )(), + tryResolve(ctx), + ]); + + assertEquals(a, "threw:AbortError"); + assertEquals(b, { title: "hello" }); + }, + ); +}); diff --git a/engine/core/resolver.ts b/engine/core/resolver.ts index c935eb546..832536b02 100644 --- a/engine/core/resolver.ts +++ b/engine/core/resolver.ts @@ -1,5 +1,6 @@ // deno-lint-ignore-file no-explicit-any import type { Context, Span, Tracer } from "../../deps.ts"; +import { RequestContext } from "../../deco.ts"; import { identity } from "../../utils/object.ts"; import type { createServerTimings } from "../../utils/timings.ts"; import { type HintNode, type ResolveHints, traverseAny } from "./hints.ts"; @@ -516,6 +517,17 @@ const invokeResolverWithProps = async < } }; +/** + * True when `err` is an abort caused by an aborted `AbortSignal` + * (`signal.throwIfAborted()` / `AbortController.abort()`), whose default reason + * is a `DOMException` named `"AbortError"`. Custom reasons that preserve that + * name match too. + */ +const isAbortError = (err: unknown): boolean => + (err instanceof DOMException && err.name === "AbortError") || + (typeof err === "object" && err !== null && "name" in err && + (err as { name?: unknown }).name === "AbortError"); + /** * Receives the props resolved and a type that should be called with the given props. * This type can be a resolvable which currently just ignore the resolved props and returns the given resolved resolvable. @@ -533,11 +545,36 @@ const resolveWithType = < const { resolvers: resolverMap, resolvables } = context; if (resolveType in resolvables) { - return context.memo[resolveType] ??= resolveResolvable( + // Resolve the shared (memoized) resolvable, evicting the cache entry when + // the resolution fails because of an aborted RequestContext signal. That + // abort belongs to whichever consumer happened to trigger the shared + // resolution first (e.g. `website/sections/Rendering/Lazy.tsx` deliberately + // aborts to render a loading fallback) — it is NOT a property of the block, + // so it must not be cached and served to the block's other references. + const resolveShared = (): Promise< + T + > => (context.memo[resolveType] ??= resolveResolvable( resolveType, context, opts, - ); + ).catch((err: unknown) => { + if (isAbortError(err) && context.memo[resolveType] !== undefined) { + delete context.memo[resolveType]; + } + throw err; + })); + + return resolveShared().catch((err: unknown) => { + // A consumer whose own signal is still live must not inherit an abort it + // did not cause — this happens when it awaited the same in-flight promise + // as an aborted consumer. The entry was evicted above, so re-resolve it + // fresh under this (live) caller. Without this, a multivariate page whose + // real render is co-scheduled with an aborted lazy read renders blank. + if (isAbortError(err) && RequestContext.signal?.aborted !== true) { + return resolveShared(); + } + throw err; + }); } else if (resolveType in resolverMap) { const resolver = resolverMap[resolveType]; const proceed = () => From 79d4f73e9ccf3ee2543ae7705993425c8b28dd1b Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 21 Aug 2026 14:31:45 +0000 Subject: [PATCH 17/27] Update version to 1.208.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 396920ffe..c9bb9f8e4 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.207.0", + "version": "1.208.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 196d3263e..bc794e5d7 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.207.0", + "version": "1.208.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index bd8a4553c..a40cae53a 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.207.0", + "version": "1.208.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 7e1f241dc70788c33c88db08ab61b24880526f2e Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 21 Aug 2026 12:01:20 -0300 Subject: [PATCH 18/27] fix(engine): bypass shared memo for aborted callers (don't poison siblings) (#1231) --- engine/core/mod.test.ts | 20 ++++++++++++++++++++ engine/core/resolver.ts | 38 +++++++++++++++++--------------------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/engine/core/mod.test.ts b/engine/core/mod.test.ts index 2b07cd421..d48422ef9 100644 --- a/engine/core/mod.test.ts +++ b/engine/core/mod.test.ts @@ -419,4 +419,24 @@ Deno.test("aborted reads must not poison the shared memo", async (t) => { assertEquals(b, { title: "hello" }); }, ); + + await t.step( + "an aborted caller never writes the shared memo", + async () => { + const ctx = makeContext(); + const aborted = new AbortController(); + aborted.abort(); + + const a = await RequestContext.bind( + { signal: aborted.signal }, + () => tryResolve(ctx), + )(); + + assertEquals(a, "threw:AbortError"); + // The doomed aborted resolution must not have populated the cache. + assertEquals(ctx.memo["SharedBlock"], undefined); + // ...so the real render still resolves it for real. + assertEquals(await tryResolve(ctx), { title: "hello" }); + }, + ); }); diff --git a/engine/core/resolver.ts b/engine/core/resolver.ts index 832536b02..6569a80d4 100644 --- a/engine/core/resolver.ts +++ b/engine/core/resolver.ts @@ -545,15 +545,23 @@ const resolveWithType = < const { resolvers: resolverMap, resolvables } = context; if (resolveType in resolvables) { - // Resolve the shared (memoized) resolvable, evicting the cache entry when - // the resolution fails because of an aborted RequestContext signal. That - // abort belongs to whichever consumer happened to trigger the shared - // resolution first (e.g. `website/sections/Rendering/Lazy.tsx` deliberately - // aborts to render a loading fallback) — it is NOT a property of the block, - // so it must not be cached and served to the block's other references. - const resolveShared = (): Promise< - T - > => (context.memo[resolveType] ??= resolveResolvable( + // A caller resolving under an already-aborted RequestContext signal (e.g. + // `website/sections/Rendering/Lazy.tsx`, which deliberately aborts to + // render a loading fallback fast) must NOT touch the shared memo: its + // resolution is doomed to reject, and caching that rejection — or handing + // the aborted caller a live consumer's in-flight promise — poisons the + // block for every other reference on the page. That is what makes a + // multivariate page with more than one variant render blank in preview. + // Resolve it standalone so the memo only ever holds the real render's + // resolution. Nested resolutions inherit the same aborted signal, so the + // whole aborted subtree bypasses the memo too. + if (RequestContext.signal?.aborted === true) { + return resolveResolvable(resolveType, context, opts); + } + // Otherwise memoize, but still evict the entry if the resolution rejects + // because the signal aborts mid-flight, so a later live read re-resolves + // instead of inheriting a cached rejection. + return (context.memo[resolveType] ??= resolveResolvable( resolveType, context, opts, @@ -563,18 +571,6 @@ const resolveWithType = < } throw err; })); - - return resolveShared().catch((err: unknown) => { - // A consumer whose own signal is still live must not inherit an abort it - // did not cause — this happens when it awaited the same in-flight promise - // as an aborted consumer. The entry was evicted above, so re-resolve it - // fresh under this (live) caller. Without this, a multivariate page whose - // real render is co-scheduled with an aborted lazy read renders blank. - if (isAbortError(err) && RequestContext.signal?.aborted !== true) { - return resolveShared(); - } - throw err; - }); } else if (resolveType in resolverMap) { const resolver = resolverMap[resolveType]; const proceed = () => From bf53329553ff330f945fff7045ee9e0f5b1b2efe Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 21 Aug 2026 12:05:22 -0300 Subject: [PATCH 19/27] 1.209.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index c9bb9f8e4..243f3585e 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.208.0", + "version": "1.209.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index bc794e5d7..e697ac8e5 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.208.0", + "version": "1.209.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index a40cae53a..fe4de34fb 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.208.0", + "version": "1.209.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From e6e8f514dcda2918580dae11c0fcf172aaa155f0 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 28 Aug 2026 14:17:20 -0300 Subject: [PATCH 20/27] fix(engine): don't inherit stale resolve hints on release swap (#1232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve hints are cached by resolveType (block id) and derived from the release's resolvables (`context.resolveHints[resolveType] ??= traverseAny(...)` in engine/core/resolver.ts). Fast Preview binds a request-scoped draft by swapping the release via `resolver.with({ release: draftProvider })`, but `.with` copied the base resolver's hints (`{ ...this.resolveHints }`). On a long-running server that serves the published release, the hint cache for a block gets populated with the published shape. When the draft is then resolved, it reuses those stale hints — so a block whose shape changed between releases (e.g. a page whose `sections` was a plain array when published but a `website/flags/multivariate` flag in the draft) resolves against the old shape and silently drops everything the old hints don't cover. Symptom: the draft renders a blank page (only globally-injected sections survive; the whole page body, footer included, is gone), while a fresh process that saw the draft first renders correctly. Fix: when `.with` swaps the release, start from empty hints instead of inheriting them. Hints belong to a release's resolvables, so a different release invalidates them — this mirrors the `release.onChange` invariant in the constructor, which already clears hints on every release change. Only the two preview/release-swap call sites are affected (Fast Preview draft, block preview); normal traffic and the setup path (which pass no `release`) are unchanged. Co-authored-by: Claude Opus 4.8 --- engine/core/mod.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ engine/core/mod.ts | 17 ++++++++++++++--- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/engine/core/mod.test.ts b/engine/core/mod.test.ts index d48422ef9..c4c75712d 100644 --- a/engine/core/mod.test.ts +++ b/engine/core/mod.test.ts @@ -6,9 +6,50 @@ import { resolve, type ResolverMap, } from "../../engine/core/resolver.ts"; +import { ReleaseResolver } from "../../engine/core/mod.ts"; +import { fromJSON } from "../../engine/decofile/fetcher.ts"; import defaults from "../manifest/fresh.ts"; import { RequestContext } from "../../deco.ts"; +Deno.test(".with({ release }) does not inherit stale resolve hints", async () => { + // Hints are cached by resolveType (block id) and derived from the release's + // resolvables. Fast Preview binds a request-scoped draft via + // `resolver.with({ release: draftProvider })`. If `.with` inherits the base + // resolver's hints, a block whose SHAPE changed between the published release + // and the draft (same id, different content) resolves against the stale shape + // and silently drops everything the old hints don't cover — the exact failure + // that blanked a page whose `sections` went from a plain array (published) to + // a `multivariate` flag (draft). + const resolvers = { + passthrough: (props: unknown) => props, + } as unknown as ResolverMap; + + // Published: block "page" has a plain `content` (no nested resolvable). + const published = fromJSON({ + page: { __resolveType: "passthrough", content: "plain" }, + }); + // Draft: SAME id "page", but `content` is now a nested resolvable — the draft + // needs a hint at `content` that the published shape never produced. + const draft = fromJSON({ + page: { __resolveType: "passthrough", content: { __resolveType: "inner" } }, + inner: { __resolveType: "passthrough", value: "from-draft" }, + }); + + const base = new ReleaseResolver({ release: published, resolvers }); + + // Resolve against the published release first — this populates (poisons) the + // base resolver's hint cache for "page" with the plain-content shape. + assertEquals(await base.resolve<{ content: unknown }>("page", {}), { + content: "plain", + }); + + // Swap the release for the draft, as Fast Preview does. + const drafted = base.with({ release: draft }); + assertEquals(await drafted.resolve<{ content: unknown }>("page", {}), { + content: { value: "from-draft" }, + }); +}); + Deno.test("resolve", async (t) => { const context: BaseContext = { revision: "", diff --git a/engine/core/mod.ts b/engine/core/mod.ts index 64d9c596d..6fe3d6b85 100644 --- a/engine/core/mod.ts +++ b/engine/core/mod.ts @@ -113,19 +113,30 @@ export class ReleaseResolver { { resolvers, resolvables, release, danglingRecover }: ExtensionOptions< TContext >, - ): ReleaseResolver => - new ReleaseResolver( + ): ReleaseResolver => { + // Hints are derived from the release's resolvables and cached by resolveType + // (block id). When the release is swapped — as Fast Preview does to bind a + // request-scoped draft (`resolver.with({ release: draftProvider })`) — the + // base hints are stale: the SAME block id can now resolve to different + // content (e.g. a page whose `sections` was a plain array in the published + // release but a `multivariate` flag in the draft). Inheriting them makes the + // draft resolve against the published shape and silently drop everything the + // old hints don't cover. This mirrors the `release.onChange` invariant in the + // constructor, which already clears hints whenever the release changes. + const releaseChanged = release !== undefined && release !== this.release; + return new ReleaseResolver( { release: release ?? this.release, danglingRecover: danglingRecover ?? this.danglingRecover, resolvables: { ...this.resolvables, ...resolvables }, resolvers: { ...this.resolvers, ...resolvers }, }, - { ...this.resolveHints }, + releaseChanged ? {} : { ...this.resolveHints }, { ...this.runOncePerRelease, }, ); + }; public getResolvers(): ResolverMap { return this._cachedResolvers ??= { From 4e145ceca52922d8c3842c0a75d297ec2b800edd Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 28 Aug 2026 17:17:33 +0000 Subject: [PATCH 21/27] Update version to 1.210.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index 243f3585e..cffd7ef07 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.209.0", + "version": "1.210.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index e697ac8e5..5334f7f16 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.209.0", + "version": "1.210.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index fe4de34fb..7151178bb 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.209.0", + "version": "1.210.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From a4975149af6662c2b5231d2b24c5ae5c1b6e5787 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 28 Aug 2026 14:25:23 -0300 Subject: [PATCH 22/27] fix(ci): drop invalid `--reload=file:` from deno cache step (#1233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer Deno (the CI pulls `deno-version: v2.x`) rejects a bare `file:` as a reload specifier: `error: invalid reload URL: 'file:'`, failing the "Setup deno" job on every run. The flag is also redundant — the preceding "Build Deno Module" step already runs `deno run --reload mod.ts`, so all deps are cached by the time this step runs; `deno cache ./mod.ts` just fetches anything still missing, which matches the step name. Co-authored-by: Claude Opus 4.8 --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 97c08568d..9c5e41085 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,7 @@ jobs: run: deno run -A --lock=deno.lock --frozen=false --reload mod.ts - name: fetch any uncached dependencies run: | - deno cache --lock=deno.lock --frozen=false --reload=file: ./mod.ts + deno cache --lock=deno.lock --frozen=false ./mod.ts - run: | deno test --lock=deno.lock --frozen=false -A . From 2c2066a3cbfe4bf9c05d857b02d18e3199b3eeba Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 28 Aug 2026 17:25:34 +0000 Subject: [PATCH 23/27] Update version to 1.211.0 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index cffd7ef07..b4f3d2dc9 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.210.0", + "version": "1.211.0", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 5334f7f16..6ff01308e 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.210.0", + "version": "1.211.0", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 7151178bb..132f6f5c7 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.210.0", + "version": "1.211.0", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 4a8b90888d24f139e9812a17e40d3ca337ca19b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nic=C3=A1cio=20Oliveira=20de=20Sousa?= Date: Mon, 21 Sep 2026 08:30:59 -0300 Subject: [PATCH 24/27] fix(engine): dispose superseded ReleaseResolver's release subscription (#1234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): dispose superseded ReleaseResolver's release subscription Every `new ReleaseResolver` subscribes to its release in the constructor and the returned Disposable was dropped. `installApps` rebuilds the resolver on each decofile change (`currentResolver = currentResolver.with(...)`), so every publish left one more superseded resolver permanently reachable from the provider's listener list, retaining its whole resolvables/resolvers/ resolveHints graph. Measured on a production replica (same runner image, Deno 1.44.4, real decofile): 8.5 MB of heap retained per publish, after two forced GCs, linear over 10 reloads — same with concurrent load (8.8 MB). A site publishing 24-36 times a day accumulated ~300 MB of unreclaimable heap in 44h. Keep the Disposable, expose `dispose()` (idempotent, also via Symbol.dispose), and drop the superseded resolver's subscription in `installApps`. Co-Authored-By: Claude Opus 5 (1M context) * fix(decofile): make the resolver's release subscription actually disposable The previous commit kept the Disposable returned by `release.onChange` and dropped it when a resolver was superseded, but that was inert in production and incomplete across publishes. Review of #1234 surfaced both: - `fromEndpoint` (engine/decofile/fetcher.ts) subscribed through `decofileProviderPromise.then((r) => r.onChange(cb))` and returned a disposable whose `[Symbol.dispose]` was empty, discarding the real inner subscription. `getProvider()` wraps every `folder://`, `file://`, `deconfig://` and `http(s)://` release in it, and `compose()` returns a single provider unchanged, so every real deployment disposed nothing. It now proxies the eventual subscription, including disposal that happens before the provider promise settles. - `fulfillContext` rebuilds the runtime resolver from the same stable base on every publish and overwrites `ctx.runtime` without disposing the outgoing one, so one resolver graph per publish stayed reachable — the very scenario the fix targets. Track the installed resolver and dispose it once its replacement is live. On the zero-apps path `runtimePromise` only takes the first resolution, so a later publish there discards the resolver it just built: dispose that one instead. Also guard `splice(indexOf(cb), 1)` in the four `onChange` implementations (fs, fsFolder, realtime, fromJSON): now that disposal actually runs, a second call would find -1 and drop the last, still-live callback. `Symbol.dispose` becomes an alias of the bound `dispose` field instead of a prototype method that breaks when detached, and two dead optional chains are removed. The new test counts live subscriptions through `fromEndpoint` by way of the `deco:hmr` event; it fails (26 live subscriptions instead of 1) without the fetcher fix. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- engine/core/mod.test.ts | 100 +++++++++++++++++++++++++++++++++++- engine/core/mod.ts | 36 ++++++++++++- engine/decofile/fetcher.ts | 25 ++++++++- engine/decofile/fs.ts | 8 ++- engine/decofile/fsFolder.ts | 8 ++- engine/decofile/realtime.ts | 8 ++- engine/manifest/manifest.ts | 27 ++++++++++ 7 files changed, 204 insertions(+), 8 deletions(-) diff --git a/engine/core/mod.test.ts b/engine/core/mod.test.ts index c4c75712d..5842baba5 100644 --- a/engine/core/mod.test.ts +++ b/engine/core/mod.test.ts @@ -7,7 +7,7 @@ import { type ResolverMap, } from "../../engine/core/resolver.ts"; import { ReleaseResolver } from "../../engine/core/mod.ts"; -import { fromJSON } from "../../engine/decofile/fetcher.ts"; +import { fromEndpoint, fromJSON } from "../../engine/decofile/fetcher.ts"; import defaults from "../manifest/fresh.ts"; import { RequestContext } from "../../deco.ts"; @@ -35,7 +35,10 @@ Deno.test(".with({ release }) does not inherit stale resolve hints", async () => inner: { __resolveType: "passthrough", value: "from-draft" }, }); - const base = new ReleaseResolver({ release: published, resolvers }); + const base = new ReleaseResolver({ + release: published, + resolvers, + }); // Resolve against the published release first — this populates (poisons) the // base resolver's hint cache for "page" with the plain-content shape. @@ -50,6 +53,99 @@ Deno.test(".with({ release }) does not inherit stale resolve hints", async () => }); }); +Deno.test("dispose() unsubscribes the resolver from its release", () => { + // `installApps` rebuilds the resolver on every decofile change: + // currentResolver = currentResolver.with({ resolvers, resolvables }) + // Each `new ReleaseResolver` subscribes to the SAME provider. Without + // disposing the superseded resolver, its subscription keeps it — and its + // whole resolvables/resolvers/resolveHints graph — reachable from the + // provider's listener list forever, so every publish permanently retains one + // more resolver. This test pins the subscription count instead of the bytes. + const listeners = new Set<() => unknown>(); + const release = { + state: () => Promise.resolve({}), + revision: () => Promise.resolve("1"), + onChange: (cb: () => unknown) => { + listeners.add(cb); + return { [Symbol.dispose]: () => listeners.delete(cb) }; + }, + // deno-lint-ignore no-explicit-any + } as any; + + const base = new ReleaseResolver({ release, resolvers: {} }); + assertEquals(listeners.size, 1); + + // Simulate repeated app installs, disposing each superseded resolver. + let current = base; + for (let i = 0; i < 25; i++) { + const superseded = current as unknown as { dispose?: () => void }; + current = current.with({ resolvers: {} }); + // Cast keeps this test meaningful without the fix: `dispose` is simply + // absent, the subscriptions pile up and the assertion below reports the + // real leak (one per rebuild) instead of failing to type-check. + superseded.dispose?.(); + } + assertEquals( + listeners.size, + 1, + "each publish must leave exactly one live subscription, not accumulate one per rebuild", + ); + + // Disposing is idempotent and actually detaches. + const last = current as unknown as { dispose?: () => void }; + last.dispose?.(); + last.dispose?.(); + assertEquals(listeners.size, 0); +}); + +Deno.test("dispose() unsubscribes through an endpoint-backed provider", async () => { + // The fake release above implements `onChange` correctly. `fromEndpoint` — + // which every real deployment goes through, since `getProvider` wraps + // `folder://`, `file://`, `deconfig://` and `http(s)://` in it — used to + // discard the inner Disposable and hand back a no-op one, so `dispose()` + // unsubscribed nothing in production while the test above stayed green. + // Count live subscriptions through the real provider instead of a stand-in. + const server = Deno.serve( + { port: 0, onListen: () => {} }, + () => new Response(JSON.stringify({})), + ); + // Each live subscription dispatches exactly one `deco:hmr` per notify, so the + // event count is the subscription count. + let hmr = 0; + const countHmr = () => hmr++; + addEventListener("deco:hmr", countHmr); + try { + const release = fromEndpoint( + `http://localhost:${server.addr.port}/decofile.json`, + ); + await release.state(); + + let current = new ReleaseResolver({ release, resolvers: {} }); + for (let i = 0; i < 25; i++) { + const superseded = current; + current = current.with({ resolvers: {} }); + superseded.dispose(); + } + await release.notify?.(); + assertEquals( + hmr, + 1, + "each publish must leave exactly one live subscription on the endpoint-backed provider", + ); + + // Disposal that lands before the provider promise settles must still + // prevent the subscription from ever being registered. + hmr = 0; + current.dispose(); + current.dispose(); + await release.notify?.(); + assertEquals(hmr, 0, "dispose() must detach the last resolver too"); + } finally { + removeEventListener("deco:hmr", countHmr); + await server.shutdown(); + } +}); + Deno.test("resolve", async (t) => { const context: BaseContext = { revision: "", diff --git a/engine/core/mod.ts b/engine/core/mod.ts index 6fe3d6b85..cb2c4bcdc 100644 --- a/engine/core/mod.ts +++ b/engine/core/mod.ts @@ -90,6 +90,13 @@ export class ReleaseResolver { private resolveHints: ResolveHints; private _cachedResolvers: ResolverMap | null = null; private _resolveIdCounter = 0; + /** + * Subscription to the release's `onChange`. Kept so it can be disposed when + * this resolver is replaced — otherwise every superseded resolver stays + * reachable from the provider's listener list and leaks its whole + * `resolvables`/`resolvers`/`resolveHints` graph. + */ + private releaseSubscription: Disposable | undefined; constructor( config: ResolverOptions, hints?: ResolveHints, @@ -101,7 +108,7 @@ export class ReleaseResolver { this.danglingRecover = config.danglingRecover; this.resolveHints = hints ?? {}; this.runOncePerRelease = oncePerRelease ?? {}; - this.release.onChange(() => { + this.releaseSubscription = this.release.onChange(() => { dispatchEvent(new Event("deco:hmr")); this.runOncePerRelease = {}; this.resolveHints = {}; @@ -109,6 +116,33 @@ export class ReleaseResolver { }); } + /** + * Unsubscribes this resolver from its release. + * + * `installApps` rebuilds the resolver on every decofile change + * (`currentResolver = currentResolver.with({ resolvers, resolvables })`), and + * each `new ReleaseResolver` subscribes to the same provider. Without + * dropping the previous subscription the superseded resolver is still + * referenced by the provider's listener list and can never be collected, so + * every publish permanently retains one full resolver graph. + * + * Safe to call on a superseded resolver: in-flight requests holding a + * reference keep working, they simply stop being notified of release changes + * they no longer serve. Idempotent. + */ + public dispose = (): void => { + this.releaseSubscription?.[Symbol.dispose](); + this.releaseSubscription = undefined; + }; + + /** + * Alias, not a forwarding method: a prototype method that calls + * `this.dispose()` throws once it is detached from the instance + * (`const d = resolver[Symbol.dispose]; d()`), while the bound field above + * survives it. + */ + readonly [Symbol.dispose] = this.dispose; + public with = ( { resolvers, resolvables, release, danglingRecover }: ExtensionOptions< TContext diff --git a/engine/decofile/fetcher.ts b/engine/decofile/fetcher.ts index 07d24a048..cdf7ac063 100644 --- a/engine/decofile/fetcher.ts +++ b/engine/decofile/fetcher.ts @@ -150,7 +150,13 @@ export const fromJSON = ( cbs.push(cb); return { [Symbol.dispose]: () => { - cbs.splice(cbs.indexOf(cb), 1); + // Guard the index: on a second disposal `indexOf` returns -1 and + // `splice(-1, 1)` would drop the LAST, still-live callback instead of + // doing nothing. + const idx = cbs.indexOf(cb); + if (idx !== -1) { + cbs.splice(idx, 1); + } }, }; }, @@ -236,9 +242,24 @@ export const fromEndpoint = (endpoint: string): DecofileProvider => { }, state: (options) => decofileProviderPromise.then((r) => r.state(options)), onChange: (cb) => { - decofileProviderPromise.then((r) => r.onChange(cb)); + // The inner provider only exists after the promise settles, but callers + // need a Disposable synchronously. Proxy it: forward the disposal to the + // real subscription once it exists, and remember a disposal that happened + // before that — otherwise this returns a no-op and every caller relying on + // it (e.g. `ReleaseResolver.dispose`) silently leaks its subscription. + let disposed = false; + let inner: Disposable | undefined; + decofileProviderPromise.then((r) => { + if (disposed) { + return; + } + inner = r.onChange(cb); + }).catch(() => {}); return { [Symbol.dispose]: () => { + disposed = true; + inner?.[Symbol.dispose](); + inner = undefined; }, }; }, diff --git a/engine/decofile/fs.ts b/engine/decofile/fs.ts index a7313572d..831c19bed 100644 --- a/engine/decofile/fs.ts +++ b/engine/decofile/fs.ts @@ -154,7 +154,13 @@ export const newFsProviderFromPath = ( onChangeCbs.push(cb); return { [Symbol.dispose]: () => { - onChangeCbs.splice(onChangeCbs.indexOf(cb), 1); + // Guard the index: on a second disposal `indexOf` returns -1 and + // `splice(-1, 1)` would drop the LAST, still-live callback instead of + // doing nothing. + const idx = onChangeCbs.indexOf(cb); + if (idx !== -1) { + onChangeCbs.splice(idx, 1); + } }, }; }, diff --git a/engine/decofile/fsFolder.ts b/engine/decofile/fsFolder.ts index 1a779f867..e1cf7e89c 100644 --- a/engine/decofile/fsFolder.ts +++ b/engine/decofile/fsFolder.ts @@ -217,7 +217,13 @@ export const newFsFolderProviderFromPath = ( onChangeCbs.push(cb); return { [Symbol.dispose]: () => { - onChangeCbs.splice(onChangeCbs.indexOf(cb), 1); + // Guard the index: on a second disposal `indexOf` returns -1 and + // `splice(-1, 1)` would drop the LAST, still-live callback instead of + // doing nothing. + const idx = onChangeCbs.indexOf(cb); + if (idx !== -1) { + onChangeCbs.splice(idx, 1); + } }, }; }, diff --git a/engine/decofile/realtime.ts b/engine/decofile/realtime.ts index 89cd2d6c2..5a2d38325 100644 --- a/engine/decofile/realtime.ts +++ b/engine/decofile/realtime.ts @@ -157,7 +157,13 @@ export const newRealtime = ( onChangeCbs.push(cb); return { [Symbol.dispose]: () => { - onChangeCbs.splice(onChangeCbs.indexOf(cb), 1); + // Guard the index: on a second disposal `indexOf` returns -1 and + // `splice(-1, 1)` would drop the LAST, still-live callback instead of + // doing nothing. + const idx = onChangeCbs.indexOf(cb); + if (idx !== -1) { + onChangeCbs.splice(idx, 1); + } }, }; }, diff --git a/engine/manifest/manifest.ts b/engine/manifest/manifest.ts index 93c722c65..bbed51493 100644 --- a/engine/manifest/manifest.ts +++ b/engine/manifest/manifest.ts @@ -194,7 +194,12 @@ const installAppsForResolver = async ( ...importMap, imports: { ...appImportMap?.imports, ...importMap?.imports }, }; + const supersededResolver = currentResolver; currentResolver = currentResolver.with({ resolvers, resolvables }); + // Drop the superseded resolver's release subscription; otherwise every + // publish leaves one more resolver graph permanently reachable from the + // provider's listener list. + supersededResolver.dispose(); return true; }; @@ -294,6 +299,13 @@ export const fulfillContext = async < release: provider, }); const firstInstallAppsPromise = deferred(); + // The resolver currently serving traffic (`ctx.runtime.resolver`). Every + // publish re-runs `installApps()` from the same stable `resolver` base, so + // without holding on to the outgoing one nothing ever disposes it: its + // subscription keeps it — and its whole resolver graph — reachable from the + // provider's listener list, and its `onChange` callback keeps firing for a + // release it no longer serves. + let installedResolver: ReleaseResolver | undefined; const installApps = async () => { const [newManifest, resolvers, recovers] = (blocks() ?? []).reduce( (curr, acc) => buildRuntime(curr, acc), @@ -324,11 +336,20 @@ export const fulfillContext = async < ); const apps: Resolvable[] = Object.values(allAppsMap); if (!apps || apps.length === 0) { + // `runtimePromise` only takes the first resolution: on a later publish + // this path leaves `ctx.runtime` untouched, so the resolver just built is + // dead on arrival — dispose it instead of the live one. + const isFirstInstall = runtimePromise.state === "pending"; runtimePromise.resolve({ resolver: currentResolver, manifest: newManifest as T, importMap: currentImportMap ?? buildImportMap(newManifest), }); + if (isFirstInstall) { + installedResolver = currentResolver; + } else { + currentResolver.dispose(); + } firstInstallAppsPromise.resolve(); return; } @@ -362,6 +383,12 @@ export const fulfillContext = async < runtimePromise.resolve(runtime); } ctx.runtime = Promise.resolve(runtime); + // Only after the replacement is live: in-flight requests holding the old + // resolver keep resolving, they just stop being notified about a release + // they no longer serve. + const supersededRuntimeResolver = installedResolver; + installedResolver = currentResolver; + supersededRuntimeResolver?.dispose(); }; let appsInstallationMutex = deferred(); From 6e37b774d5df9720ec1e59f880714a6453c178ba Mon Sep 17 00:00:00 2001 From: decobot Date: Mon, 21 Sep 2026 11:31:12 +0000 Subject: [PATCH 25/27] Update version to 1.211.1 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index b4f3d2dc9..a5f395453 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.211.0", + "version": "1.211.1", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index 6ff01308e..f3ef20b37 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.211.0", + "version": "1.211.1", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 132f6f5c7..15bc8a52c 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.211.0", + "version": "1.211.1", "exports": { "./release": "./release.ts", "./update": "./update.run.ts", From 180dd6856ed70d273c16b54eddb10a56a0873b89 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 22 Sep 2026 08:15:50 -0300 Subject: [PATCH 26/27] feat(draft): allow fast preview on local dev hosts and the dev tunnel (#1238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(draft): allow fast preview on local dev hosts and the dev tunnel The `?__draft=` fast preview was inert during local development: it only rendered drafts on hosts named in `previewHosts` / `DECO_ALLOWED_PREVIEW_HOSTS` or the deco-hosted `.deco.site` / `envs---.decocdn.com` domains. But `deno task start` serves on `localhost:` and on the per-developer dev tunnel `--.deco.{host,site}` (daemon/tunnel.ts), so a draft never previewed locally without extra config. Now local dev hosts (`localhost`, `127.0.0.1`, `*.localhost`, any port) are always allowed, and the dev tunnel is inferred from the resolved site name with its `` label pinned to a single DNS label — the same anti-widening constraint as the per-deploy ``. `DECO_ALLOWED_PREVIEW_HOSTS=none` still kills everything, local and tunnel included. The signed `?__draft=` grant remains the actual capability; host-scoping only bounds blast radius. Co-Authored-By: Claude Opus 4.8 * fix(draft): address review — drop .deco.site tunnel apex, match IPv6 loopback Two issues from PR review: 1. The dev-tunnel match no longer accepts the `--.deco.site` simpletunnel fallback: `.deco.site` is also the stable production apex, so matching it widened the draft gate on production, not just dev machines. Only `--.deco.host` (the `DECO_HOST` default) is matched now; the rare `DECO_HOST=false` opt-out previews via localhost or an explicit allowlist. 2. `isLocalDevHost` now treats the IPv6 loopback as local — `::1`, `[::1]`, and `[::1]:port` — which the port-splitting previously mangled to a non-match. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- engine/decofile/draft.test.ts | 118 +++++++++++++++++++++++++--- engine/decofile/draft.ts | 140 ++++++++++++++++++++++++++-------- 2 files changed, 216 insertions(+), 42 deletions(-) diff --git a/engine/decofile/draft.test.ts b/engine/decofile/draft.test.ts index ca5a9d086..627e8a36c 100644 --- a/engine/decofile/draft.test.ts +++ b/engine/decofile/draft.test.ts @@ -137,20 +137,46 @@ Deno.test("previewApiOriginForHost", async (t) => { }); Deno.test("gating", async (t) => { - await t.step("on iff an allowed host is configured", () => { + await t.step("on by default (local dev), and with a configured host", () => { + // Local dev hosts are always allowed, so the feature reads as enabled even + // with no config; a configured host obviously enables it too. assertEquals(isDraftPreviewEnabled(ENV_ON), true); - assertEquals(isDraftPreviewEnabled({}), false); + assertEquals(isDraftPreviewEnabled({}), true); }); - await t.step("matches request hosts verbatim, port + case", () => { + await t.step("matches non-local request hosts verbatim, port + case", () => { const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" }; assertEquals(isDraftHostAllowed("FILA.VTEX.APP", env), true); assertEquals(isDraftHostAllowed("localhost:3100", env), true); assertEquals(isDraftHostAllowed("fila.com.br", env), false); - assertEquals(isDraftHostAllowed("localhost", env), false); + // The port is part of the exact match for a configured host. + assertEquals(isDraftHostAllowed("fila.vtex.app:80", env), false); assertEquals(isDraftHostAllowed(null, env), false); + // A non-local host is inert without config. assertEquals(isDraftHostAllowed("fila.vtex.app", {}), false); }); + + await t.step("local dev hosts are always allowed, with any port", () => { + // No config, no site name: a dev server still previews out of the box. + assertEquals(isDraftHostAllowed("localhost", {}), true); + assertEquals(isDraftHostAllowed("localhost:8000", {}), true); + assertEquals(isDraftHostAllowed("127.0.0.1:3000", {}), true); + assertEquals(isDraftHostAllowed("my-site.localhost:5173", {}), true); + // IPv6 loopback: bracketed with/without a port, and the bare portless form. + assertEquals(isDraftHostAllowed("[::1]:8000", {}), true); + assertEquals(isDraftHostAllowed("[::1]", {}), true); + assertEquals(isDraftHostAllowed("::1", {}), true); + // Not actually loopback — must not be treated as local. + assertEquals(isDraftHostAllowed("localhost.evil.example", {}), false); + assertEquals(isDraftHostAllowed("notlocalhost", {}), false); + assertEquals(isDraftHostAllowed("[::2]:8000", {}), false); + }); + + await t.step("the kill switch disables local too", () => { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" }; + assertEquals(isDraftPreviewEnabled(env), false); + assertEquals(isDraftHostAllowed("localhost:8000", env), false); + }); }); Deno.test("resolveDraftDecofile", async (t) => { @@ -173,12 +199,15 @@ Deno.test("resolveDraftDecofile", async (t) => { ]); }); - await t.step("is inert without a host allowlist — no fetch", async () => { + await t.step("is inert under the kill switch — no fetch", async () => { + // resolveDraftDecofile is origin-gated (the request-host gate lives in + // resolveDraftForRequest), so the "inert / no network" case at this level is + // the kill switch, which disables preview entirely — local included. clearDraftCache(); let called = false; const blocks = await resolveDraftDecofile({ pointer: `${P}@v1`, - env: {}, + env: { DECO_ALLOWED_PREVIEW_HOSTS: "none" }, fetchImpl: (() => { called = true; return Promise.resolve(jsonResponse({})); @@ -367,6 +396,67 @@ Deno.test("deco-hosted preview domain (setDecoSiteHost)", async (t) => { } }); + await t.step("infers the per-developer --.deco.host dev tunnel", () => { + setDecoSiteHost("farmrio"); + try { + assertEquals(isDraftPreviewEnabled({}), true); + // The host `deno task start` actually serves on (see daemon/tunnel.ts). + assertEquals( + isDraftHostAllowed("tavano--farmrio.deco.host", {}), + true, + ); + // The `.deco.site` simpletunnel fallback is deliberately NOT matched: it + // shares the apex with the stable production domain, so matching it would + // widen the gate on production, not just dev machines. + assertEquals( + isDraftHostAllowed("tavano--farmrio.deco.site", {}), + false, + ); + // Any developer env label — matched as a single label, like the deploy hash. + assertEquals( + isDraftHostAllowed("other-dev--farmrio.deco.host", {}), + true, + ); + // Wrong site segment. + assertEquals( + isDraftHostAllowed("tavano--other.deco.host", {}), + false, + ); + // Missing the `--` boundary (would collide with the exact .deco.site). + assertEquals( + isDraftHostAllowed("farmrio.deco.host", {}), + false, + ); + // The env must be a single label — no nested subdomain under deco.host. + assertEquals( + isDraftHostAllowed("x.evil--farmrio.deco.host", {}), + false, + ); + // Empty env label. + assertEquals( + isDraftHostAllowed("--farmrio.deco.host", {}), + false, + ); + // Wrong apex. + assertEquals( + isDraftHostAllowed("tavano--farmrio.deco.example", {}), + false, + ); + } finally { + setDecoSiteHost(null); + } + }); + + await t.step("the kill switch disables the dev tunnel too", () => { + setDecoSiteHost("farmrio"); + try { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" }; + assertEquals(isDraftHostAllowed("tavano--farmrio.deco.host", env), false); + } finally { + setDecoSiteHost(null); + } + }); + await t.step("infers the per-deploy envs---.decocdn.com host", () => { setDecoSiteHost("als-storefront"); try { @@ -452,21 +542,27 @@ Deno.test("deco-hosted preview domain (setDecoSiteHost)", async (t) => { } }); - await t.step("a blank/null site name registers no host", () => { + await t.step("a blank/null site name infers no deco-hosted host", () => { setDecoSiteHost(" "); try { - assertEquals(isDraftPreviewEnabled({}), false); + // No `.deco.site` inferred; only local dev stays allowed. + assertEquals(isDraftHostAllowed("anything.deco.site", {}), false); + assertEquals(isDraftHostAllowed("preview.example", {}), false); + assertEquals(isDraftHostAllowed("localhost:8000", {}), true); } finally { setDecoSiteHost(null); } }); - await t.step("undefined (the random dev fallback) registers no host", () => { + await t.step("undefined (the random dev fallback) infers no host", () => { // runtime/mod.ts passes `resolvedSite` (undefined when the site name falls - // back to randomSiteName) straight through — an unnamed site is not armed. + // back to randomSiteName) straight through — an unnamed site infers no + // deco-hosted domain, though local dev is still allowed. setDecoSiteHost(undefined); try { - assertEquals(isDraftPreviewEnabled({}), false); + assertEquals(isDraftHostAllowed("anything.deco.site", {}), false); + assertEquals(isDraftHostAllowed("preview.example", {}), false); + assertEquals(isDraftHostAllowed("localhost:8000", {}), true); } finally { setDecoSiteHost(null); } diff --git a/engine/decofile/draft.ts b/engine/decofile/draft.ts index 7c781664f..a05c80472 100644 --- a/engine/decofile/draft.ts +++ b/engine/decofile/draft.ts @@ -16,12 +16,15 @@ import { deleteCookie, setCookie } from "@std/http"; * request is done by the runtime (see `runtime/mod.ts`'s `prepareState`, which * swaps `state.release` for a `fromJSON`-backed provider of the pulled draft). * - * Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` (or the site-declared 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 may render drafts while the - * production domain, on the same build, must ignore a `?__draft=` entirely. + * Inert unless the request's host is a local dev origin, or is named by + * `DECO_ALLOWED_PREVIEW_HOSTS` (or the site-declared preview hosts), or is a + * deco-hosted domain inferred from the site name: upgrading the package must + * never be enough to start fetching from the network and rendering unpublished + * content on a production domain. 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. `DECO_ALLOWED_PREVIEW_HOSTS=none` disables everything, + * including local. */ /** @@ -200,8 +203,11 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void { * - `envs---.decocdn.com` — the per-deploy preview URL, whose * `` label changes every deploy (so it is matched as a PATTERN, never * a fixed allowlist entry). + * - `--.deco.host` — the per-developer dev tunnel that + * `deno task start` serves on, whose `` label varies per developer (so + * it too is matched as a PATTERN — see `matchesDevTunnel`). * - * Both are MERGED with the site-block/env list rather than replacing it: they + * All are MERGED with the site-block/env list rather than replacing it: they * are deco-operated infra, so a signed draft grant can preview there out of the * box, while a custom production domain — never inferred here — stays inert. * Fed from the trusted setup-time site name, never from the request or a draft. @@ -223,6 +229,57 @@ const DEPLOY_PREVIEW_SUFFIX = ".decocdn.com"; /** A single DNS label — the per-deploy ``, which carries no dots. */ const DEPLOY_HASH_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; +/** + * Apex of deco's per-developer dev tunnel: `deno task start` exposes the local + * server at `--.deco.host` (the `decoHost` tunnel, the default — see + * `daemon/tunnel.ts` and `DECO_HOST`). + * + * Deliberately NOT the simpletunnel fallback `--.deco.site`: `.deco.site` + * is also the STABLE PRODUCTION apex (`.deco.site`, the exact host + * previewConfig already infers), so matching `--.deco.site` here would + * widen the draft gate on production deployments, not just dev machines. The + * fallback is a rare `DECO_HOST=false` opt-out; those devs can preview via + * localhost or an explicit `DECO_ALLOWED_PREVIEW_HOSTS` entry instead. + */ +const DEV_TUNNEL_APEX = ".deco.host"; + +/** + * Whether `host` is this site's dev tunnel: `--.deco.host`. + * + * The `` label is per-developer (`tavano` in `tavano--farmrio.deco.host`), + * so — like the per-deploy `` — it can't be a fixed allowlist entry, but + * it is pinned to a SINGLE DNS label ahead of the literal `--` boundary. + * Nothing under an attacker-controlled subdomain can widen the match: the site + * segment and apex are fixed, and the env segment carries no dots. The apex is + * dev-only (`.deco.host`, never the production `.deco.site`), so this stays + * inert on production. + */ +function matchesDevTunnel(host: string, site: string | null): boolean { + if (!site) return false; + const suffix = `--${site}${DEV_TUNNEL_APEX}`; // e.g. `--farmrio.deco.host` + if (!host.endsWith(suffix)) return false; + const envLabel = host.slice(0, -suffix.length); + return DEPLOY_HASH_RE.test(envLabel); +} + +/** + * Whether `host` is a local dev origin — `localhost`, either loopback IP + * (`127.0.0.1` / IPv6 `::1`), or any `*.localhost` subdomain, with an optional + * port. These are ALWAYS allowed to render drafts (unless the kill switch is + * set): a dev server should preview a draft with zero config, and a loopback + * host is only reachable on the dev machine, so it adds no production blast + * radius. The signed `?__draft=` grant remains the actual capability. + */ +function isLocalDevHost(host: string): boolean { + // IPv6 loopback arrives bracketed (`[::1]` / `[::1]:port`) or, portless and + // unbracketed, as `::1`; the port-splitting below would mangle both. + if (host === "::1" || host === "[::1]" || host.startsWith("[::1]:")) { + return true; + } + const h = host.split(":")[0]; // strip an optional port + return h === "localhost" || h === "127.0.0.1" || h.endsWith(".localhost"); +} + /** * The resolved preview gate: the exact-match host list, the per-deploy preview * pattern, and the kill switch — all in one read so callers see a consistent @@ -234,9 +291,10 @@ const DEPLOY_HASH_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; * deploy, add a machine-specific port) — not the primary configuration. * * The deco-hosted domains inferred from the site name (`.deco.site` as an - * exact host, `envs---.decocdn.com` as `deployPreviewPrefix`) are - * always ADDED on top, so a signed draft grant can preview on deco-operated - * infra without any per-site config. + * exact host, `envs---.decocdn.com` as `deployPreviewPrefix`, and + * the `--.deco.host` dev tunnel via `site`) are always ADDED + * on top, so a signed draft grant can preview on deco-operated infra — including + * the local dev tunnel — without any per-site config. * * The sentinel `DECO_ALLOWED_PREVIEW_HOSTS=none` is a KILL SWITCH: it disables * preview entirely — the inferred hosts and the site block included — so a bad @@ -245,19 +303,36 @@ const DEPLOY_HASH_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; function previewConfig(env: EnvLike): { hosts: string[]; deployPreviewPrefix: string | null; + /** Resolved site name, for the dev-tunnel match (`matchesDevTunnel`); null + * when unset or killed. */ + site: string | null; + /** Whether local dev hosts (see `isLocalDevHost`) may render drafts. Always + * true except under the `none` kill switch. */ + localAllowed: boolean; } { const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); - if (fromEnv.includes("none")) return { hosts: [], deployPreviewPrefix: null }; + if (fromEnv.includes("none")) { + return { hosts: [], deployPreviewPrefix: null, site: null, localAllowed: false }; + } const configured = fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); const site = G.__decoSite; - if (!site) return { hosts: configured, deployPreviewPrefix: null }; + if (!site) { + return { + hosts: configured, + deployPreviewPrefix: null, + site: null, + localAllowed: true, + }; + } const decoSite = `${site}.deco.site`; return { hosts: configured.includes(decoSite) ? configured : [...configured, decoSite], deployPreviewPrefix: `envs-${site}--`, + site, + localAllowed: true, }; } @@ -277,19 +352,18 @@ function matchesDeployPreview(host: string, prefix: string | null): boolean { return DEPLOY_HASH_RE.test(hash); } -/** Hosts allowed to render drafts by an exact match. */ -function readAllowedHosts(env: EnvLike): string[] { - return previewConfig(env).hosts; -} - /** * Whether `host` (as seen on the request) may render drafts. * - * Exact-matched against the allowlist (port included — local dev is - * `localhost:3100`, not `localhost`), then against the per-deploy preview - * pattern. 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. + * Local dev hosts are always allowed (see `isLocalDevHost`) so a dev server + * previews with no config, as is this site's dev tunnel + * (`--.deco.host`, see `matchesDevTunnel`) — the host + * `deno task start` actually serves on. Otherwise the host is exact-matched + * against the allowlist (port included — a configured `localhost:3100` is + * matched here, not by the local rule) and the per-deploy preview pattern. 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. */ export function isDraftHostAllowed( host: string | null | undefined, @@ -298,20 +372,24 @@ export function isDraftHostAllowed( if (!host) return false; const h = host.trim().toLowerCase(); const cfg = previewConfig(envOrDeno(env)); - return cfg.hosts.includes(h) || - matchesDeployPreview(h, cfg.deployPreviewPrefix); + return (cfg.localAllowed && isLocalDevHost(h)) || + cfg.hosts.includes(h) || + matchesDeployPreview(h, cfg.deployPreviewPrefix) || + matchesDevTunnel(h, cfg.site); } /** * True when any host is allowed to preview. A cheap read callers use to gate - * BEFORE touching the network. A site with no config but a resolved name is now - * enabled here (its inferred `.deco.site` / `envs---*.decocdn.com` - * hosts); `DECO_ALLOWED_PREVIEW_HOSTS=none` forces it back to fully inert. The + * BEFORE touching the network. Local dev hosts are always allowed, so this is + * effectively always on (a site with a resolved name additionally enables its + * inferred `.deco.site` / `envs---*.decocdn.com` hosts); + * `DECO_ALLOWED_PREVIEW_HOSTS=none` forces it back to fully inert. The * per-request host match happens later, in `isDraftHostAllowed`. */ export function isDraftPreviewEnabled(env?: EnvLike): boolean { const cfg = previewConfig(envOrDeno(env)); - return cfg.hosts.length > 0 || cfg.deployPreviewPrefix !== null; + return cfg.localAllowed || cfg.hosts.length > 0 || + cfg.deployPreviewPrefix !== null; } /** @@ -366,7 +444,7 @@ export async function resolveDraftDecofile( options: ResolveDraftOptions, ): Promise | null> { const env = envOrDeno(options.env); - if (readAllowedHosts(env).length === 0) return null; + if (!isDraftPreviewEnabled(env)) return null; const parsed = parseDraftPointer(options.pointer); if (!parsed) return null; @@ -485,7 +563,7 @@ export function applyDraftCookie( return; } const e = envOrDeno(env); - if (readAllowedHosts(e).length === 0) return; + if (!isDraftPreviewEnabled(e)) return; if (!isDraftHostAllowed(draftHostFromRequest(request), e)) return; setCookie(headers, { name: DRAFT_COOKIE_NAME, @@ -517,7 +595,7 @@ export async function resolveDraftForRequest( options: ResolveDraftForRequestOptions = {}, ): Promise | null> { const env = envOrDeno(options.env); - if (readAllowedHosts(env).length === 0) return null; + if (!isDraftPreviewEnabled(env)) return null; const pointer = draftPointerFromRequest(request); if (!pointer) return null; if (!isDraftHostAllowed(draftHostFromRequest(request), env)) return null; From 7dbb79ae257338630ffbe41ab44c953fb86ab845 Mon Sep 17 00:00:00 2001 From: decobot Date: Tue, 22 Sep 2026 11:16:03 +0000 Subject: [PATCH 27/27] Update version to 1.211.2 --- deno.json | 2 +- dev/deno.json | 2 +- scripts/deno.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deno.json b/deno.json index a5f395453..fce173c8e 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/deco", - "version": "1.211.1", + "version": "1.211.2", "lock": false, "nodeModulesDir": "auto", "exports": { diff --git a/dev/deno.json b/dev/deno.json index f3ef20b37..73bbc8109 100644 --- a/dev/deno.json +++ b/dev/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/dev", - "version": "1.211.1", + "version": "1.211.2", "exports": { "./tailwind": "./tailwind.ts" }, diff --git a/scripts/deno.json b/scripts/deno.json index 15bc8a52c..9bb3f2b9a 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -1,6 +1,6 @@ { "name": "@deco/scripts", - "version": "1.211.1", + "version": "1.211.2", "exports": { "./release": "./release.ts", "./update": "./update.run.ts",