@decocms/start 6.3.0 → 6.3.1

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/start",
3
- "version": "6.3.0",
3
+ "version": "6.3.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -89,6 +89,14 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
89
89
  handleRender,
90
90
  corsHeaders,
91
91
  },
92
+ // Region splits the cache so a RJ-cached response isn't served to SP visitors
93
+ // when pages use the website/matchers/location.ts matcher. Without this, the
94
+ // first geo-resolved response leaks across regions.
95
+ buildSegment: (request) => {
96
+ const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
97
+ const regionCode = request.headers.get("cf-region-code") ?? cf?.regionCode ?? "";
98
+ return regionCode ? { regionId: regionCode } : {};
99
+ },
92
100
  });
93
101
 
94
102
  export default instrumentWorker(decoWorker, {
@@ -155,11 +163,16 @@ const decoWorker = createDecoWorkerEntry(serverEntry, {
155
163
  csp: CSP_DIRECTIVES,
156
164
  buildSegment: (request) => {
157
165
  const vtx = extractVtexContext(request);
166
+ const cf = (request as unknown as { cf?: { regionCode?: string } }).cf;
167
+ const geoRegion = request.headers.get("cf-region-code") ?? cf?.regionCode ?? "";
158
168
  return {
159
169
  device: MOBILE_RE.test(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop",
160
170
  loggedIn: vtx.isLoggedIn,
161
171
  salesChannel: vtx.salesChannel,
162
- regionId: (vtx as any).regionId ?? undefined,
172
+ // Prefer VTEX regionalization regionId when present; otherwise fall back
173
+ // to Cloudflare geo so the website/matchers/location.ts matcher gets a
174
+ // properly segmented cache.
175
+ regionId: (vtx as any).regionId ?? (geoRegion || undefined),
163
176
  };
164
177
  },
165
178
  admin: {
@@ -301,35 +301,53 @@ registerMatcherSchemas([
301
301
  key: "website/matchers/location.ts",
302
302
  title: "Location",
303
303
  namespace: "website",
304
- propsSchema: {
305
- type: "object",
306
- properties: {
307
- includeLocations: {
308
- type: "array",
309
- title: "Include Locations",
310
- items: {
311
- type: "object",
312
- properties: {
313
- country: { type: "string", title: "Country" },
314
- regionCode: { type: "string", title: "Region" },
315
- city: { type: "string", title: "City" },
316
- },
304
+ propsSchema: (() => {
305
+ const locationOrMapItem = {
306
+ type: "object" as const,
307
+ properties: {
308
+ city: {
309
+ type: "string",
310
+ title: "City",
311
+ examples: ["São Paulo"],
312
+ description: "Exact city name (case-insensitive) as returned by Cloudflare's cf-ipcity header.",
313
+ },
314
+ regionCode: {
315
+ type: "string",
316
+ title: "Region Code",
317
+ examples: ["SP", "RJ", "MG", "47"],
318
+ description:
319
+ "Matches Cloudflare's cf-region-code header. Usually the ISO 3166-2 subdivision code (SP, RJ, MG, …); for some regions Cloudflare returns numeric codes (e.g. 47). Use the same value cf-region-code returns for your audience — full region names like \"São Paulo\" are NOT matched.",
320
+ },
321
+ country: {
322
+ type: "string",
323
+ title: "Country",
324
+ examples: ["BR", "Brasil", "US"],
325
+ description: "ISO 3166-1 alpha-2 code (BR, US, AR, …) or a full country name (Brasil, United States) — the matcher resolves common aliases.",
326
+ },
327
+ coordinates: {
328
+ type: "string",
329
+ title: "Area selection",
330
+ examples: ["-23.5505,-46.6333,5000"],
331
+ description: "\"latitude,longitude,radius_in_meters\" for haversine-radius matching. Set this for the Map mode.",
317
332
  },
318
333
  },
319
- excludeLocations: {
320
- type: "array",
321
- title: "Exclude Locations",
322
- items: {
323
- type: "object",
324
- properties: {
325
- country: { type: "string", title: "Country" },
326
- regionCode: { type: "string", title: "Region" },
327
- city: { type: "string", title: "City" },
328
- },
334
+ };
335
+ return {
336
+ type: "object" as const,
337
+ properties: {
338
+ includeLocations: {
339
+ type: "array",
340
+ title: "Include Locations",
341
+ items: locationOrMapItem,
342
+ },
343
+ excludeLocations: {
344
+ type: "array",
345
+ title: "Exclude Locations",
346
+ items: locationOrMapItem,
329
347
  },
330
348
  },
331
- },
332
- },
349
+ };
350
+ })(),
333
351
  },
334
352
  {
335
353
  key: "website/matchers/userAgent.ts",
@@ -0,0 +1,251 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import type { MatcherContext } from "../cms/resolve";
3
+ import { evaluateMatcher } from "../cms/resolve";
4
+ import { registerBuiltinMatchers } from "./builtins";
5
+
6
+ const LOCATION_KEY = "website/matchers/location.ts";
7
+
8
+ beforeEach(() => {
9
+ registerBuiltinMatchers();
10
+ });
11
+
12
+ function ctxFromCookies(cookies: Record<string, string>): MatcherContext {
13
+ return { cookies };
14
+ }
15
+
16
+ function ctxFromCf(cf: Record<string, unknown>): MatcherContext {
17
+ const request = new Request("https://example.com/");
18
+ Object.defineProperty(request, "cf", { value: cf, configurable: true });
19
+ return { request };
20
+ }
21
+
22
+ function ctxFromHeaders(headers: Record<string, string>): MatcherContext {
23
+ return {
24
+ request: new Request("https://example.com/", { headers }),
25
+ };
26
+ }
27
+
28
+ function match(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
29
+ return evaluateMatcher({ ...rule, __resolveType: LOCATION_KEY }, ctx);
30
+ }
31
+
32
+ describe("locationMatcher — typed mode (Location)", () => {
33
+ it("matches when regionCode equals Cloudflare's cf-region-code (SP)", () => {
34
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
35
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
36
+ });
37
+
38
+ it("matches a raw numeric regionCode (e.g. '47') for parity with deco-cx/apps", () => {
39
+ const ctx = ctxFromHeaders({ "cf-region-code": "47", "cf-ipcountry": "BR" });
40
+ expect(match({ includeLocations: [{ regionCode: "47" }] }, ctx)).toBe(true);
41
+ });
42
+
43
+ it("is case-insensitive on regionCode", () => {
44
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
45
+ expect(match({ includeLocations: [{ regionCode: "sp" }] }, ctx)).toBe(true);
46
+ });
47
+
48
+ it("does NOT match a region NAME against cf-region-code (parity with original)", () => {
49
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
50
+ expect(
51
+ match({ includeLocations: [{ regionCode: "São Paulo" }] }, ctx),
52
+ ).toBe(false);
53
+ });
54
+
55
+ it("does not match when the region differs", () => {
56
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
57
+ expect(match({ includeLocations: [{ regionCode: "RJ" }] }, ctx)).toBe(false);
58
+ });
59
+
60
+ it("AND's multiple fields on the same entry (regionCode + country)", () => {
61
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
62
+ expect(
63
+ match(
64
+ { includeLocations: [{ regionCode: "SP", country: "BR" }] },
65
+ ctx,
66
+ ),
67
+ ).toBe(true);
68
+ expect(
69
+ match(
70
+ { includeLocations: [{ regionCode: "SP", country: "AR" }] },
71
+ ctx,
72
+ ),
73
+ ).toBe(false);
74
+ });
75
+
76
+ it("resolves country aliases (Brasil → BR)", () => {
77
+ const ctx = ctxFromHeaders({ "cf-ipcountry": "BR", "cf-region-code": "SP" });
78
+ expect(
79
+ match({ includeLocations: [{ country: "Brasil" }] }, ctx),
80
+ ).toBe(true);
81
+ });
82
+
83
+ it("OR's across multiple include entries", () => {
84
+ const ctx = ctxFromHeaders({ "cf-region-code": "RJ" });
85
+ expect(
86
+ match(
87
+ {
88
+ includeLocations: [{ regionCode: "SP" }, { regionCode: "RJ" }],
89
+ },
90
+ ctx,
91
+ ),
92
+ ).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe("locationMatcher — empty / shape edge cases", () => {
97
+ it("empty includeLocations array → match (no constraint)", () => {
98
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
99
+ expect(match({ includeLocations: [] }, ctx)).toBe(true);
100
+ });
101
+
102
+ it("missing includeLocations → match", () => {
103
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
104
+ expect(match({}, ctx)).toBe(true);
105
+ });
106
+
107
+ it("entry {} inside includeLocations matches everyone (parity with original)", () => {
108
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
109
+ expect(match({ includeLocations: [{}] }, ctx)).toBe(true);
110
+ });
111
+
112
+ it("entry {} inside excludeLocations does NOT exclude anyone (parity)", () => {
113
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP", "cf-ipcountry": "BR" });
114
+ expect(match({ excludeLocations: [{}] }, ctx)).toBe(true);
115
+ });
116
+
117
+ it("excludeLocations short-circuits over includeLocations", () => {
118
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
119
+ expect(
120
+ match(
121
+ {
122
+ includeLocations: [{ regionCode: "SP" }],
123
+ excludeLocations: [{ regionCode: "SP" }],
124
+ },
125
+ ctx,
126
+ ),
127
+ ).toBe(false);
128
+ });
129
+
130
+ it("includeLocations with [{regionCode}] fails when geo is empty", () => {
131
+ expect(
132
+ match({ includeLocations: [{ regionCode: "SP" }] }, {}),
133
+ ).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("locationMatcher — Map mode (haversine)", () => {
138
+ it("matches when source coords are within target radius", () => {
139
+ // Target: São Paulo center, 5km. Source: ~500m away.
140
+ const ctx = ctxFromHeaders({
141
+ "cf-iplatitude": "-23.5510",
142
+ "cf-iplongitude": "-46.6340",
143
+ });
144
+ expect(
145
+ match(
146
+ { includeLocations: [{ coordinates: "-23.5505,-46.6333,5000" }] },
147
+ ctx,
148
+ ),
149
+ ).toBe(true);
150
+ });
151
+
152
+ it("does NOT match when source coords are outside the radius", () => {
153
+ // Target: São Paulo, 5km. Source: Rio (~360km away).
154
+ const ctx = ctxFromHeaders({
155
+ "cf-iplatitude": "-22.9068",
156
+ "cf-iplongitude": "-43.1729",
157
+ });
158
+ expect(
159
+ match(
160
+ { includeLocations: [{ coordinates: "-23.5505,-46.6333,5000" }] },
161
+ ctx,
162
+ ),
163
+ ).toBe(false);
164
+ });
165
+
166
+ it("Map-only rule does NOT match when source has no coordinates", () => {
167
+ // Deliberate divergence from deco-cx/apps: upstream lets coord-only rules
168
+ // vacuously pass when the visitor has no lat/lng, which matches every
169
+ // such visitor — a footgun in production. We require both sides to have
170
+ // coordinates before the haversine check passes.
171
+ const ctx = ctxFromHeaders({ "cf-region-code": "SP" });
172
+ expect(
173
+ match(
174
+ { includeLocations: [{ coordinates: "-23.5505,-46.6333,5000" }] },
175
+ ctx,
176
+ ),
177
+ ).toBe(false);
178
+ });
179
+
180
+ it("AND's coordinates with regionCode on the same entry", () => {
181
+ // Source: SP coords, region=SP.
182
+ const ctx = ctxFromHeaders({
183
+ "cf-region-code": "SP",
184
+ "cf-iplatitude": "-23.5510",
185
+ "cf-iplongitude": "-46.6340",
186
+ });
187
+ // Entry asks for region=SP AND within 5km of SP center — matches.
188
+ expect(
189
+ match(
190
+ {
191
+ includeLocations: [
192
+ { regionCode: "SP", coordinates: "-23.5505,-46.6333,5000" },
193
+ ],
194
+ },
195
+ ctx,
196
+ ),
197
+ ).toBe(true);
198
+ // Entry asks for region=SP AND within 5km of Rio — fails on coords.
199
+ expect(
200
+ match(
201
+ {
202
+ includeLocations: [
203
+ { regionCode: "SP", coordinates: "-22.9068,-43.1729,5000" },
204
+ ],
205
+ },
206
+ ctx,
207
+ ),
208
+ ).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe("locationMatcher — data source fallbacks", () => {
213
+ it("reads from request.cf when headers are absent", () => {
214
+ const ctx = ctxFromCf({ country: "BR", regionCode: "SP" });
215
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
216
+ });
217
+
218
+ it("reads from __cf_geo_* cookies as fallback", () => {
219
+ const ctx = ctxFromCookies({
220
+ __cf_geo_country: "BR",
221
+ __cf_geo_region_code: "SP",
222
+ });
223
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
224
+ });
225
+
226
+ it("decodes URL-encoded cookie values (e.g. city with accent)", () => {
227
+ const ctx = ctxFromCookies({
228
+ __cf_geo_country: "BR",
229
+ __cf_geo_city: encodeURIComponent("São Paulo"),
230
+ });
231
+ expect(
232
+ match({ includeLocations: [{ city: "São Paulo" }] }, ctx),
233
+ ).toBe(true);
234
+ });
235
+
236
+ it("headers take precedence over request.cf and cookies", () => {
237
+ const request = new Request("https://example.com/", {
238
+ headers: { "cf-region-code": "SP" },
239
+ });
240
+ Object.defineProperty(request, "cf", {
241
+ value: { regionCode: "RJ", country: "BR" },
242
+ configurable: true,
243
+ });
244
+ const ctx: MatcherContext = {
245
+ request,
246
+ cookies: { __cf_geo_region_code: "MG" },
247
+ };
248
+ expect(match({ includeLocations: [{ regionCode: "SP" }] }, ctx)).toBe(true);
249
+ expect(match({ includeLocations: [{ regionCode: "RJ" }] }, ctx)).toBe(false);
250
+ });
251
+ });
@@ -186,95 +186,168 @@ function queryStringMatcher(rule: Record<string, unknown>, ctx: MatcherContext):
186
186
  }
187
187
 
188
188
  // -------------------------------------------------------------------------
189
- // Location matcher
189
+ // Location matcher — parity with deco-cx/apps/website/matchers/location.ts
190
+ //
191
+ // Each entry in includeLocations/excludeLocations is a union of:
192
+ // Location: { city?, regionCode?, country? }
193
+ // Map: { coordinates?: "lat,lng,radius_in_meters" }
194
+ // Fields may coexist on the same entry; the matcher AND's every constraint
195
+ // that is populated. An entry with zero constraints returns defaultNotMatched
196
+ // (true for includes, false for excludes), matching upstream semantics.
190
197
  // -------------------------------------------------------------------------
191
198
 
192
- interface LocationRule {
199
+ interface LocationOrMap {
193
200
  country?: string;
194
201
  regionCode?: string;
195
202
  city?: string;
203
+ /** "latitude,longitude,radius_in_meters" — e.g. "-23.5505,-46.6333,5000" */
204
+ coordinates?: string;
196
205
  }
197
206
 
198
- interface GeoData {
207
+ interface GeoSource {
199
208
  country: string;
200
209
  regionCode: string;
201
- regionName: string;
202
210
  city: string;
211
+ /** "latitude,longitude" when available */
212
+ coordinates?: string;
213
+ }
214
+
215
+ function decodeCookie(value: string | undefined): string {
216
+ if (!value) return "";
217
+ try {
218
+ return decodeURIComponent(value);
219
+ } catch {
220
+ return value;
221
+ }
203
222
  }
204
223
 
205
224
  /**
206
225
  * Extract geo data from the request context.
207
- * Priority: request.cf (Cloudflare Workers) > geo cookies > geo headers.
226
+ *
227
+ * Read order mirrors the original deco-cx/apps matcher (header-first), with
228
+ * additional fallbacks for environments that don't preserve the cf-* request
229
+ * headers (e.g. when TanStack Start re-wraps the request and drops them):
230
+ * 1. cf-* request headers (cf-region-code, cf-ipcity, ...)
231
+ * 2. request.cf (Cloudflare Workers native)
232
+ * 3. __cf_geo_* cookies (injected by createDecoWorkerEntry)
208
233
  */
209
- function getGeoData(ctx: MatcherContext): GeoData {
210
- // 1. Cloudflare Workers: request.cf has authoritative geo data
211
- const req = ctx.request;
212
- if (req) {
213
- const cf = (req as any).cf as Record<string, unknown> | undefined;
214
- if (cf?.country) {
215
- return {
216
- country: (cf.country as string) ?? "",
217
- regionCode: (cf.regionCode as string) ?? (cf.region as string) ?? "",
218
- regionName: (cf.region as string) ?? "",
219
- city: (cf.city as string) ?? "",
220
- };
234
+ function getGeoData(ctx: MatcherContext): GeoSource {
235
+ const headers = ctx.headers ?? {};
236
+ const reqHeaders = ctx.request?.headers;
237
+ const h = (name: string): string =>
238
+ headers[name] ?? reqHeaders?.get(name) ?? "";
239
+
240
+ let regionCode = h("cf-region-code");
241
+ let country = h("cf-ipcountry") || h("x-vercel-ip-country");
242
+ let city = h("cf-ipcity");
243
+ let latitude = h("cf-iplatitude");
244
+ let longitude = h("cf-iplongitude");
245
+
246
+ if (!country || !regionCode || !city || !latitude || !longitude) {
247
+ const req = ctx.request;
248
+ const cf = req ? ((req as any).cf as Record<string, unknown> | undefined) : undefined;
249
+ if (cf) {
250
+ country = country || ((cf.country as string) ?? "");
251
+ regionCode = regionCode || ((cf.regionCode as string) ?? "");
252
+ city = city || ((cf.city as string) ?? "");
253
+ latitude = latitude || (cf.latitude != null ? String(cf.latitude) : "");
254
+ longitude = longitude || (cf.longitude != null ? String(cf.longitude) : "");
221
255
  }
222
256
  }
223
257
 
224
- // 2. Geo cookies (set by Cloudflare middleware on Fresh/Deno sites)
225
- const cookies = ctx.cookies ?? {};
226
- const cookieCountry = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
227
- if (cookieCountry) {
228
- return {
229
- country: cookieCountry,
230
- regionCode: cookies.__cf_geo_region_code ? decodeURIComponent(cookies.__cf_geo_region_code) : "",
231
- regionName: cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "",
232
- city: cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "",
233
- };
258
+ if (!country || !regionCode || !city || !latitude || !longitude) {
259
+ const cookies = ctx.cookies ?? {};
260
+ country = country || decodeCookie(cookies.__cf_geo_country);
261
+ regionCode = regionCode || decodeCookie(cookies.__cf_geo_region_code);
262
+ city = city || decodeCookie(cookies.__cf_geo_city);
263
+ latitude = latitude || decodeCookie(cookies.__cf_geo_lat);
264
+ longitude = longitude || decodeCookie(cookies.__cf_geo_lng);
234
265
  }
235
266
 
236
- // 3. Fallback: standard geo headers (Vercel, etc.)
237
- const headers = ctx.headers ?? {};
238
- return {
239
- country: headers["cf-ipcountry"] ?? headers["x-vercel-ip-country"] ?? "",
240
- regionCode: headers["cf-region"] ?? headers["x-vercel-ip-country-region"] ?? "",
241
- regionName: "",
242
- city: "",
243
- };
267
+ const coordinates = latitude && longitude ? `${latitude},${longitude}` : undefined;
268
+ return { country, regionCode, city, coordinates };
244
269
  }
245
270
 
246
- function matchesLocationRule(
247
- loc: LocationRule,
248
- geo: GeoData,
249
- ): boolean {
250
- if (loc.country) {
251
- const code = resolveCountryCode(loc.country);
252
- if (code.toUpperCase() !== geo.country.toUpperCase()) return false;
253
- }
254
- if (loc.regionCode) {
255
- // Match against both the short code ("SP") and full name ("São Paulo")
256
- // so rules authored against either format continue working.
257
- const ruleVal = loc.regionCode.toLowerCase();
258
- if (geo.regionCode.toLowerCase() !== ruleVal && geo.regionName.toLowerCase() !== ruleVal) return false;
271
+ /**
272
+ * Haversine "within radius" check.
273
+ *
274
+ * @param source "latitude,longitude" from the request's geo.
275
+ * @param target "latitude,longitude,radius_in_meters" from the rule entry.
276
+ */
277
+ function haversineWithinRadius(source: string, target: string): boolean {
278
+ const [slat, slng] = source.split(",").map(Number);
279
+ const parts = target.split(",").map(Number);
280
+ const [tlat, tlng, radiusMeters] = parts;
281
+ if (
282
+ !Number.isFinite(slat) ||
283
+ !Number.isFinite(slng) ||
284
+ !Number.isFinite(tlat) ||
285
+ !Number.isFinite(tlng) ||
286
+ !Number.isFinite(radiusMeters)
287
+ ) {
288
+ return false;
259
289
  }
260
- if (loc.city && loc.city.toLowerCase() !== geo.city.toLowerCase()) return false;
261
- return true;
290
+ const R = 6371000;
291
+ const toRad = (d: number) => (d * Math.PI) / 180;
292
+ const dLat = toRad(tlat - slat);
293
+ const dLng = toRad(tlng - slng);
294
+ const a =
295
+ Math.sin(dLat / 2) ** 2 +
296
+ Math.cos(toRad(slat)) * Math.cos(toRad(tlat)) * Math.sin(dLng / 2) ** 2;
297
+ const distance = 2 * R * Math.asin(Math.sqrt(a));
298
+ return distance <= radiusMeters;
262
299
  }
263
300
 
264
- function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
265
- const geo = getGeoData(ctx);
266
- if (!geo.country) return !((rule.includeLocations as unknown[] | undefined)?.length);
301
+ function matchLocation(defaultNotMatched: boolean, source: GeoSource) {
302
+ return (target: LocationOrMap): boolean => {
303
+ const hasRegion = !!target.regionCode;
304
+ const hasCity = !!target.city;
305
+ const hasCountry = !!target.country;
306
+ const hasCoords = !!target.coordinates;
307
+
308
+ if (!hasRegion && !hasCity && !hasCountry && !hasCoords) {
309
+ return defaultNotMatched;
310
+ }
311
+
312
+ let result =
313
+ !hasRegion ||
314
+ (target.regionCode!.toLowerCase() === source.regionCode.toLowerCase());
315
+
316
+ // Map-mode entries require the visitor to have coordinates — otherwise
317
+ // we can't tell whether they're inside the target radius, so the
318
+ // conservative default is "no match". This is a deliberate divergence
319
+ // from deco-cx/apps, which let coord-only rules vacuously pass when the
320
+ // visitor had no lat/lng — that behavior matches every visitor without
321
+ // geo data, which is a footgun in production.
322
+ result = result &&
323
+ (!hasCoords ||
324
+ (!!source.coordinates &&
325
+ haversineWithinRadius(source.coordinates, target.coordinates!)));
326
+
327
+ result = result &&
328
+ (!hasCity || target.city!.toLowerCase() === source.city.toLowerCase());
329
+
330
+ result = result &&
331
+ (!hasCountry ||
332
+ resolveCountryCode(target.country!).toUpperCase() ===
333
+ source.country.toUpperCase());
334
+
335
+ return result;
336
+ };
337
+ }
267
338
 
268
- const includeLocations = rule.includeLocations as LocationRule[] | undefined;
269
- const excludeLocations = rule.excludeLocations as LocationRule[] | undefined;
339
+ function locationMatcher(rule: Record<string, unknown>, ctx: MatcherContext): boolean {
340
+ const source = getGeoData(ctx);
341
+ const includeLocations = rule.includeLocations as LocationOrMap[] | undefined;
342
+ const excludeLocations = rule.excludeLocations as LocationOrMap[] | undefined;
270
343
 
271
- if (excludeLocations?.some((loc) => matchesLocationRule(loc, geo))) {
344
+ if (excludeLocations?.some(matchLocation(false, source))) {
272
345
  return false;
273
346
  }
274
- if (includeLocations?.length) {
275
- return includeLocations.some((loc) => matchesLocationRule(loc, geo));
347
+ if (!includeLocations || includeLocations.length === 0) {
348
+ return true;
276
349
  }
277
- return true;
350
+ return includeLocations.some(matchLocation(true, source));
278
351
  }
279
352
 
280
353
  // -------------------------------------------------------------------------
@@ -489,6 +489,17 @@ export function injectGeoCookies(request: Request): Request {
489
489
  }
490
490
  headers.set("cookie", combined);
491
491
 
492
+ // Mirror the ASCII-safe geo fields from request.cf into headers so matchers
493
+ // that read `request.headers.get("cf-region-code")` (parity with the
494
+ // upstream deco-cx/apps location matcher) still work even if the inbound
495
+ // request didn't carry them. Non-ASCII fields (region name, city) stay in
496
+ // the cookies above — putting them in headers would re-trigger the
497
+ // non-ASCII warning we strip on the loop above.
498
+ if (cf.country && !headers.has("cf-ipcountry")) headers.set("cf-ipcountry", cf.country);
499
+ if (cf.regionCode && !headers.has("cf-region-code")) headers.set("cf-region-code", cf.regionCode);
500
+ if (cf.latitude && !headers.has("cf-iplatitude")) headers.set("cf-iplatitude", cf.latitude);
501
+ if (cf.longitude && !headers.has("cf-iplongitude")) headers.set("cf-iplongitude", cf.longitude);
502
+
492
503
  return new Request(request, { headers });
493
504
  }
494
505