@decocms/blocks 7.55.1 → 7.56.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/blocks",
3
- "version": "7.55.1",
3
+ "version": "7.56.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  canonicalizeServerFnPayloadForCacheKey,
4
4
  detectCacheProfile,
5
+ getCacheProfile,
6
+ registerCachePattern,
7
+ registerPrivatePaths,
5
8
  serverFnPagePath,
9
+ setCacheProfile,
6
10
  } from "./cacheHeaders";
7
11
 
8
12
  const sfn = (payload: unknown): URL => {
@@ -113,3 +117,89 @@ describe("canonicalizeServerFnPayloadForCacheKey — variant-param cache key", (
113
117
  expect(canonicalizeServerFnPayloadForCacheKey("{not json")).toBe("{not json");
114
118
  });
115
119
  });
120
+
121
+ describe("detectCacheProfile — private areas", () => {
122
+ it.each([
123
+ "/checkout",
124
+ "/checkout/cart",
125
+ "/cart",
126
+ "/carrinho",
127
+ "/minha-conta",
128
+ "/meus-pedidos",
129
+ "/login",
130
+ "/myaccount",
131
+ ])("keeps the existing private route %s private", (path) => {
132
+ expect(detectCacheProfile(path)).toBe("private");
133
+ });
134
+
135
+ it.each([
136
+ // Every one of these used to fall through to the cacheable `listing`
137
+ // default. `/listadedesejos` is the one that bit a live store.
138
+ "/listadedesejos",
139
+ "/lista-de-desejos",
140
+ "/wishlist",
141
+ "/favoritos",
142
+ "/orders",
143
+ "/order-placed",
144
+ "/profile",
145
+ "/perfil",
146
+ "/logout",
147
+ "/sair",
148
+ "/cadastro",
149
+ "/signup",
150
+ "/register",
151
+ "/assinaturas",
152
+ "/trocas",
153
+ "/devolucao",
154
+ ])("treats %s as private", (path) => {
155
+ expect(detectCacheProfile(path)).toBe("private");
156
+ });
157
+
158
+ it("matches case-insensitively", () => {
159
+ expect(detectCacheProfile("/Checkout")).toBe("private");
160
+ expect(detectCacheProfile("/MINHA-CONTA")).toBe("private");
161
+ });
162
+
163
+ it("matches behind a locale prefix", () => {
164
+ expect(detectCacheProfile("/pt/checkout")).toBe("private");
165
+ expect(detectCacheProfile("/pt-br/minha-conta")).toBe("private");
166
+ });
167
+
168
+ it("does not swallow public routes that merely start with two letters", () => {
169
+ expect(detectCacheProfile("/pt/tenis")).toBe("listing");
170
+ expect(detectCacheProfile("/carteiras")).toBe("listing");
171
+ expect(detectCacheProfile("/cartoes-presente")).toBe("listing");
172
+ });
173
+
174
+ it("registerPrivatePaths adds site-specific private routes", () => {
175
+ expect(detectCacheProfile("/clube-vip")).toBe("listing");
176
+ registerPrivatePaths(["/clube-vip", "sem-barra"]);
177
+ expect(detectCacheProfile("/clube-vip")).toBe("private");
178
+ expect(detectCacheProfile("/clube-vip/beneficios")).toBe("private");
179
+ expect(detectCacheProfile("/sem-barra")).toBe("private");
180
+ // prefix match must respect segment boundaries
181
+ expect(detectCacheProfile("/clube-vip-publico")).toBe("listing");
182
+ });
183
+ });
184
+
185
+ describe("cache configuration can tighten, not loosen", () => {
186
+ it("refuses to make a non-public profile public", () => {
187
+ setCacheProfile("private", { isPublic: true });
188
+ expect(getCacheProfile("private").isPublic).toBe(false);
189
+ });
190
+
191
+ it("still allows ordinary tuning of a private profile", () => {
192
+ setCacheProfile("private", { loader: { fresh: 1_000 } });
193
+ expect(getCacheProfile("private").loader.fresh).toBe(1_000);
194
+ expect(getCacheProfile("private").isPublic).toBe(false);
195
+ });
196
+
197
+ it("a custom pattern cannot make a private path public", () => {
198
+ // A catch-all site pattern used to win over the built-in private check,
199
+ // because custom patterns are evaluated first.
200
+ registerCachePattern({ test: () => true, profile: "static" });
201
+ expect(detectCacheProfile("/checkout")).toBe("private");
202
+ // ...but it still applies everywhere else.
203
+ expect(detectCacheProfile("/tenis")).toBe("static");
204
+ });
205
+ });
@@ -149,15 +149,45 @@ export function setCacheProfile(
149
149
  overrides: CacheProfileOverrides,
150
150
  ): void {
151
151
  const current = PROFILES[profile];
152
+
153
+ // `private`/`cart`/`none` are the profiles that keep authenticated pages off
154
+ // the shared edge (and, once CDN caching is on, off the CDN). Flipping one to
155
+ // public is how a checkout page ends up served to another visitor, so it
156
+ // takes more than an `isPublic: true` in a props bag — see
157
+ // `allowPublicPrivateProfile`.
158
+ let isPublic = overrides.isPublic ?? current.isPublic;
159
+ if (isPublic && !current.isPublic && !publicPrivateProfilesAllowed) {
160
+ console.warn(
161
+ `[deco] setCacheProfile("${profile}", { isPublic: true }) ignored: making a ` +
162
+ `non-public profile public would let authenticated pages be shared between ` +
163
+ `visitors. Call allowPublicPrivateProfile() first if this is deliberate.`,
164
+ );
165
+ isPublic = current.isPublic;
166
+ }
167
+
152
168
  PROFILES[profile] = {
153
169
  edge: { ...current.edge, ...overrides.edge },
154
170
  browser: { ...current.browser, ...overrides.browser },
155
171
  loader: { ...current.loader, ...overrides.loader },
156
172
  client: { ...current.client, ...overrides.client },
157
- isPublic: overrides.isPublic ?? current.isPublic,
173
+ isPublic,
158
174
  };
159
175
  }
