Skip to content

Commit d56b1b1

Browse files
guitavanoclaude
andauthored
feat(preview): infer <site>.deco.site as an allowed draft preview host (#1224)
* feat(preview): infer <site>.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. <site>.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 <noreply@anthropic.com> * feat(preview): add kill switch and document threat model for inferred host Address review of the `<site>.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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 659bbdf commit d56b1b1

4 files changed

Lines changed: 123 additions & 5 deletions

File tree

engine/decofile/draft.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
previewApiOriginForHost,
1111
resolveDraftDecofile,
1212
resolveDraftForRequest,
13+
setDecoSiteHost,
1314
setDraftPreviewHosts,
1415
} from "./draft.ts";
1516

@@ -352,6 +353,80 @@ Deno.test("site-block preview hosts", async (t) => {
352353
});
353354
});
354355

356+
Deno.test("deco-hosted preview domain (setDecoSiteHost)", async (t) => {
357+
await t.step("infers <site>.deco.site and enables the feature", () => {
358+
setDecoSiteHost("als-storefront");
359+
try {
360+
assertEquals(isDraftPreviewEnabled({}), true);
361+
assertEquals(isDraftHostAllowed("als-storefront.deco.site", {}), true);
362+
assertEquals(isDraftHostAllowed("other.deco.site", {}), false);
363+
// A custom production domain is never inferred.
364+
assertEquals(isDraftHostAllowed("www.als-storefront.com", {}), false);
365+
} finally {
366+
setDecoSiteHost(null);
367+
}
368+
});
369+
370+
await t.step("is merged ON TOP of the site block, not replacing it", () => {
371+
setDraftPreviewHosts(["fila.vtex.app"]);
372+
setDecoSiteHost("als-storefront");
373+
try {
374+
assertEquals(isDraftHostAllowed("fila.vtex.app", {}), true);
375+
assertEquals(isDraftHostAllowed("als-storefront.deco.site", {}), true);
376+
} finally {
377+
setDraftPreviewHosts([]);
378+
setDecoSiteHost(null);
379+
}
380+
});
381+
382+
await t.step("is merged ON TOP of the env escape hatch too", () => {
383+
setDecoSiteHost("als-storefront");
384+
try {
385+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
386+
assertEquals(isDraftHostAllowed("other.example", env), true);
387+
assertEquals(isDraftHostAllowed("als-storefront.deco.site", env), true);
388+
} finally {
389+
setDecoSiteHost(null);
390+
}
391+
});
392+
393+
await t.step("a blank/null site name registers no host", () => {
394+
setDecoSiteHost(" ");
395+
try {
396+
assertEquals(isDraftPreviewEnabled({}), false);
397+
} finally {
398+
setDecoSiteHost(null);
399+
}
400+
});
401+
402+
await t.step("undefined (the random dev fallback) registers no host", () => {
403+
// runtime/mod.ts passes `resolvedSite` (undefined when the site name falls
404+
// back to randomSiteName) straight through — an unnamed site is not armed.
405+
setDecoSiteHost(undefined);
406+
try {
407+
assertEquals(isDraftPreviewEnabled({}), false);
408+
} finally {
409+
setDecoSiteHost(null);
410+
}
411+
});
412+
413+
await t.step("DECO_ALLOWED_PREVIEW_HOSTS=none kills the inferred host", () => {
414+
setDraftPreviewHosts(["fila.vtex.app"]);
415+
setDecoSiteHost("als-storefront");
416+
try {
417+
// The kill switch wins over the inferred host AND the site block, so a
418+
// bad rollout can be stopped without a deploy.
419+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" };
420+
assertEquals(isDraftPreviewEnabled(env), false);
421+
assertEquals(isDraftHostAllowed("als-storefront.deco.site", env), false);
422+
assertEquals(isDraftHostAllowed("fila.vtex.app", env), false);
423+
} finally {
424+
setDraftPreviewHosts([]);
425+
setDecoSiteHost(null);
426+
}
427+
});
428+
});
429+
355430
Deno.test("draftPointerFromRequest", async (t) => {
356431
await t.step("reads the __deco_draft cookie", () => {
357432
const req = new Request(

engine/decofile/draft.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export function previewApiOriginForHost(
182182
* (possibly drafted) release: an allowlist readable through the draft could be
183183
* rewritten by the very draft it gates.
184184
*/
185-
const G = globalThis as { __decoDraftHosts?: string[] };
185+
const G = globalThis as { __decoDraftHosts?: string[]; __decoSiteHost?: string };
186186

187187
/** Install the site-declared preview hosts. Called at setup by the site app. */
188188
export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
@@ -192,20 +192,54 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
192192
.filter(Boolean);
193193
}
194194

195+
/**
196+
* Register the deco-hosted preview domain, derived from the resolved site name
197+
* (`opts.site ?? DECO_SITE_NAME ?? …`, resolved by the runtime at setup).
198+
*
199+
* MERGED with the site-block/env list rather than replacing it: `<site>.deco.site`
200+
* is deco-operated infra, so a signed draft grant can preview there out of the
201+
* box, while a custom production domain — never inferred here — stays inert.
202+
* Fed from the trusted setup-time site name, never from the request or a draft.
203+
*
204+
* Threat model, now that a named site is no longer inert by default: the
205+
* request host is spoofable on a direct-to-origin request (the edge is trusted
206+
* to set `x-forwarded-host`), so for a named site the SIGNED `?__draft=` grant
207+
* is the sole remaining gate — host-scoping only bounds blast radius. Set
208+
* `DECO_ALLOWED_PREVIEW_HOSTS=none` to kill preview entirely, including this
209+
* inferred host, without a deploy.
210+
*/
211+
export function setDecoSiteHost(site: string | null | undefined): void {
212+
const s = (site ?? "").trim().toLowerCase();
213+
G.__decoSiteHost = s ? `${s}.deco.site` : undefined;
214+
}
215+
195216
/**
196217
* Hosts allowed to render drafts.
197218
*
198219
* The site block is the expected source — the opt-in lives in the repo,
199220
* reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS`
200221
* REPLACES it when set: an operational escape hatch (kill a bad value without
201222
* a deploy, add a machine-specific port) — not the primary configuration.
223+
*
224+
* The deco-hosted preview domain (`<site>.deco.site`, via `setDecoSiteHost`)
225+
* is always ADDED on top, so a signed draft grant can preview on deco-operated
226+
* infra without any per-site config.
227+
*
228+
* The sentinel `DECO_ALLOWED_PREVIEW_HOSTS=none` is a KILL SWITCH: it disables
229+
* preview entirely — including the inferred host and the site block — so a bad
230+
* rollout can be stopped without a deploy. It must win over every other source.
202231
*/
203232
function readAllowedHosts(env: EnvLike): string[] {
204233
const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
205234
.split(",")
206235
.map((s) => s.trim().toLowerCase())
207236
.filter(Boolean);
208-
return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
237+
if (fromEnv.includes("none")) return [];
238+
const configured = fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
239+
const siteHost = G.__decoSiteHost;
240+
return siteHost && !configured.includes(siteHost)
241+
? [...configured, siteHost]
242+
: configured;
209243
}
210244

211245
/**
@@ -226,8 +260,10 @@ export function isDraftHostAllowed(
226260

227261
/**
228262
* True when any host is allowed to preview. A cheap read callers use to gate
229-
* BEFORE touching the network, so an unconfigured site is fully inert. The
230-
* per-request host match happens later, in `isDraftHostAllowed`.
263+
* BEFORE touching the network. A site with no config but a resolved name is now
264+
* enabled here (its inferred `<site>.deco.site` host); `DECO_ALLOWED_PREVIEW_HOSTS=none`
265+
* forces it back to fully inert. The per-request host match happens later, in
266+
* `isDraftHostAllowed`.
231267
*/
232268
export function isDraftPreviewEnabled(env?: EnvLike): boolean {
233269
return readAllowedHosts(envOrDeno(env)).length > 0;

engine/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ export {
2222
previewApiOriginForHost,
2323
resolveDraftDecofile,
2424
resolveDraftForRequest,
25+
setDecoSiteHost,
2526
setDraftPreviewHosts,
2627
} from "./decofile/draft.ts";

runtime/mod.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
DRAFT_QUERY_PARAM,
1717
draftPointerFromRequest,
1818
resolveDraftForRequest,
19+
setDecoSiteHost,
1920
} from "../engine/decofile/draft.ts";
2021
import { fromJSON } from "../engine/decofile/fetcher.ts";
2122
import { DRAFT_PREVIEW_KEY } from "./draftBadge.ts";
@@ -86,7 +87,12 @@ export class Deco<TAppManifest extends AppManifest = AppManifest> {
8687
static async init<TAppManifest extends AppManifest = AppManifest>(
8788
opts?: DecoOptions<TAppManifest>,
8889
): Promise<Deco<TAppManifest>> {
89-
const site = opts?.site ?? siteNameFromEnv() ?? randomSiteName();
90+
const resolvedSite = opts?.site ?? siteNameFromEnv();
91+
const site = resolvedSite ?? randomSiteName();
92+
// Allow drafts to preview on the deco-hosted `<site>.deco.site` domain out
93+
// of the box. Skipped for the random dev fallback (an unnamed site is not a
94+
// preview host); a custom production domain is never inferred.
95+
setDecoSiteHost(resolvedSite);
9096
const decofile = opts?.decofile ?? (await getProvider());
9197
const manifest = opts?.manifest ??
9298
(await import(toFileUrl(join(Deno.cwd(), "manifest.gen.ts")).href).then(

0 commit comments

Comments
 (0)