@decocms/tanstack 7.20.9 → 7.21.0

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/tanstack",
3
- "version": "7.20.9",
3
+ "version": "7.21.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -24,9 +24,9 @@
24
24
  "lint:unused": "knip"
25
25
  },
26
26
  "dependencies": {
27
- "@decocms/blocks": "7.20.9",
28
- "@decocms/blocks-admin": "7.20.9",
29
- "@decocms/blocks-cli": "7.20.9",
27
+ "@decocms/blocks": "7.21.0",
28
+ "@decocms/blocks-admin": "7.21.0",
29
+ "@decocms/blocks-cli": "7.21.0",
30
30
  "@deco-cx/warp-node": "^0.3.16",
31
31
  "fast-json-patch": "^3.1.0",
32
32
  "ws": "^8.18.0"
@@ -1,10 +1,21 @@
1
1
  import { setBlocks } from "@decocms/blocks/cms";
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import { buildGeoCacheParam, createDecoWorkerEntry, detectLocationMatcher, injectGeoCookies } from "./workerEntry";
3
+ import {
4
+ buildGeoCacheParam,
5
+ createDecoWorkerEntry,
6
+ DECO_ADMIN_FRAME_ANCESTORS,
7
+ DEFAULT_FRAME_ANCESTORS_CSP,
8
+ DEFAULT_SECURITY_HEADERS,
9
+ detectLocationMatcher,
10
+ injectGeoCookies,
11
+ } from "./workerEntry";
4
12
  import { __resetKvHydrationStateForTests } from "./kvHydration";
5
13
 
6
14
  const EMPTY_ENV = {};
7
- const MOCK_CTX = { waitUntil: (_p: Promise<unknown>) => {} };
15
+ const MOCK_CTX = {
16
+ waitUntil: (_p: Promise<unknown>) => {},
17
+ passThroughOnException: () => {},
18
+ };
8
19
  const MOCK_SERVER_ENTRY = {
9
20
  fetch: async (_req: Request) => new Response("page content", { status: 200 }),
10
21
  };
@@ -313,3 +324,109 @@ describe("CMS redirects", () => {
313
324
  expect(res2.headers.get("Location")).toBe("/v2");
314
325
  });
315
326
  });
327
+
328
+ describe("security headers — frame-ancestors default", () => {
329
+ afterEach(() => {
330
+ __resetKvHydrationStateForTests();
331
+ setBlocks({});
332
+ });
333
+
334
+ const HTML_SERVER_ENTRY = {
335
+ fetch: async (_req: Request) =>
336
+ new Response("<html>hi</html>", {
337
+ status: 200,
338
+ headers: { "content-type": "text/html; charset=utf-8" },
339
+ }),
340
+ };
341
+
342
+ it("does NOT ship X-Frame-Options (frame-ancestors supersedes it)", () => {
343
+ expect(DEFAULT_SECURITY_HEADERS["X-Frame-Options"]).toBeUndefined();
344
+ });
345
+
346
+ it("allows the deco studio to embed the storefront by default", () => {
347
+ expect(DEFAULT_FRAME_ANCESTORS_CSP).toContain("frame-ancestors");
348
+ expect(DECO_ADMIN_FRAME_ANCESTORS).toContain("https://studio.decocms.com");
349
+ expect(DECO_ADMIN_FRAME_ANCESTORS).toContain("https://*.deco.studio");
350
+ expect(DECO_ADMIN_FRAME_ANCESTORS).toContain("'self'");
351
+ });
352
+
353
+ it("applies the default frame-ancestors CSP on HTML responses and drops X-Frame-Options", async () => {
354
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY);
355
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
356
+ expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_FRAME_ANCESTORS_CSP);
357
+ expect(res.headers.get("X-Frame-Options")).toBeNull();
358
+ // Other defaults are still present.
359
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
360
+ });
361
+
362
+ it("lets a site override the frame-ancestors CSP via securityHeaders", async () => {
363
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
364
+ securityHeaders: { "Content-Security-Policy": "frame-ancestors 'none'" },
365
+ });
366
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
367
+ expect(res.headers.get("Content-Security-Policy")).toBe("frame-ancestors 'none'");
368
+ });
369
+
370
+ it("emits the full site CSP report-only alongside the enforced frame-ancestors", async () => {
371
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
372
+ csp: ["default-src 'self'", "img-src 'self' https:"],
373
+ });
374
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
375
+ expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_FRAME_ANCESTORS_CSP);
376
+ expect(res.headers.get("Content-Security-Policy-Report-Only")).toBe(
377
+ "default-src 'self'; img-src 'self' https:",
378
+ );
379
+ });
380
+
381
+ it("omits all security headers when securityHeaders is false", async () => {
382
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, { securityHeaders: false });
383
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
384
+ expect(res.headers.get("Content-Security-Policy")).toBeNull();
385
+ expect(res.headers.get("X-Content-Type-Options")).toBeNull();
386
+ });
387
+ });
388
+
389
+ describe("POST /_cache/purge-loaders", () => {
390
+ afterEach(() => {
391
+ __resetKvHydrationStateForTests();
392
+ setBlocks({});
393
+ });
394
+
395
+ const purge = (env: Record<string, unknown>, headers: Record<string, string> = {}) => {
396
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
397
+ return worker.fetch(
398
+ new Request("https://example.com/_cache/purge-loaders", { method: "POST", headers }),
399
+ env,
400
+ MOCK_CTX,
401
+ );
402
+ };
403
+
404
+ it("401s without a valid Bearer token", async () => {
405
+ const res = await purge({ PURGE_TOKEN: "secret" });
406
+ expect(res.status).toBe(401);
407
+ });
408
+
409
+ it("401s with the wrong token", async () => {
410
+ const res = await purge({ PURGE_TOKEN: "secret" }, { Authorization: "Bearer nope" });
411
+ expect(res.status).toBe(401);
412
+ });
413
+
414
+ it("clears the loader cache with a valid token", async () => {
415
+ const res = await purge({ PURGE_TOKEN: "secret" }, { Authorization: "Bearer secret" });
416
+ expect(res.status).toBe(200);
417
+ expect(await res.json()).toEqual({ cleared: true, scope: "isolate" });
418
+ });
419
+
420
+ it("404s when purge is disabled (purgeTokenEnv: false)", async () => {
421
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
422
+ observability: false,
423
+ purgeTokenEnv: false,
424
+ });
425
+ const res = await worker.fetch(
426
+ new Request("https://example.com/_cache/purge-loaders", { method: "POST" }),
427
+ EMPTY_ENV,
428
+ MOCK_CTX,
429
+ );
430
+ expect(res.status).toBe(404);
431
+ });
432
+ });
@@ -38,6 +38,7 @@ import {
38
38
  runSingleSectionLoader,
39
39
  } from "@decocms/blocks/cms";
