@decocms/tanstack 7.20.10 → 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.10",
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.10",
28
- "@decocms/blocks-admin": "7.20.10",
29
- "@decocms/blocks-cli": "7.20.10",
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"
@@ -385,3 +385,48 @@ describe("security headers — frame-ancestors default", () => {
385
385
  expect(res.headers.get("X-Content-Type-Options")).toBeNull();
386
386
  });
387
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,
@@ -1152,15 +1153,33 @@ export function createDecoWorkerEntry(
1152
1153
  return segments;
1153
1154
  }
1154
1155
 
1155
- 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 {
1156
1169
  if (purgeTokenEnv === false) {
1157
1170
  return new Response("Purge disabled", { status: 404 });
1158
1171
  }
1159
-
1160
1172
  const token = (env[purgeTokenEnv] as string) || "";
1161
- if (!token || request.headers.get("Authorization") !== `Bearer ${token}`) {
1173
+ const auth = request.headers.get("Authorization") ?? "";
1174
+ if (!token || !timingSafeEqualStr(auth, `Bearer ${token}`)) {
1162
1175
  return new Response("Unauthorized", { status: 401 });
1163
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;
1164
1183
 
1165
1184
  let body: PurgeRequestBody;
1166
1185
  try {
@@ -1268,6 +1287,21 @@ export function createDecoWorkerEntry(
1268
1287
  return Response.json({ purged, total: purged.length });
1269
1288
  }
1270
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
+
1271
1305
  // -- Admin route handler ---------------------------------------------------
1272
1306
 
1273
1307
  const ADMIN_NO_CACHE: Record<string, string> = {
@@ -1593,6 +1627,14 @@ export function createDecoWorkerEntry(
1593
1627
  return handlePurge(request, env);
1594
1628
  }
1595
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
+
1596
1638
  // CMS redirects (website/loaders/redirect.ts blocks) — checked before
1597
1639
  // ?asJson and commerce proxy so every request path respects redirects.
1598
1640
  // Revision-keyed so cross-bundle setBlocks() calls are detected even when