@decocms/blocks 7.28.0-beta.2 → 7.28.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.28.0-beta.2",
3
+ "version": "7.28.0-beta.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -6,23 +6,20 @@
6
6
  * registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
7
7
  * registerLayoutSections, registerSeoSections, and registerSection.
8
8
  */
9
- import {
10
- registerSection,
11
- registerSectionsSync,
12
- } from "./registry";
13
- import {
14
- type CacheableSectionInput,
15
- registerCacheableSections,
16
- registerLayoutSections,
17
- } from "./sectionLoaders";
9
+ import { registerSection, registerSectionsSync } from "./registry";
18
10
  import {
19
11
  type AsyncRenderingConfig,
12
+ getAsyncRenderingConfig,
20
13
  registerEagerSections,
21
14
  registerNeverDeferSections,
22
15
  registerSeoSections,
23
16
  setAsyncRenderingConfig,
24
- getAsyncRenderingConfig,
25
17
  } from "./resolve";
18
+ import {
19
+ type CacheableSectionInput,
20
+ registerCacheableSections,
21
+ registerLayoutSections,
22
+ } from "./sectionLoaders";
26
23
 
27
24
  export interface SectionMetaEntry {
28
25
  eager?: boolean;
@@ -96,8 +93,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
96
93
  // it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
97
94
  // even editor-marked ⚡ sections render eagerly. (The default foldThreshold is
98
95
  // Infinity, so position-based deferral stays off unless a site opts in.)
99
- const existing: Partial<AsyncRenderingConfig> =
100
- getAsyncRenderingConfig() ?? {};
96
+ const existing: Partial<AsyncRenderingConfig> = getAsyncRenderingConfig() ?? {};
101
97
  setAsyncRenderingConfig({
102
98
  ...existing,
103
99
  alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { djb2Hex } from "../sdk/djb2";
2
3
  import {
3
4
  BundledBlockSource,
4
5
  computeRevision,
@@ -8,7 +9,6 @@ import {
8
9
  revisionKey,
9
10
  snapshotKey,
10
11
  } from "./blockSource";
11
- import { djb2Hex } from "../sdk/djb2";
12
12
 
13
13
  describe("computeRevision", () => {
14
14
  it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => {
@@ -1,7 +1,7 @@
1
- import { describe, expect, it } from "vitest";
2
1
  import { execFileSync } from "node:child_process";
3
- import { fileURLToPath } from "node:url";
4
2
  import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
5
 
6
6
  const here = dirname(fileURLToPath(import.meta.url));
7
7
  // esbuild's JS API relies on `new TextEncoder().encode("") instanceof
package/src/cms/client.ts CHANGED
@@ -49,8 +49,6 @@ export {
49
49
  registerSectionsSync,
50
50
  setResolvedComponent,
51
51
  } from "./registry";
52
- export type { SectionLoaderFn } from "./sectionLoaders";
53
- export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins";
54
52
  export type {
55
53
  ActionConfig,
56
54
  AppSchemas,
@@ -72,3 +70,11 @@ export {
72
70
  registerMatcherSchema,
73
71
  registerMatcherSchemas,
74
72
  } from "./schema";
73
+ export type { SectionLoaderFn } from "./sectionLoaders";
74
+ export {
75
+ compose,
76
+ withDevice,
77
+ withMobile,
78
+ withSearchParam,
79
+ withSectionLoader,
80
+ } from "./sectionMixins";
@@ -1,19 +1,16 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
- buildDraftOrigin,
5
4
  clearDraftCache,
6
- DEFAULT_SANDBOX_ORIGIN_SUFFIXES,
5
+ DEFAULT_PREVIEW_API_DOMAINS,
7
6
  isDraftHostAllowed,
8
7
  isDraftPreviewEnabled,
9
8
  parseDraftPointer,
9
+ previewApiOriginForHost,
10
10
  resolveDraftDecofile,
11
11
  } from "./draftSource";
12
12
 
13
- const ENV_ON = {
14
- DECO_DRAFT_PREVIEW_HOST: "preview.example",
15
- DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
16
- };
13
+ const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
17
14
 
18
15
  function jsonResponse(body: unknown, init?: ResponseInit): Response {
19
16
  return new Response(JSON.stringify(body), {
@@ -28,106 +25,99 @@ beforeEach(() => {
28
25
  });
29
26
 
30
27
  describe("parseDraftPointer", () => {
31
- it("parses handle@version", () => {
32
- expect(parseDraftPointer("gimenes-abc123@ff00")).toEqual({
33
- handle: "gimenes-abc123",
34
- version: "ff00",
28
+ it("parses authority@version, lowercasing the authority", () => {
29
+ expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({
30
+ host: "abc.preview-studio.decocms.com",
31
+ version: "FF00",
32
+ });
33
+ });
34
+
35
+ it("keeps an explicit port on the authority", () => {
36
+ expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({
37
+ host: "abc.localhost:60534",
38
+ version: "v1",
35
39
  });
36
40
  });
37
41
 
38
42
  it("rejects more than one @", () => {
39
43
  // A naive split("@") accepts this and silently uses the first two
40
44
  // segments — the exact hole found while spiking the fetch path.
41
- expect(parseDraftPointer("a@b@c")).toBeNull();
45
+ expect(parseDraftPointer("a.example@b@c")).toBeNull();
42
46
  });
43
47
 
44
- it("rejects a handle that could escape the authority", () => {
45
- expect(parseDraftPointer("evil.com/x@v1")).toBeNull();
46
- expect(parseDraftPointer("user:pw@v1")).toBeNull();
47
- expect(parseDraftPointer("a/../b@v1")).toBeNull();
48
- expect(parseDraftPointer(".leading-dot@v1")).toBeNull();
48
+ it("rejects anything that could escape the authority", () => {
49
+ // No scheme, no path, no userinfo — the token carries an authority only,
50
+ // so `javascript:`/`file:`/full URLs fail structurally at parse time.
51
+ expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
52
+ expect(parseDraftPointer("evil.example/x@v1")).toBeNull();
53
+ expect(parseDraftPointer("a.example:80:80@v1")).toBeNull();
54
+ expect(parseDraftPointer("a.example:abc@v1")).toBeNull();
55
+ expect(parseDraftPointer(".leading.dot@v1")).toBeNull();
56
+ expect(parseDraftPointer("bare-label@v1")).toBeNull();
49
57
  });
50
58
 
51
- it("rejects empty halves and missing input", () => {
52
- expect(parseDraftPointer("@v1")).toBeNull();
53
- expect(parseDraftPointer("handle@")).toBeNull();
54
- expect(parseDraftPointer("handle")).toBeNull();
59
+ it("validates the version charset — it becomes a cache key", () => {
60
+ expect(parseDraftPointer("a.example@")).toBeNull();
61
+ expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull();
62
+ expect(parseDraftPointer("a.example@v 1")).toBeNull();
55
63
  expect(parseDraftPointer(null)).toBeNull();
56
- expect(parseDraftPointer("")).toBeNull();
57
64
  });
58
65
  });
59
66
 
60
- describe("buildDraftOrigin", () => {
61
- it("builds https from the configured suffix", () => {
62
- expect(buildDraftOrigin("abc", ".preview-studio.decocms.com")).toBe(
67
+ describe("previewApiOriginForHost", () => {
68
+ it("admits authorities under the default deco domains", () => {
69
+ expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
63
70
  "https://abc.preview-studio.decocms.com",
64
71
  );
72
+ expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
73
+ "https://abc.local.studio.decocms.com",
74
+ );
75
+ expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534");
76
+ });
77
+
78
+ it("rejects hosts outside the domains — the token proposes, config disposes", () => {
79
+ expect(previewApiOriginForHost("evil.example", {})).toBeNull();
80
+ // Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
81
+ // that merely ends with the same characters cannot pass.
82
+ expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull();
83
+ // The domain itself (no label in front) is not a draft host.
84
+ expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull();
65
85
  });
66
86
 
67
- it("uses http for a localhost suffix (local e2e)", () => {
68
- expect(buildDraftOrigin("abc", ".localhost:3200")).toBe("http://abc.localhost:3200");
87
+ it("allows an explicit port only under localhost-ish domains", () => {
88
+ // A public-domain token must not steer the fetch at odd ports.
89
+ expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull();
69
90
  });
70
91
 
71
- it("returns null with no configured suffix — never guesses an origin", () => {
72
- expect(buildDraftOrigin("abc", "")).toBeNull();
92
+ it("honours a configured override instead of the defaults", () => {
93
+ const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
94
+ expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example");
95
+ expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull();
73
96
  });
74
97
  });
75
98
 
76
- describe("isDraftPreviewEnabled", () => {
77
- it("is on iff an allowed host is configured — suffixes have defaults", () => {
78
- // Inverted from the old two-key gate: suffixes now default to the
79
- // deco-operated origins, so the single opt-in is the host allowlist.
80
- // Upgrading the package with no host configured stays fully inert.
99
+ describe("gating", () => {
100
+ it("is on iff an allowed host is configured — API domains have defaults", () => {
81
101
  expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
82
- expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW_HOST: "a.example" })).toBe(true);
83
- expect(
84
- isDraftPreviewEnabled({
85
- DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
86
- }),
87
- ).toBe(false);
88
102
  expect(isDraftPreviewEnabled({})).toBe(false);
89
103
  });
90
- });
91
-
92
- describe("isDraftHostAllowed", () => {
93
- const env = { DECO_DRAFT_PREVIEW_HOST: "fila.vtex.app, localhost:3100" };
94
104
 
95
- it("matches listed hosts verbatim, port included, case-insensitively", () => {
96
- expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(true);
105
+ it("matches request hosts verbatim, port included, case-insensitively", () => {
106
+ const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" };
97
107
  expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true);
98
108
  expect(isDraftHostAllowed("localhost:3100", env)).toBe(true);
99
- });
100
-
101
- it("rejects everything else — the production domain on the same build", () => {
102
109
  expect(isDraftHostAllowed("fila.com.br", env)).toBe(false);
103
- expect(isDraftHostAllowed("localhost", env)).toBe(false); // port matters
110
+ expect(isDraftHostAllowed("localhost", env)).toBe(false);
104
111
  expect(isDraftHostAllowed(null, env)).toBe(false);
105
112
  expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false);
106
113
  });
107
114
  });
108
115
 
109
116
  describe("resolveDraftDecofile", () => {
110
- it("uses the default deco suffixes when none are configured", async () => {
111
- const calls: string[] = [];
112
- await resolveDraftDecofile({
113
- pointer: "abc@v1",
114
- env: { DECO_DRAFT_PREVIEW_HOST: "preview.example" },
115
- fetchImpl: (async (url: string) => {
116
- calls.push(String(url));
117
- throw new Error("unreachable");
118
- }) as unknown as typeof fetch,
119
- });
120
- expect(calls).toEqual(
121
- DEFAULT_SANDBOX_ORIGIN_SUFFIXES.map(
122
- (sfx) => `${sfx.includes("localhost") ? "http" : "https"}://abc${sfx}/_sandbox/decofile`,
123
- ),
124
- );
125
- });
126
-
127
- it("fetches the sandbox decofile and returns it", async () => {
117
+ it("fetches exactly the token's validated origin", async () => {
128
118
  const calls: string[] = [];
129
119
  const blocks = await resolveDraftDecofile({
130
- pointer: "abc@v1",
120
+ pointer: "abc.preview-studio.decocms.com@v1",
131
121
  env: ENV_ON,
132
122
  fetchImpl: (async (url: string) => {
133
123
  calls.push(String(url));
@@ -142,14 +132,27 @@ describe("resolveDraftDecofile", () => {
142
132
  it("is inert without a host allowlist — no fetch at all", async () => {
143
133
  let called = false;
144
134
  const blocks = await resolveDraftDecofile({
145
- pointer: "abc@v1",
146
- env: { DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com" },
135
+ pointer: "abc.preview-studio.decocms.com@v1",
136
+ env: {},
147
137
  fetchImpl: (async () => {
148
138
  called = true;
149
139
  return jsonResponse({});
150
140
  }) as unknown as typeof fetch,
151
141
  });
142
+ expect(blocks).toBeNull();
143
+ expect(called).toBe(false);
144
+ });
152
145
 
146
+ it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
147
+ let called = false;
148
+ const blocks = await resolveDraftDecofile({
149
+ pointer: "abc.evil.example@v1",
150
+ env: ENV_ON,
151
+ fetchImpl: (async () => {
152
+ called = true;
153
+ return jsonResponse({});
154
+ }) as unknown as typeof fetch,
155
+ });
153
156
  expect(blocks).toBeNull();
154
157
  expect(called).toBe(false);
155
158
  });
@@ -160,13 +163,14 @@ describe("resolveDraftDecofile", () => {
160
163
  fetches++;
161
164
  return jsonResponse({ n: fetches });
162
165
  }) as unknown as typeof fetch;
166
+ const P = "abc.preview-studio.decocms.com";
163
167
 
164
- const a = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
165
- const b = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
168
+ const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
169
+ const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
166
170
  expect(fetches).toBe(1);
167
171
  expect(b).toBe(a);
168
172
 
169
- await resolveDraftDecofile({ pointer: "abc@v2", env: ENV_ON, fetchImpl });
173
+ await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl });
170
174
  expect(fetches).toBe(2);
171
175
  });
172
176
 
@@ -176,89 +180,50 @@ describe("resolveDraftDecofile", () => {
176
180
  fetches++;
177
181
  return jsonResponse({ n: fetches });
178
182
  }) as unknown as typeof fetch;
183
+ const P = "abc.preview-studio.decocms.com";
179
184
 
180
185
  for (const v of ["v1", "v2", "v3", "v4"]) {
181
- await resolveDraftDecofile({ pointer: `abc@${v}`, env: ENV_ON, fetchImpl });
186
+ await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl });
182
187
  }
183
188
  expect(fetches).toBe(4);
184
-
185
- // v1 was evicted (cap is 3), so it must re-fetch rather than serve stale.
186
- await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
187
- expect(fetches).toBe(5);
188
-
189
- // v4 is still resident.
190
- await resolveDraftDecofile({ pointer: "abc@v4", env: ENV_ON, fetchImpl });
191
- expect(fetches).toBe(5);
189
+ await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
190
+ expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale
191
+ await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl });
192
+ expect(fetches).toBe(5); // v4 resident
192
193
  });
193
194
 
194
- it("falls through the suffix list until one origin answers", async () => {
195
- // A deployment can serve sandboxes from more than one origin (cluster and
196
- // desktop-link); the handle doesn't say which. The first suffix here is
197
- // unreachable — the resolver must try the next, not give up.
198
- const calls: string[] = [];
199
- const blocks = await resolveDraftDecofile({
200
- pointer: "abc@v1",
201
- env: {
202
- DECO_DRAFT_PREVIEW_HOST: "preview.example",
203
- DECO_SANDBOX_ORIGIN_SUFFIXES: ".dead.example, .alive.example",
204
- },
205
- fetchImpl: (async (url: string) => {
206
- calls.push(String(url));
207
- if (String(url).includes("dead")) throw new Error("ECONNREFUSED");
208
- return jsonResponse({ ok: true });
209
- }) as unknown as typeof fetch,
210
- });
211
- expect(blocks).toEqual({ ok: true });
212
- expect(calls).toEqual([
213
- "https://abc.dead.example/_sandbox/decofile",
214
- "https://abc.alive.example/_sandbox/decofile",
215
- ]);
216
- });
217
-
218
- it("degrades to published on a malformed pointer, without fetching", async () => {
219
- let called = false;
220
- const blocks = await resolveDraftDecofile({
221
- pointer: "a@b@c",
222
- env: ENV_ON,
223
- fetchImpl: (async () => {
224
- called = true;
225
- return jsonResponse({});
226
- }) as unknown as typeof fetch,
227
- });
228
- expect(blocks).toBeNull();
229
- expect(called).toBe(false);
230
- });
231
-
232
- it("degrades to published on a non-2xx sandbox", async () => {
233
- const blocks = await resolveDraftDecofile({
234
- pointer: "abc@v1",
235
- env: ENV_ON,
236
- fetchImpl: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch,
237
- });
238
- expect(blocks).toBeNull();
239
- });
240
-
241
- it("degrades to published when the sandbox is unreachable", async () => {
242
- const blocks = await resolveDraftDecofile({
243
- pointer: "abc@v1",
244
- env: ENV_ON,
245
- fetchImpl: (async () => {
195
+ it("degrades to published on unreachable / non-2xx / unparseable", async () => {
196
+ const P = "abc.preview-studio.decocms.com";
197
+ for (const fetchImpl of [
198
+ async () => {
246
199
  throw new Error("ECONNREFUSED");
247
- }) as unknown as typeof fetch,
248
- });
249
- expect(blocks).toBeNull();
250
- });
251
-
252
- it("degrades to published on an unparseable body", async () => {
253
- const blocks = await resolveDraftDecofile({
254
- pointer: "abc@v1",
255
- env: ENV_ON,
256
- fetchImpl: (async () =>
200
+ },
201
+ async () => new Response("nope", { status: 404 }),
202
+ async () =>
257
203
  new Response("<html>not json</html>", {
258
204
  status: 200,
259
205
  headers: { "content-type": "text/html" },
260
- })) as unknown as typeof fetch,
261
- });
262
- expect(blocks).toBeNull();
206
+ }),
207
+ ]) {
208
+ clearDraftCache();
209
+ expect(
210
+ await resolveDraftDecofile({
211
+ pointer: `${P}@v1`,
212
+ env: ENV_ON,
213
+ fetchImpl: fetchImpl as unknown as typeof fetch,
214
+ }),
215
+ ).toBeNull();
216
+ }
217
+ });
218
+ });
219
+
220
+ // DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
221
+ describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
222
+ it("ships the deco-operated origins, dot-prefixed", () => {
223
+ expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
224
+ ".preview-studio.decocms.com",
225
+ ".local.studio.decocms.com",
226
+ ".localhost",
227
+ ]);
263
228
  });
264
229
  });
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * Draft preview — pull-based decofile override.
3
3
  *
4
- * A Studio sandbox serves the working-tree draft at
4
+ * A Studio preview serves the working-tree draft at
5
5
  * `GET <origin>/_sandbox/decofile`; a production site pulls it and renders its
6
6
  * own real pages against it. This replaces pushing the decofile into a POST
7
7
  * body, which only deco's own runtime honours — Next.js and most frameworks
8
8
  * render on GET only.
9
9
  *
10
- * This module is the framework-agnostic half: pointer parsing, origin
11
- * construction, fetching, and version caching. Binding a resolved draft to a
10
+ * This module is the framework-agnostic half: token parsing, origin
11
+ * validation, fetching, and version caching. Binding a resolved draft to a
12
12
  * request is framework-specific (see `@decocms/nextjs`'s draft wiring) and
13
13
  * reaches this module through {@link setDraftOverrideGetter} — the same
14
14
  * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its
15
15
  * zero-dependency direction.
16
16
  *
17
- * Inert unless `DECO_DRAFT_PREVIEW_HOST` names the request's host: upgrading
17
+ * Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` names the request's host: upgrading
18
18
  * the package must never be enough to start fetching from the network and
19
19
  * rendering unpublished content. Host-scoping (rather than a boolean) exists
20
20
  * because one deployment commonly serves several domains — the preview domain
@@ -22,69 +22,95 @@
22
22
  * ignore a `?__draft=` entirely.
23
23
  */
24
24
 
25
- /** A parsed `<handle>@<version>` draft pointer. */
25
+ /**
26
+ * A parsed `?__draft=` token: `<host[:port]>@<version>`.
27
+ *
28
+ * The token carries the AUTHORITY of the draft content API, never a scheme or
29
+ * path — a full URL would be an SSRF vector, and the scheme is derived from
30
+ * the matched domain instead. Reserved evolution: a future signed token uses a
31
+ * distinguishable prefix (e.g. `s1.`), so this strict two-part parse rejects
32
+ * it cleanly rather than half-reading it.
33
+ */
26
34
  export interface DraftPointer {
27
- /** Sandbox handle — the subdomain under a configured origin suffix. */
28
- handle: string;
29
- /** Content version (the daemon's ETag). Immutable, so safe to cache on. */
35
+ /** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */
36
+ host: string;
37
+ /** Opaque content version (the server's ETag). Immutable → safe cache key. */
30
38
  version: string;
31
39
  }
32
40
 
33
- /**
34
- * Sandbox handles are `[a-z0-9-]`, always leading with an alphanumeric.
35
- *
36
- * Validated BEFORE the handle is interpolated into an authority, so it cannot
37
- * smuggle `/`, `@`, `:` or userinfo into the URL and redirect the fetch at some
38
- * other host.
39
- */
40
- const HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
41
+ /** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
42
+ const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
43
+ const PORT_RE = /^[0-9]{1,5}$/;
44
+ const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
41
45
 
42
46
  /**
43
- * Parse `<handle>@<version>`.
44
- *
45
- * Requires EXACTLY one `@`: a naive `split("@")` accepts `a@b@c` and silently
46
- * uses the first two segments, which is how a malformed pointer sneaks past
47
- * validation. Returns null on anything unexpected — callers fall back to
48
- * published content.
47
+ * Parse `<host[:port]>@<version>`. Null on anything unexpected — callers fall
48
+ * back to published content. Requires EXACTLY one `@`: a naive split accepts
49
+ * `a@b@c` and silently uses the first two segments.
49
50
  */
50
51
  export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
51
52
  if (!raw) return null;
52
53
  const parts = raw.split("@");
53
54
  if (parts.length !== 2) return null;
54
- const [handle, version] = parts;
55
- if (!handle || !version) return null;
56
- if (!HANDLE_RE.test(handle)) return null;
57
- return { handle, version };
55
+ const [authority, version] = parts;
56
+ if (!authority || !version || !VERSION_RE.test(version)) return null;
57
+
58
+ const [host, port, extra] = authority.toLowerCase().split(":");
59
+ if (extra !== undefined) return null;
60
+ if (!host || !HOST_RE.test(host)) return null;
61
+ if (port !== undefined && !PORT_RE.test(port)) return null;
62
+
63
+ return { host: port === undefined ? host : `${host}:${port}`, version };
58
64
  }
59
65
 
60
66
  /**
61
- * Default sandbox origin suffixes — deco-operated domains, so shipping them as
62
- * defaults adds no SSRF surface: the origin is still configuration, never
63
- * caller input. `DECO_SANDBOX_ORIGIN_SUFFIXES` overrides (e.g. to pin a local
64
- * link port: `.localhost:60534`).
67
+ * Domains the draft content API may live under — deco-operated, so shipping
68
+ * them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides
69
+ * the whole list when set. Entries are dot-prefixed suffixes, which guarantees
70
+ * a label boundary on match (`evil-preview-studio.decocms.com` cannot pass).
65
71
  */
66
- export const DEFAULT_SANDBOX_ORIGIN_SUFFIXES = [
72
+ export const DEFAULT_PREVIEW_API_DOMAINS = [
67
73
  ".preview-studio.decocms.com",
68
74
  ".local.studio.decocms.com",
69
75
  ".localhost",
70
76
  ];
71
77
 
72
- /**
73
- * Suffixes are tried in order until one answers — a deployment can serve
74
- * sandboxes from more than one origin (cluster and desktop-link), and the
75
- * handle alone does not say which one it lives under.
76
- */
77
- function readSuffixes(env: Record<string, string | undefined>): string[] {
78
- const configured = (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "")
78
+ function readApiDomains(env: Record<string, string | undefined>): string[] {
79
+ const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "")
79
80
  .split(",")
80
- .map((s) => s.trim())
81
+ .map((s) => s.trim().toLowerCase())
81
82
  .filter(Boolean);
82
- return configured.length > 0 ? configured : DEFAULT_SANDBOX_ORIGIN_SUFFIXES;
83
+ return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS;
83
84
  }
84
85
 
85
- /** Hosts allowed to render drafts (`DECO_DRAFT_PREVIEW_HOST`, comma list). */
86
+ /**
87
+ * Validate the token's authority against the configured domains and derive the
88
+ * fetch origin, or null if no domain admits it.
89
+ *
90
+ * The token proposes, configuration disposes: only the hostname-suffix match
91
+ * decides, so a caller can steer WHICH label under your domains, never which
92
+ * domains. Scheme is derived — http for localhost-ish domains, https
93
+ * otherwise — and an explicit port is allowed only there, so a public-domain
94
+ * token cannot aim at odd ports.
95
+ */
96
+ export function previewApiOriginForHost(
97
+ authority: string,
98
+ env?: Record<string, string | undefined>,
99
+ ): string | null {
100
+ const [host, port] = authority.toLowerCase().split(":");
101
+ if (!host) return null;
102
+ const domain = readApiDomains(envOrProcess(env)).find(
103
+ (d) => host.length > d.length && host.endsWith(d),
104
+ );
105
+ if (!domain) return null;
106
+ const local = domain.includes("localhost");
107
+ if (port !== undefined && !local) return null;
108
+ return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
109
+ }
110
+
111
+ /** Hosts allowed to render drafts (`DECO_ALLOWED_PREVIEW_HOSTS`, comma list). */
86
112
  function readAllowedHosts(env: Record<string, string | undefined>): string[] {
87
- return (env.DECO_DRAFT_PREVIEW_HOST ?? "")
113
+ return (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
88
114
  .split(",")
89
115
  .map((s) => s.trim().toLowerCase())
90
116
  .filter(Boolean);
@@ -93,9 +119,9 @@ function readAllowedHosts(env: Record<string, string | undefined>): string[] {
93
119
  /**
94
120
  * Whether `host` (as seen on the request) may render drafts.
95
121
  *
96
- * Compared against `DECO_DRAFT_PREVIEW_HOST` verbatim, port included — local
97
- * dev is `localhost:3100`, not `localhost`. The header is spoofable by a
98
- * direct-to-origin request, but the sandbox handle is the actual capability;
122
+ * Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included —
123
+ * local dev is `localhost:3100`, not `localhost`. The header is spoofable by a
124
+ * direct-to-origin request, but the draft id is the actual capability;
99
125
  * host-scoping bounds blast radius (production domains stay inert), it is not
100
126
  * a secret.
101
127
  */
@@ -104,8 +130,17 @@ export function isDraftHostAllowed(
104
130
  env?: Record<string, string | undefined>,
105
131
  ): boolean {
106
132
  if (!host) return false;
107
- const e = envOrProcess(env);
108
- return readAllowedHosts(e).includes(host.trim().toLowerCase());
133
+ return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase());
134
+ }
135
+
136
+ /**
137
+ * True when any host is allowed to preview. A plain env read — callers use it
138
+ * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
139
+ * unconfigured site never loses static/ISR rendering. The per-request host
140
+ * match happens later, in `isDraftHostAllowed`.
141
+ */
142
+ export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
143
+ return readAllowedHosts(envOrProcess(env)).length > 0;
109
144
  }
110
145
 
111
146
  function envOrProcess(
@@ -119,31 +154,14 @@ function envOrProcess(
119
154
  }
120
155
 
121
156
  /**
122
- * Build the sandbox origin for a handle under ONE configured suffix.
123
- *
124
- * The origin comes from configuration, never from caller input, so there is no
125
- * SSRF surface to defend and no allowlist to keep correct. A `localhost`
126
- * suffix (local e2e) speaks http; everything else is https.
127
- */
128
- export function buildDraftOrigin(handle: string, suffix: string): string | null {
129
- if (!suffix) return null;
130
- if (!HANDLE_RE.test(handle)) return null;
131
- const scheme = suffix.includes("localhost") ? "http" : "https";
132
- return `${scheme}://${handle}${suffix}`;
133
- }
134
-
135
- /**
136
- * Version cache.
137
- *
138
- * Bounded on purpose: a decofile is routinely multi-megabyte, so an unbounded
139
- * map keyed by version would grow with every save until the process died.
140
- * Keyed by version (content-addressed), so a hit is always correct.
157
+ * Version cache. Bounded on purpose: a decofile is routinely multi-megabyte,
158
+ * so an unbounded map keyed by version would grow with every save until the
159
+ * process died. Content-addressed, so a hit is always correct.
141
160
  */
142
161
  const MAX_CACHED_VERSIONS = 3;
143
162
  const byVersion = new Map<string, Record<string, unknown>>();
144
163
 
145
164
  function cacheDraft(version: string, blocks: Record<string, unknown>): void {
146
- // Re-insert to make this the most recently used key.
147
165
  byVersion.delete(version);
148
166
  byVersion.set(version, blocks);
149
167
  while (byVersion.size > MAX_CACHED_VERSIONS) {
@@ -159,7 +177,7 @@ export function clearDraftCache(): void {
159
177
  }
160
178
 
161
179
  export interface ResolveDraftOptions {
162
- /** Raw `<handle>@<version>` pointer from the request. */
180
+ /** Raw `<host[:port]>@<version>` token from the request. */
163
181
  pointer: string | null | undefined;
164
182
  /** Defaults to `process.env`. */
165
183
  env?: Record<string, string | undefined>;
@@ -168,14 +186,11 @@ export interface ResolveDraftOptions {
168
186
  }
169
187
 
170
188
  /**
171
- * Resolve a draft pointer to a decofile, or null to render published content.
189
+ * Resolve a draft token to a decofile, or null to render published content.
172
190
  *
173
- * Null on every failure path — disabled, malformed pointer, unreachable
174
- * sandbox, non-2xx — because a draft that cannot be resolved must degrade to
175
- * published rather than break the page. Callers that need to *tell the user*
176
- * the draft failed should check {@link isDraftPreviewEnabled} and surface it
177
- * themselves; silently showing published content while the user believes they
178
- * are looking at a draft is the failure mode worth avoiding.
191
+ * Null on every failure path — disabled, malformed token, disallowed origin,
192
+ * unreachable, non-2xx — because a draft that cannot be resolved must degrade
193
+ * to published rather than break the page.
179
194
  */
180
195
  export async function resolveDraftDecofile(
181
196
  options: ResolveDraftOptions,
@@ -189,44 +204,27 @@ export async function resolveDraftDecofile(
189
204
  const cached = byVersion.get(parsed.version);
190
205
  if (cached) return cached;
191
206
 
192
- const doFetch = options.fetchImpl ?? fetch;
193
- // Suffixes are tried in order; the first that answers with parseable JSON
194
- // wins. A miss on one origin (unreachable, non-2xx, garbage) is expected —
195
- // the handle only exists under one of them — so every failure falls through
196
- // to the next rather than aborting the resolve.
197
- for (const suffix of readSuffixes(env)) {
198
- const origin = buildDraftOrigin(parsed.handle, suffix);
199
- if (!origin) continue;
200
-
201
- let res: Response;
202
- try {
203
- res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
204
- } catch {
205
- continue;
206
- }
207
- if (!res.ok) continue;
207
+ const origin = previewApiOriginForHost(parsed.host, env);
208
+ if (!origin) return null;
208
209
 
209
- let blocks: Record<string, unknown>;
210
- try {
211
- blocks = (await res.json()) as Record<string, unknown>;
212
- } catch {
213
- continue;
214
- }
210
+ const doFetch = options.fetchImpl ?? fetch;
211
+ let res: Response;
212
+ try {
213
+ res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
214
+ } catch {
215
+ return null;
216
+ }
217
+ if (!res.ok) return null;
215
218
 
216
- cacheDraft(parsed.version, blocks);
217
- return blocks;
219
+ let blocks: Record<string, unknown>;
220
+ try {
221
+ blocks = (await res.json()) as Record<string, unknown>;
222
+ } catch {
223
+ return null;
218
224
  }
219
- return null;
220
- }
221
225
 
222
- /**
223
- * True when any host is allowed to preview. A plain env read — callers use it
224
- * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
225
- * unconfigured site never loses static/ISR rendering. The per-request host
226
- * match happens later, in `isDraftHostAllowed`.
227
- */
228
- export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
229
- return readAllowedHosts(envOrProcess(env)).length > 0;
226
+ cacheDraft(parsed.version, blocks);
227
+ return blocks;
230
228
  }
231
229
 
232
230
  // ---------------------------------------------------------------------------
@@ -242,10 +240,8 @@ let getDraftOverride: DraftOverrideGetter = () => undefined;
242
240
  *
243
241
  * Binding a value to "the current request" is framework-specific and `blocks`
244
242
  * must not know about any framework: `@decocms/nextjs` backs this with React
245
- * `cache()` (App Router has no AsyncLocalStorage request scope of its own —
246
- * `RequestContext.run` is never entered there). Bindings that do have an ALS
247
- * request scope can back it with that instead. Never called → returns
248
- * undefined → `loadBlocks()` behaves exactly as before.
243
+ * `cache()` (App Router has no AsyncLocalStorage request scope of its own).
244
+ * Never called → returns undefined → `loadBlocks()` behaves exactly as before.
249
245
  */
250
246
  export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
251
247
  getDraftOverride = getter;
package/src/cms/index.ts CHANGED
@@ -14,13 +14,13 @@ export {
14
14
  } from "./blockSource";
15
15
  export type { DraftPointer, ResolveDraftOptions } from "./draftSource";
16
16
  export {
17
- buildDraftOrigin,
18
17
  clearDraftCache,
19
- DEFAULT_SANDBOX_ORIGIN_SUFFIXES,
18
+ DEFAULT_PREVIEW_API_DOMAINS,
20
19
  getRequestDraftOverride,
21
20
  isDraftHostAllowed,
22
21
  isDraftPreviewEnabled,
23
22
  parseDraftPointer,
23
+ previewApiOriginForHost,
24
24
  resolveDraftDecofile,
25
25
  setDraftOverrideGetter,
26
26
  } from "./draftSource";
@@ -82,12 +82,12 @@ describe("matchPath", () => {
82
82
  });
83
83
 
84
84
  it("matches with an optional prefix before a literal segment", () => {
85
- expect(
86
- matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023"),
87
- ).toEqual({ "0": "destaques-2023" });
88
- expect(
89
- matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"),
90
- ).toEqual({ "0": "destaques-2023" });
85
+ expect(matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023")).toEqual({
86
+ "0": "destaques-2023",
87
+ });
88
+ expect(matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023")).toEqual({
89
+ "0": "destaques-2023",
90
+ });
91
91
  });
92
92
 
93
93
  it("matches an optional suffix group present and absent", () => {
@@ -112,9 +112,7 @@ describe("matchPath", () => {
112
112
  // biome-ignore lint/performance/noDelete: restoring exact global state
113
113
  delete g.URLPattern;
114
114
  try {
115
- expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(
116
- /URLPattern.*Node\.js >= 24/s,
117
- );
115
+ expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s);
118
116
  } finally {
119
117
  if (saved !== undefined) g.URLPattern = saved;
120
118
  }
@@ -1,11 +1,11 @@
1
- import { describe, expect, it, beforeEach } from "vitest";
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
2
  import {
3
- registerSection,
4
- registerSectionsSync,
3
+ getResolvedComponent,
5
4
  getSection,
6
5
  getSectionOptions,
7
6
  getSyncComponent,
8
- getResolvedComponent,
7
+ registerSection,
8
+ registerSectionsSync,
9
9
  } from "./registry";
10
10
 
11
11
  // Reset globalThis.__deco between tests to avoid cross-test pollution
@@ -34,44 +34,36 @@ describe("registerSection + getSection", () => {
34
34
  describe("registerSection with options", () => {
35
35
  it("stores loadingFallback in section options", () => {
36
36
  const fallback = () => null;
37
- registerSection(
38
- "site/sections/Shelf.tsx",
39
- async () => ({ default: () => null }),
40
- { loadingFallback: fallback },
41
- );
37
+ registerSection("site/sections/Shelf.tsx", async () => ({ default: () => null }), {
38
+ loadingFallback: fallback,
39
+ });
42
40
 
43
41
  const opts = getSectionOptions("site/sections/Shelf.tsx");
44
42
  expect(opts?.loadingFallback).toBe(fallback);
45
43
  });
46
44
 
47
45
  it("stores clientOnly flag in section options", () => {
48
- registerSection(
49
- "site/sections/Analytics.tsx",
50
- async () => ({ default: () => null }),
51
- { clientOnly: true },
52
- );
46
+ registerSection("site/sections/Analytics.tsx", async () => ({ default: () => null }), {
47
+ clientOnly: true,
48
+ });
53
49
 
54
50
  const opts = getSectionOptions("site/sections/Analytics.tsx");
55
51
  expect(opts?.clientOnly).toBe(true);
56
52
  });
57
53
 
58
54
  it("clientOnly defaults to undefined when not set", () => {
59
- registerSection(
60
- "site/sections/Normal.tsx",
61
- async () => ({ default: () => null }),
62
- { loadingFallback: () => null },
63
- );
55
+ registerSection("site/sections/Normal.tsx", async () => ({ default: () => null }), {
56
+ loadingFallback: () => null,
57
+ });
64
58
 
65
59
  const opts = getSectionOptions("site/sections/Normal.tsx");
66
60
  expect(opts?.clientOnly).toBeUndefined();
67
61
  });
68
62
 
69
63
  it("clientOnly false is preserved", () => {
70
- registerSection(
71
- "site/sections/Explicit.tsx",
72
- async () => ({ default: () => null }),
73
- { clientOnly: false },
74
- );
64
+ registerSection("site/sections/Explicit.tsx", async () => ({ default: () => null }), {
65
+ clientOnly: false,
66
+ });
75
67
 
76
68
  const opts = getSectionOptions("site/sections/Explicit.tsx");
77
69
  expect(opts?.clientOnly).toBe(false);
@@ -1,8 +1,6 @@
1
1
  import type { ComponentType } from "react";
2
2
 
3
- export type OnBeforeResolveProps = (
4
- props: Record<string, unknown>,
5
- ) => Record<string, unknown>;
3
+ export type OnBeforeResolveProps = (props: Record<string, unknown>) => Record<string, unknown>;
6
4
 
7
5
  export type SectionModule = {
8
6
  default: ComponentType<any>;
@@ -35,8 +33,7 @@ if (!G.__deco.sectionRegistry) G.__deco.sectionRegistry = {};
35
33
  if (!G.__deco.sectionOptions) G.__deco.sectionOptions = {};
36
34
  if (!G.__deco.resolvedComponents) G.__deco.resolvedComponents = {};
37
35
  if (!G.__deco.syncComponents) G.__deco.syncComponents = {};
38
- if (!G.__deco.onBeforeResolvePropsRegistry)
39
- G.__deco.onBeforeResolvePropsRegistry = {};
36
+ if (!G.__deco.onBeforeResolvePropsRegistry) G.__deco.onBeforeResolvePropsRegistry = {};
40
37
 
41
38
  const registry: Record<string, RegistryEntry> = G.__deco.sectionRegistry;
42
39
  const sectionOptions: Record<string, SectionOptions> = G.__deco.sectionOptions;
@@ -194,9 +191,7 @@ export function registerSectionsSync(sections: Record<string, SyncSectionEntry>)
194
191
  ];
195
192
  const component =
196
193
  typeof raw === "function" ||
197
- (raw != null &&
198
- typeof raw === "object" &&
199
- REACT_WRAPPERS.includes((raw as any).$$typeof))
194
+ (raw != null && typeof raw === "object" && REACT_WRAPPERS.includes((raw as any).$$typeof))
200
195
  ? raw
201
196
  : undefined;
202
197
  if (!component) {
@@ -229,17 +224,12 @@ export function getSyncComponent(key: string): ComponentType<any> | undefined {
229
224
  * BEFORE the resolution engine resolves them. Use to extract metadata from
230
225
  * resolvable structures that would be lost after resolution.
231
226
  */
232
- export function registerOnBeforeResolveProps(
233
- sectionKey: string,
234
- fn: OnBeforeResolveProps,
235
- ): void {
227
+ export function registerOnBeforeResolveProps(sectionKey: string, fn: OnBeforeResolveProps): void {
236
228
  onBeforeResolvePropsRegistry[sectionKey] = fn;
237
229
  }
238
230
 
239
231
  /** Get the registered onBeforeResolveProps for a section, if any. */
240
- export function getOnBeforeResolveProps(
241
- sectionKey: string,
242
- ): OnBeforeResolveProps | undefined {
232
+ export function getOnBeforeResolveProps(sectionKey: string): OnBeforeResolveProps | undefined {
243
233
  return onBeforeResolvePropsRegistry[sectionKey];
244
234
  }
245
235
 
@@ -20,6 +20,10 @@ vi.mock("./registry", () => ({
20
20
  getOnBeforeResolveProps: vi.fn(),
21
21
  }));
22
22
 
23
+ import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
24
+ import { findPageByPath } from "./loader";
25
+ import { getSection } from "./registry";
26
+ import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
23
27
  import {
24
28
  clearCommerceLoaders,
25
29
  DEFAULT_FOLD_THRESHOLD,
@@ -29,8 +33,8 @@ import {
29
33
  registerCommerceLoader,
30
34
  registerEagerSections,
31
35
  registerNeverDeferSections,
32
- resolveDeferredSectionFull,
33
36
  resolveDecoPage,
37
+ resolveDeferredSectionFull,
34
38
  resolvePageSeoBlock,
35
39
  resolveSectionsList,
36
40
  resolveValue,
@@ -39,10 +43,6 @@ import {
39
43
  WELL_KNOWN_TYPES,
40
44
  } from "./resolve";
41
45
  import { runSingleSectionLoader } from "./sectionLoaders";
42
- import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
43
- import { findPageByPath } from "./loader";
44
- import { getSection } from "./registry";
45
- import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
46
46
 
47
47
  describe("resolveDeferredSectionFull", () => {
48
48
  it("resolves a deferred section and preserves index", async () => {
@@ -217,14 +217,10 @@ describe("commerce loader auto-injects URL search params as props", () => {
217
217
  return null;
218
218
  });
219
219
 
220
- await resolveValue(
221
- { __resolveType: KEY, slug: "sabonete" },
222
- undefined,
223
- {
224
- url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
225
- path: "/produto/sabonete/p",
226
- },
227
- );
220
+ await resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, {
221
+ url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
222
+ path: "/produto/sabonete/p",
223
+ });
228
224
 
229
225
  expect(calls).toHaveLength(1);
230
226
  expect(calls[0]).toMatchObject({
@@ -243,11 +239,10 @@ describe("commerce loader auto-injects URL search params as props", () => {
243
239
  return null;
244
240
  });
245
241
 
246
- await resolveValue(
247
- { __resolveType: KEY, skuId: "cms-locked-sku" },
248
- undefined,
249
- { url: "https://store.com/p?skuId=url-value", path: "/p" },
250
- );
242
+ await resolveValue({ __resolveType: KEY, skuId: "cms-locked-sku" }, undefined, {
243
+ url: "https://store.com/p?skuId=url-value",
244
+ path: "/p",
245
+ });
251
246
 
252
247
  expect(calls[0]?.skuId).toBe("cms-locked-sku");
253
248
  });
@@ -358,7 +353,10 @@ describe("commerce loader resolves legacy .ts-suffixed resolveType", () => {
358
353
  await resolveValue(
359
354
  { __resolveType: "shopify/loaders/ProductDetailsPage.ts", slug: "oversize-t-shirt-123" },
360
355
  undefined,
361
- { url: "https://store.com/products/oversize-t-shirt-123", path: "/products/oversize-t-shirt-123" },
356
+ {
357
+ url: "https://store.com/products/oversize-t-shirt-123",
358
+ path: "/products/oversize-t-shirt-123",
359
+ },
362
360
  );
363
361
 
364
362
  expect(calls).toHaveLength(1);
@@ -509,9 +507,9 @@ describe("isEagerRequest — programmatic fetch detection", () => {
509
507
  });
510
508
 
511
509
  it("falls back to matcherCtx.headers when no Request is present", () => {
512
- expect(
513
- isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } }),
514
- ).toBe(true);
510
+ expect(isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } })).toBe(
511
+ true,
512
+ );
515
513
  });
516
514
 
517
515
  it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => {
@@ -1,10 +1,3 @@
1
- import {
2
- type ActionConfig,
3
- inferLoaderTags,
4
- type LoaderConfig,
5
- registerActionSchemas,
6
- registerLoaderSchemas,
7
- } from "./schema";
8
1
  import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
9
2
  import { getMeter, MetricNames, withTracing } from "../middleware/observability";
10
3
  import { djb2Hex } from "../sdk/djb2";
@@ -13,6 +6,13 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
13
6
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
14
7
  import { findPageByPath, loadBlocks } from "./loader";
15
8
  import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
9
+ import {
10
+ type ActionConfig,
11
+ inferLoaderTags,
12
+ type LoaderConfig,
13
+ registerActionSchemas,
14
+ registerLoaderSchemas,
15
+ } from "./schema";
16
16
  import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
17
17
 
18
18
  // globalThis-backed: share state across Vite server function split modules
@@ -227,7 +227,7 @@ function isEagerSection(key: string): boolean {
227
227
  * during hydration (search filters, configurators, etc.).
228
228
  */
229
229
  export function registerNeverDeferSections(keys: string[]): void {
230
- const set: Set<string> = G.__deco.neverDeferSectionKeys ??= new Set();
230
+ const set: Set<string> = (G.__deco.neverDeferSectionKeys ??= new Set());
231
231
  for (const k of keys) set.add(k);
232
232
  }
233
233
 
@@ -342,8 +342,7 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean {
342
342
  */
343
343
  function isProgrammaticFetch(ctx?: MatcherContext): boolean {
344
344
  if (ctx?.isClientNavigation) return false;
345
- const dest = ctx?.request?.headers.get("sec-fetch-dest") ??
346
- ctx?.headers?.["sec-fetch-dest"];
345
+ const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? ctx?.headers?.["sec-fetch-dest"];
347
346
  return dest === "empty";
348
347
  }
349
348
 
@@ -354,8 +353,7 @@ function isProgrammaticFetch(ctx?: MatcherContext): boolean {
354
353
  * to gate both section deferral and page-SEO commerce resolution.
355
354
  */
356
355
  export function isEagerRequest(ctx?: MatcherContext): boolean {
357
- return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) ||
358
- isProgrammaticFetch(ctx);
356
+ return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || isProgrammaticFetch(ctx);
359
357
  }
360
358
 
361
359
  /**
@@ -735,7 +733,9 @@ function evaluateVariantRule(
735
733
  const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct);
736
734
  if (already) return already.value;
737
735
 
738
- const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find((f) => f.name === meta.name);
736
+ const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
737
+ (f) => f.name === meta.name,
738
+ );
739
739
  // pct === -1 marks a classic-deco segment without a fingerprint — honor it
740
740
  // (stay sticky) instead of re-rolling. A stale fingerprint re-rolls.
741
741
  const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct);
@@ -1264,9 +1264,7 @@ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean {
1264
1264
  * serialized into the HTML. Lightweight literal props (title, description,
1265
1265
  * canonical, …) are preserved.
1266
1266
  */
1267
- function stripCommerceLoaderProps(
1268
- rawProps: Record<string, unknown>,
1269
- ): Record<string, unknown> {
1267
+ function stripCommerceLoaderProps(rawProps: Record<string, unknown>): Record<string, unknown> {
1270
1268
  const out: Record<string, unknown> = {};
1271
1269
  for (const [k, v] of Object.entries(rawProps)) {
1272
1270
  if (resolvesToCommerceLoader(v)) continue;
@@ -1785,8 +1783,7 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown
1785
1783
  const configJsonLD = props.configJsonLD as
1786
1784
  | { ignoreStructuredData?: boolean; removeVideos?: boolean }
1787
1785
  | undefined;
1788
- const ignore =
1789
- props.ignoreStructuredData === true || configJsonLD?.ignoreStructuredData === true;
1786
+ const ignore = props.ignoreStructuredData === true || configJsonLD?.ignoreStructuredData === true;
1790
1787
  if (ignore || isEmpty) return;
1791
1788
 
1792
1789
  seo.jsonLDs = [
package/src/cms/schema.ts CHANGED
@@ -1089,10 +1089,7 @@ export interface ComposeMetaOptions {
1089
1089
  framework?: string;
1090
1090
  }
1091
1091
 
1092
- export function composeMeta(
1093
- siteMeta: MetaResponse,
1094
- options?: ComposeMetaOptions,
1095
- ): MetaResponse {
1092
+ export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions): MetaResponse {
1096
1093
  // Idempotency guard. composeMeta is NOT structurally idempotent — it appends
1097
1094
  // the framework section refs to `root.sections.anyOf` (and `__SECTION_REF__`),
1098
1095
  // so composing an already-composed meta a second time duplicates those refs.
@@ -17,8 +17,7 @@ import {
17
17
  // request must opt in by setting this flag — and `compose` must propagate
18
18
  // it whenever any input has it.
19
19
  const isRequestDependent = (fn: unknown): boolean =>
20
- typeof fn === "function" &&
21
- (fn as { __requestDependent?: boolean }).__requestDependent === true;
20
+ typeof fn === "function" && (fn as { __requestDependent?: boolean }).__requestDependent === true;
22
21
 
23
22
  const makeReq = (url = "https://store.example/foo?q=hello") =>
24
23
  new Request(url, { headers: { "user-agent": "vitest" } });
@@ -87,12 +86,10 @@ describe("withSectionLoader", () => {
87
86
 
88
87
  it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
89
88
  const seen: Array<Record<string, unknown>> = [];
90
- const sectionLoader = vi.fn(
91
- async (props: Record<string, unknown>) => {
92
- seen.push({ ...props });
93
- return { ...props, sectionLoaderRan: true };
94
- },
95
- );
89
+ const sectionLoader = vi.fn(async (props: Record<string, unknown>) => {
90
+ seen.push({ ...props });
91
+ return { ...props, sectionLoaderRan: true };
92
+ });
96
93
 
97
94
  const composed = compose(
98
95
  withSearchParam(),
@@ -154,7 +151,14 @@ describe("request-dependent tagging (#206)", () => {
154
151
  });
155
152
 
156
153
  it("compose does NOT set the flag when no input is request-dependent", () => {
157
- expect(isRequestDependent(compose(async (p) => p, async (p) => p))).toBe(false);
154
+ expect(
155
+ isRequestDependent(
156
+ compose(
157
+ async (p) => p,
158
+ async (p) => p,
159
+ ),
160
+ ),
161
+ ).toBe(false);
158
162
  });
159
163
 
160
164
  it("empty compose() is not request-dependent", () => {
@@ -135,9 +135,7 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
135
135
  * });
136
136
  * ```
137
137
  */
138
- export function withSectionLoader(
139
- modImport: () => Promise<unknown>,
140
- ): SectionLoaderFn {
138
+ export function withSectionLoader(modImport: () => Promise<unknown>): SectionLoaderFn {
141
139
  return async (props, req, ctx) => {
142
140
  const mod = (await modImport()) as { loader?: unknown } | undefined;
143
141
  const loader = mod?.loader;