40
40
  import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
41
+ import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
41
42
  import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
42
43
  import {
43
44
  type CacheProfileName,
@@ -331,10 +332,13 @@ export interface DecoWorkerEntryOptions {
331
332
  * Security headers appended to every SSR response (HTML pages).
332
333
  * Pass `false` to disable entirely.
333
334
  *
334
- * Default headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
335
- * Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy.
335
+ * Default headers: X-Content-Type-Options, Referrer-Policy,
336
+ * Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy,
337
+ * and a `Content-Security-Policy: frame-ancestors` directive that lets the
338
+ * deco admin/studio embed the storefront (see DEFAULT_FRAME_ANCESTORS_CSP).
336
339
  *
337
- * Custom entries are merged with defaults (custom values take precedence).
340
+ * Custom entries are merged with defaults (custom values take precedence) —
341
+ * pass `Content-Security-Policy` here to override the default frame-ancestors.
338
342
  *
339
343
  * @default DEFAULT_SECURITY_HEADERS
340
344
  */
@@ -621,11 +625,19 @@ const ONE_YEAR = 31536000;
621
625
 
622
626
  /**
623
627
  * Sensible security headers for any production storefront.
624
- * CSP is intentionally not included — it's site-specific (third-party script domains).
628
+ *
629
+ * Framing protection is handled by a default `Content-Security-Policy:
630
+ * frame-ancestors` directive (see DEFAULT_FRAME_ANCESTORS_CSP), NOT
631
+ * `X-Frame-Options`. CSP level 3 `frame-ancestors` supersedes X-Frame-Options
632
+ * in every modern browser, and — unlike the all-or-nothing SAMEORIGIN value —
633
+ * it can allow the deco admin/studio to embed the storefront for live preview
634
+ * while still blocking arbitrary third-party framing (clickjacking).
635
+ *
636
+ * Full CSP (script-src etc.) is intentionally not included here — it's
637
+ * site-specific (third-party script domains) and passed via the `csp` option.
625
638
  */
626
639
  export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
627
640
  "X-Content-Type-Options": "nosniff",
628
- "X-Frame-Options": "SAMEORIGIN",
629
641
  "Referrer-Policy": "strict-origin-when-cross-origin",
630
642
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
631
643
  "X-XSS-Protection": "1; mode=block",
@@ -633,6 +645,28 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
633
645
  "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
634
646
  };
635
647
 
648
+ /**
649
+ * Origins allowed to embed the storefront in an iframe. These are the deco
650
+ * studio surfaces that render the live-preview iframe, plus `'self'` so the
651
+ * storefront can frame itself.
652
+ *
653
+ * A site can widen this by passing its own
654
+ * `securityHeaders["Content-Security-Policy"]`, or drop framing protection
655
+ * entirely with `securityHeaders: false`.
656
+ */
657
+ export const DECO_ADMIN_FRAME_ANCESTORS: string[] = [
658
+ "'self'",
659
+ "https://studio.decocms.com",
660
+ "https://*.deco.studio",
661
+ ];
662
+
663
+ /**
664
+ * Default enforcement CSP shipped on every SSR HTML response. Only the
665
+ * `frame-ancestors` directive is enforced by default; the full site CSP
666
+ * (passed via the `csp` option) is emitted report-only.
667
+ */
668
+ export const DEFAULT_FRAME_ANCESTORS_CSP = `frame-ancestors ${DECO_ADMIN_FRAME_ANCESTORS.join(" ")}`;
669
+
636
670
  const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
637
671
 
638
672
  /**
@@ -879,6 +913,12 @@ export function createDecoWorkerEntry(
879
913
  const secHeaders: Record<string, string> | null = (() => {
880
914
  if (securityHeadersOpt === false) return null;
881
915
  const base = { ...DEFAULT_SECURITY_HEADERS };
916
+ // Ship an enforcement `frame-ancestors` CSP by default so the deco
917
+ // admin/studio can embed the storefront for live preview without every
918
+ // site copy-pasting the directive into its worker entry. A site can
919
+ // override the whole header via securityHeaders["Content-Security-Policy"]
920
+ // (the merge loop below wins) or disable all security headers with `false`.
921
+ base["Content-Security-Policy"] = DEFAULT_FRAME_ANCESTORS_CSP;
882
922
  if (securityHeadersOpt) {
883
923
  for (const [k, v] of Object.entries(securityHeadersOpt)) base[k] = v;
884
924
  }
@@ -1113,15 +1153,33 @@ export function createDecoWorkerEntry(
1113
1153
  return segments;
1114
1154
  }
1115
1155
 
1116
- async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
1156
+ // Constant-time string compare — avoids a byte-wise timing side-channel on the
1157
+ // purge token. Length is not secret here, so a fast length-mismatch reject is fine.
1158
+ function timingSafeEqualStr(a: string, b: string): boolean {
1159
+ if (a.length !== b.length) return false;
1160
+ let diff = 0;
1161
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1162
+ return diff === 0;
1163
+ }
1164
+
1165
+ // Shared auth for the cache purge endpoints. Returns a Response when the request
1166
+ // is unauthorized or purge is disabled, or null when authorized. Single source
1167
+ // of truth so /_cache/purge and /_cache/purge-loaders can't drift.
1168
+ function authorizePurge(request: Request, env: Record<string, unknown>): Response | null {
1117
1169
  if (purgeTokenEnv === false) {
1118
1170
  return new Response("Purge disabled", { status: 404 });
1119
1171
  }
1120
-
1121
1172
  const token = (env[purgeTokenEnv] as string) || "";
1122
- if (!token || request.headers.get("Authorization") !== `Bearer ${token}`) {
1173
+ const auth = request.headers.get("Authorization") ?? "";
1174
+ if (!token || !timingSafeEqualStr(auth, `Bearer ${token}`)) {
1123
1175
  return new Response("Unauthorized", { status: 401 });
1124
1176
  }
1177
+ return null;
1178
+ }
1179
+
1180
+ async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
1181
+ const denied = authorizePurge(request, env);
1182
+ if (denied) return denied;
1125
1183
 
1126
1184
  let body: PurgeRequestBody;
1127
1185
  try {
@@ -1229,6 +1287,21 @@ export function createDecoWorkerEntry(
1229
1287
  return Response.json({ purged, total: purged.length });
1230
1288
  }
1231
1289
 
1290
+ // Purge the in-memory loader/commerce cache for THIS isolate. Same auth as
1291
+ // handlePurge (Bearer `purgeTokenEnv`). Per-isolate by nature — the isolate
1292
+ // that serves this request clears its cache; other live isolates keep theirs
1293
+ // until they're recycled or hit too. For a guaranteed global flush, redeploy
1294
+ // (loader cache keys are build-hash versioned).
1295
+ async function handlePurgeLoaders(
1296
+ request: Request,
1297
+ env: Record<string, unknown>,
1298
+ ): Promise<Response> {
1299
+ const denied = authorizePurge(request, env);
1300
+ if (denied) return denied;
1301
+ clearLoaderCache();
1302
+ return Response.json({ cleared: true, scope: "isolate" });
1303
+ }
1304
+
1232
1305
  // -- Admin route handler ---------------------------------------------------
1233
1306
 
1234
1307
  const ADMIN_NO_CACHE: Record<string, string> = {
@@ -1554,6 +1627,14 @@ export function createDecoWorkerEntry(
1554
1627
  return handlePurge(request, env);
1555
1628
  }
1556
1629
 
1630
+ // Loader/commerce cache purge (in-memory, this isolate only). Escape hatch
1631
+ // to invalidate loader results immediately after an out-of-band upstream
1632
+ // change (e.g. a Magento catalog sync) without waiting out the TTL. A
1633
+ // redeploy is the global lever — loader cache keys are build-hash versioned.
1634
+ if (url.pathname === "/_cache/purge-loaders" && request.method === "POST") {
1635
+ return handlePurgeLoaders(request, env);
1636
+ }
1637
+
1557
1638
  // CMS redirects (website/loaders/redirect.ts blocks) — checked before
1558
1639
  // ?asJson and commerce proxy so every request path respects redirects.
1559
1640
  // Revision-keyed so cross-bundle setBlocks() calls are detected even when