160
176
 
177
+ let publicPrivateProfilesAllowed = false;
178
+
179
+ /**
180
+ * Opt out of the guard in `setCacheProfile` that refuses to turn a non-public
181
+ * profile (`private`, `cart`, `none`) public.
182
+ *
183
+ * There is no legitimate storefront reason to call this. It exists so the
184
+ * escape hatch has a name you have to type, rather than being a silent side
185
+ * effect of passing `isPublic: true`.
186
+ */
187
+ export function allowPublicPrivateProfile(): void {
188
+ publicPrivateProfilesAllowed = true;
189
+ }
190
+
161
191
  // ---------------------------------------------------------------------------
162
192
  // Derivation: Cache-Control headers (browser layer)
163
193
  // ---------------------------------------------------------------------------
@@ -262,16 +292,95 @@ interface CachePattern {
262
292
  profile: CacheProfileName;
263
293
  }
264
294
 
265
- // Authenticated / per-user areas that must never be edge-cached. Includes the
266
- // hyphenless `myaccount` (VTEX My Account wrapper path) and common pt-BR routes
267
- // (`minha-conta`, `meus-pedidos`, `pedidos`) — omitting these let account pages
268
- // fall through to the cacheable `listing` default (see decocms/blocks#412).
269
- const PRIVATE_PREFIX_RE =
270
- /^\/(cart|checkout|account|myaccount|my-account|minha-conta|meus-pedidos|pedidos|login)(\/|$)/;
295
+ // Authenticated / per-user areas that must never be edge-cached. Anything not
296
+ // matched here falls through to the cacheable `listing` default, so a missing
297
+ // entry is a live content leak, not a missed optimization (see
298
+ // decocms/blocks#412, which added the hyphenless `myaccount` VTEX wrapper path
299
+ // and the pt-BR account routes).
300
+ //
301
+ // Three properties this regex must keep, each of which was a real hole:
302
+ // - case-insensitive: `/Checkout` used to fall through to `listing`.
303
+ // - optional locale prefix: `/pt/checkout`, `/br/minha-conta` likewise. Only
304
+ // matches when followed by a private segment, so a legitimate two-letter
305
+ // route can't be swallowed by it.
306
+ // - wishlist / profile / signup / returns: absent until the CDN work, which
307
+ // is what served a live store's `/listadedesejos` from the shared entry.
308
+ const PRIVATE_SEGMENTS = [
309
+ "cart",
310
+ "carrinho",
311
+ "checkout",
312
+ "account",
313
+ "myaccount",
314
+ "my-account",
315
+ "minha-conta",
316
+ "meus-pedidos",
317
+ "pedidos",
318
+ "orders",
319
+ "order-placed",
320
+ "login",
321
+ "logout",
322
+ "sair",
323
+ "cadastro",
324
+ "signup",
325
+ "register",
326
+ "profile",
327
+ "perfil",
328
+ "wishlist",
329
+ "favoritos",
330
+ "listadedesejos",
331
+ "lista-de-desejos",
332
+ "minha-lista",
333
+ "assinaturas",
334
+ "subscriptions",
335
+ "troca",
336
+ "trocas",
337
+ "devolucao",
338
+ "devolucoes",
339
+ ];
340
+
341
+ const LOCALE_PREFIX = "(?:\\/[a-z]{2}(?:-[a-z]{2})?)?";
342
+
343
+ const PRIVATE_PREFIX_RE = new RegExp(
344
+ `^${LOCALE_PREFIX}\\/(?:${PRIVATE_SEGMENTS.join("|")})(?:\\/|$)`,
345
+ "i",
346
+ );
347
+
348
+ // Site-registered private prefixes (see `registerPrivatePaths`). Kept separate
349
+ // from `PRIVATE_SEGMENTS` so a site can only ever ADD to the private set.
350
+ const extraPrivatePaths: string[] = [];
351
+
352
+ /**
353
+ * Mark additional path prefixes as private — never edge-cached, never served
354
+ * from the CDN.
355
+ *
356
+ * This is the safe half of cache configuration: it can only restrict, never
357
+ * relax. Prefer it over `registerCachePattern` (which can also make things
358
+ * public, and is evaluated before the built-in private check).
359
+ *
360
+ * @example
361
+ * ```ts
362
+ * registerPrivatePaths(["/listadedesejos", "/trocas"]);
363
+ * ```
364
+ */
365
+ export function registerPrivatePaths(paths: string[]): void {
366
+ for (const path of paths) {
367
+ const normalized = path.startsWith("/") ? path : `/${path}`;
368
+ if (!extraPrivatePaths.includes(normalized)) extraPrivatePaths.push(normalized);
369
+ }
370
+ }
371
+
372
+ function isPrivatePath(pathname: string): boolean {
373
+ if (PRIVATE_PREFIX_RE.test(pathname)) return true;
374
+ const lower = pathname.toLowerCase();
375
+ return extraPrivatePaths.some((prefix) => {
376
+ const p = prefix.toLowerCase();
377
+ return lower === p || lower.startsWith(`${p}/`);
378
+ });
379
+ }
271
380
 
