From 17a4a18e809e35c12ce0d3144f458782c1940b64 Mon Sep 17 00:00:00 2001 From: guitavano Date: Wed, 1 Jul 2026 12:46:48 -0300 Subject: [PATCH] feat(website/matchers): make Location matcher a discriminated Map | Location union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `(Location | Map)[]` union of two open, all-optional object interfaces gets structurally merged by the schema generator into a single flat object ({city, regionCode, country, coordinates}) with no way to tell the branches apart. In the CMS this renders every field at once instead of letting the editor pick "Location" (country/region/city) vs "Map" (area radius), and the @format annotations are lost. Add a required `type` literal discriminator ("location" | "map") to each branch so the generated schema is a real discriminated anyOf. The Studio renders a type-discriminated union as a branch selector, restoring the Map-vs-Location choice and letting the @format widgets attach to the right branch. Also flesh out the field descriptions (cf-* header semantics). Runtime matching is unchanged — matchLocation keys off the fields present and ignores the extra `type` discriminator. Co-Authored-By: Claude Opus 4.8 --- website/__tests__/matchers.test.ts | 16 ++++++++++++---- website/matchers/location.ts | 26 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/website/__tests__/matchers.test.ts b/website/__tests__/matchers.test.ts index 54315ec..e69dba3 100644 --- a/website/__tests__/matchers.test.ts +++ b/website/__tests__/matchers.test.ts @@ -215,22 +215,30 @@ describe("MatchHost", () => { describe("MatchLocation", () => { it("matches by country", () => { const ctx = makeCtx({ headers: { "cf-ipcountry": "BR" } }); - expect(MatchLocation({ includeLocations: [{ country: "BR" }] }, ctx)).toBe(true); + expect(MatchLocation({ includeLocations: [{ type: "location", country: "BR" }] }, ctx)).toBe( + true, + ); }); it("does not match wrong country", () => { const ctx = makeCtx({ headers: { "cf-ipcountry": "US" } }); - expect(MatchLocation({ includeLocations: [{ country: "BR" }] }, ctx)).toBe(false); + expect(MatchLocation({ includeLocations: [{ type: "location", country: "BR" }] }, ctx)).toBe( + false, + ); }); it("excludes matching location", () => { const ctx = makeCtx({ headers: { "cf-ipcountry": "BR" } }); - expect(MatchLocation({ excludeLocations: [{ country: "BR" }] }, ctx)).toBe(false); + expect(MatchLocation({ excludeLocations: [{ type: "location", country: "BR" }] }, ctx)).toBe( + false, + ); }); it("matches by city", () => { const ctx = makeCtx({ headers: { "cf-ipcity": "Sao Paulo", "cf-ipcountry": "BR" } }); - expect(MatchLocation({ includeLocations: [{ city: "Sao Paulo" }] }, ctx)).toBe(true); + expect( + MatchLocation({ includeLocations: [{ type: "location", city: "Sao Paulo" }] }, ctx), + ).toBe(true); }); it("returns true when includeLocations is empty", () => { diff --git a/website/matchers/location.ts b/website/matchers/location.ts index 85b1e02..e329d78 100644 --- a/website/matchers/location.ts +++ b/website/matchers/location.ts @@ -7,32 +7,56 @@ export interface Coordinate { radius?: number; } +/** + * A rule that matches by a circular area (haversine radius). + * + * @title Map + */ export interface Map { + /** + * @title Mode + * @default map + * @description Match users inside a circular area on the map. + */ + type: "map"; /** * @title Area selection * @format map * @example -7.27820,-35.97630,2000 + * @description "latitude,longitude,radius_in_meters" for haversine-radius matching. */ - coordinates?: string; + coordinates: string; } /** + * A rule that matches by Cloudflare geo headers (country / region / city). + * + * @title Location * @format location */ export interface Location { + /** + * @title Mode + * @default location + * @description Match users by country, region and city. + */ + type: "location"; /** * @title City * @example São Paulo + * @description Exact city name (case-insensitive) as returned by Cloudflare's cf-ipcity header. */ city?: string; /** * @title Region Code * @example SP + * @description Matches Cloudflare's cf-region-code header — usually the ISO 3166-2 subdivision code (SP, RJ, MG, …); some regions return numeric codes. Full names like "São Paulo" are NOT matched. */ regionCode?: string; /** * @title Country * @example BR + * @description ISO 3166-1 alpha-2 code (BR, US, AR, …) as returned by Cloudflare's cf-ipcountry header. */ country?: string; }