272
381
  const builtinPatterns: CachePattern[] = [
273
382
  {
274
- test: (p) => PRIVATE_PREFIX_RE.test(p),
383
+ test: (p) => isPrivatePath(p),
275
384
  profile: "private",
276
385
  },
277
386
  {
@@ -299,7 +408,13 @@ const customPatterns: CachePattern[] = [];
299
408
 
300
409
  /**
301
410
  * Register additional URL-to-profile patterns. Custom patterns are evaluated
302
- * before built-in ones, so they can override defaults.
411
+ * before built-in ones, so they can override defaults — with one exception:
412
+ * a custom pattern resolving to a PUBLIC profile cannot override the built-in
413
+ * private check (see `detectCacheProfile`). A broad site pattern would
414
+ * otherwise silently capture `/checkout` and make it cacheable.
415
+ *
416
+ * To mark routes as private, prefer {@link registerPrivatePaths} — it can only
417
+ * restrict, and isn't subject to ordering rules.
303
418
  */
304
419
  export function registerCachePattern(pattern: CachePattern): void {
305
420
  customPatterns.push(pattern);
@@ -324,8 +439,15 @@ export function detectCacheProfile(pathnameOrUrl: string | URL): CacheProfileNam
324
439
  searchParams = url.searchParams;
325
440
  }
326
441
 
442
+ // A private path can never be talked into a public profile by a site pattern.
443
+ // Custom patterns still win for anything non-public (a site can always make a
444
+ // route MORE restricted), and for public-vs-public overrides on other paths.
445
+ const privatePath = isPrivatePath(pathname);
446
+
327
447
  for (const pattern of customPatterns) {
328
- if (pattern.test(pathname, searchParams)) return pattern.profile;
448
+ if (!pattern.test(pathname, searchParams)) continue;
449
+ if (privatePath && PROFILES[pattern.profile].isPublic) return "private";
450
+ return pattern.profile;
329
451
  }
330
452
  for (const pattern of builtinPatterns) {
331
453
  if (pattern.test(pathname, searchParams)) return pattern.profile;