@gfargo/doorman 3.14.2 → 3.15.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": "@gfargo/doorman",
3
- "version": "3.14.2",
3
+ "version": "3.15.1",
4
4
  "description": "Manage firewall rules as code across multiple providers",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -76,7 +76,7 @@ FASTLY_WORKSPACE_ID=workspace_xxx
76
76
  }
77
77
  ```
78
78
 
79
- For Cloudflare or Fastly, add `provider` and `providers` fields instead of `projectId`/`teamId` — and note this switches the *rule* shape too, not just the top-level fields. See [Rule Shape](#rule-shape-minimal) below.
79
+ For Cloudflare or Fastly, add `provider` and `providers` fields instead of `projectId`/`teamId` — and note this switches the _rule_ shape too, not just the top-level fields. See [Rule Shape](#rule-shape-minimal) below.
80
80
 
81
81
  ## Core Workflow
82
82
 
@@ -137,14 +137,14 @@ Two different rule shapes, picked by whether the config has `provider`/`provider
137
137
 
138
138
  Load the relevant reference file for detailed documentation:
139
139
 
140
- | Task | Reference |
141
- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
142
- | Writing rules — full field docs, operators, actions, IP blocking, patterns | [references/rules.md](references/rules.md) |
140
+ | Task | Reference |
141
+ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
142
+ | Writing rules — full field docs, operators, actions, IP blocking, patterns | [references/rules.md](references/rules.md) |
143
143
  | Cloudflare-specific setup, Lists API, managed rule groups, expression translation, limitations | [references/cloudflare.md](references/cloudflare.md) |
144
- | Fastly-specific setup, condition/action mapping, rate-limit signal requirement, limitations | [references/fastly.md](references/fastly.md) |
145
- | GCP Cloud Armor setup, CEL translation, priority model, manual e2e verification runbook | [references/gcp.md](references/gcp.md) |
146
- | Available templates and what they protect against | [references/templates.md](references/templates.md) |
147
- | CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) |
144
+ | Fastly-specific setup, condition/action mapping, rate-limit signal requirement, limitations | [references/fastly.md](references/fastly.md) |
145
+ | GCP Cloud Armor setup, CEL translation, priority model, manual e2e verification runbook | [references/gcp.md](references/gcp.md) |
146
+ | Available templates and what they protect against | [references/templates.md](references/templates.md) |
147
+ | CI/CD integration, automation, export formats, validation in pipelines | [references/cicd.md](references/cicd.md) |
148
148
 
149
149
  ## Principles
150
150
 
@@ -58,25 +58,26 @@ Doorman translates its unified rule format (`conditions`/`enabled`/`action: {typ
58
58
 
59
59
  ### Field Mapping
60
60
 
61
- Cloudflare supports all 15 unified condition fields:
62
-
63
- | Doorman Field | Cloudflare Field | Notes |
64
- | -------------- | ------------------------------ | ------------------------------------------------------------------------------------------ |
65
- | `ip` | `ip.src` | |
66
- | `country` | `ip.geoip.country` | |
67
- | `region` | `ip.geoip.subdivision_1` | |
68
- | `city` | `ip.geoip.city` | |
69
- | `asn` | `ip.geoip.asnum` | |
70
- | `path` | `http.request.uri.path` | |
71
- | `host` | `http.host` | |
72
- | `method` | `http.request.method` | |
73
- | `header` | `http.request.headers["key"]` | Requires `key` (the header name) |
74
- | `query` | `http.request.uri.query` | ⚠️ `key` is currently ignored — matches the whole query string, not one parameter ([doorman#263](https://github.com/gfargo/doorman/issues/263)) |
75
- | `cookie` | `http.cookie["key"]` | Requires `key` (the cookie name) |
76
- | `user_agent` | `http.user_agent` | |
77
- | `referer` | `http.referer` | |
78
- | `scheme` | `ssl` (boolean) | |
79
- | `port` | `cf.edge.server_port` | |
61
+ Cloudflare supports all 16 unified condition fields:
62
+
63
+ | Doorman Field | Cloudflare Field | Notes |
64
+ | -------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `ip` | `ip.src` | |
66
+ | `country` | `ip.geoip.country` | |
67
+ | `region` | `ip.geoip.subdivision_1` | Client geo subdivision (e.g. `"CA"`) — see the legacy-field note below for how this differs from Vercel's own `region` |
68
+ | `city` | `ip.geoip.city` | |
69
+ | `asn` | `ip.geoip.asnum` | |
70
+ | `path` | `http.request.uri.path` | |
71
+ | `host` | `http.host` | |
72
+ | `method` | `http.request.method` | |
73
+ | `header` | `http.request.headers["key"]` | Requires `key` (the header name) |
74
+ | `query` | `http.request.uri.query` | ⚠️ `key` is currently ignored — matches the whole query string, not one parameter ([doorman#263](https://github.com/gfargo/doorman/issues/263)) |
75
+ | `cookie` | `http.cookie["key"]` | Requires `key` (the cookie name) |
76
+ | `user_agent` | `http.user_agent` | |
77
+ | `referer` | `http.referer` | |
78
+ | `scheme` | `ssl` (boolean) | |
79
+ | `port` | `cf.edge.server_port` | |
80
+ | `threat_score` | `cf.threat_score` | Cloudflare bot/attack threat score (0-100) |
80
81
 
81
82
  ### Operator Mapping
82
83
 
@@ -88,16 +89,16 @@ Cloudflare supports all 15 unified operators exactly, no approximation — `not_
88
89
 
89
90
  ### Action Mapping
90
91
 
91
- | Doorman Action | Cloudflare Action | Notes |
92
- | --------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
93
- | `log` | `log` | |
94
- | `deny` | `block` | |
95
- | `challenge` | `managed_challenge` | Cloudflare's `challenge`/`managed_challenge`/`js_challenge` all fold back to unified `challenge` on `download` — a one-way narrowing, not a failure |
96
- | `bypass` | `skip` | |
97
- | `allow` | `allow` | |
98
- | `block` | `block` | Same target as `deny` |
99
- | `rate_limit` | `block` + `ratelimit` config | ⚠️ If `action.rateLimit` is omitted, this silently becomes a plain unconditional `block` rule with no rate-limit effect — no error raised locally |
100
- | `redirect` | `redirect` + `from_value` params | ⚠️ If `action.redirect` is omitted, this silently becomes a rule with no redirect target — no error raised locally |
92
+ | Doorman Action | Cloudflare Action | Notes |
93
+ | -------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
94
+ | `log` | `log` | |
95
+ | `deny` | `block` | |
96
+ | `challenge` | `managed_challenge` | Cloudflare's `challenge`/`managed_challenge`/`js_challenge` all fold back to unified `challenge` on `download` — a one-way narrowing, not a failure |
97
+ | `bypass` | `skip` | |
98
+ | `allow` | `allow` | |
99
+ | `block` | `block` | Same target as `deny` |
100
+ | `rate_limit` | `block` + `ratelimit` config | ⚠️ If `action.rateLimit` is omitted, this silently becomes a plain unconditional `block` rule with no rate-limit effect — no error raised locally |
101
+ | `redirect` | `redirect` + `from_value` params | ⚠️ If `action.redirect` is omitted, this silently becomes a rule with no redirect target — no error raised locally |
101
102
 
102
103
  ## Lists API (Bulk IP Management)
103
104
 
@@ -112,16 +113,19 @@ Without `CLOUDFLARE_ACCOUNT_ID`, IP blocking falls back to individual WAF rules
112
113
 
113
114
  ## Limitations & Differences
114
115
 
115
- `environment`, `ja3_digest`, `ja4_digest` are **legacy-format-only concepts** — they exist on Vercel's native rule type but have no unified-format equivalent at all, so they're not reachable through a `provider`/`providers`-tagged config for *any* provider, not just Cloudflare. Every other unified field maps to something on Cloudflare — see Field Mapping above.
116
+ `protocol`, `target_path`, `environment`, `geo_continent`, `ja3_digest`, `ja4_digest`, `rate_limit_api_id` are **Vercel-native-only concepts** with no Cloudflare equivalent. Unlike what this doc previously claimed, they _are_ reachable through a `provider`/`providers`-tagged config — `UnifiedCondition.field` accepts arbitrary strings, and a Vercel rule using any of them translates straight through into the unified layer unchanged. Cloudflare's translator drops each one from the generated expression with a `TranslationWarning` rather than emitting invalid wirefilter ([doorman#271](https://github.com/gfargo/doorman/issues/271)); if _every_ condition on a rule falls into this set, translation throws rather than syncing a conditionless (match-everything) rule. Two related fields are handled specially, not dropped:
116
117
 
117
- | Feature | Cloudflare | Notes |
118
- | -------------------------- | ------------------------------ | ------------------------------------------------- |
119
- | `matches` (regex) | Enterprise-plan-restricted on some plans | Doorman emits it regardless; Cloudflare's API may reject it — see Operator Mapping above |
120
- | IP Lists (bulk) | Lists API | Needs `accountId`; without it, falls back to individual `ip.src` rules |
121
- | Max custom rules | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100, Enterprise: 125+ |
122
- | Rate limit | Separate phase | `ratelimit` config attached to a `block` action, not a distinct rule type |
123
- | Rule order | Sequential (first match wins) | `priority` is fully honoured — unlike Vercel, which can't reposition rules that already exist remotely |
124
- | Managed rule groups | Supported via `managedRules` | See [Managed Rule Groups](#managed-rule-groups) below — Cloudflare-only among doorman's providers today |
118
+ - Vercel's own `region` (the edge/deployment location a request was served from, e.g. `"sfo1"`) is namespaced internally to `vercel_region` so it can't collide with the unified `region` field in the table above (client geo subdivision) — before [doorman#270](https://github.com/gfargo/doorman/issues/270) the two silently shared one name, so a Vercel edge-region condition became a Cloudflare `ip.geoip.subdivision_1` check that could never match. `vercel_region` itself is still Cloudflare-unsupported and gets dropped-with-warning like the rest of this list.
119
+ - Vercel's `geo_country_region` ("Region/state code", e.g. `"CA"`) _is_ the same concept as the unified `region` field — it maps there directly and translates to `ip.geoip.subdivision_1` normally, unlike the rest of this list.
120
+
121
+ | Feature | Cloudflare | Notes |
122
+ | ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------- |
123
+ | `matches` (regex) | Enterprise-plan-restricted on some plans | Doorman emits it regardless; Cloudflare's API may reject it — see Operator Mapping above |
124
+ | IP Lists (bulk) | Lists API | Needs `accountId`; without it, falls back to individual `ip.src` rules |
125
+ | Max custom rules | 5-125 (plan dependent) | Free: 5, Pro: 20, Business: 100, Enterprise: 125+ |
126
+ | Rate limit | Separate phase | `ratelimit` config attached to a `block` action, not a distinct rule type |
127
+ | Rule order | Sequential (first match wins) | `priority` is fully honoured — unlike Vercel, which can't reposition rules that already exist remotely |
128
+ | Managed rule groups | Supported via `managedRules` | See [Managed Rule Groups](#managed-rule-groups) below — Cloudflare-only among doorman's providers today |
125
129
 
126
130
  ## Translation Warnings
127
131
 
@@ -174,14 +178,14 @@ Cloudflare is the only provider doorman can deploy vendor-managed rulesets (Clou
174
178
  }
175
179
  ```
176
180
 
177
- | Property | Type | Required | Description |
178
- | --- | --- | --- | --- |
179
- | `id` | string | No | Doorman's diff/sync identifier for this deployment. Omit for a new declaration — doorman assigns one on first sync. |
180
- | `ruleset` | string | Yes | The vendor ruleset id to deploy (e.g. Cloudflare Managed Ruleset's well-known id shown above) |
181
- | `name` | string | No | Human label |
182
- | `enabled` | boolean | Yes | Whether this deployment is active |
183
- | `action` | string | No | Ruleset-wide override — downgrade every rule in the group to this action. One of `log`, `deny`, `challenge`, `allow` |
184
- | `overrides` | array | No | Per-rule overrides within the ruleset — `{ "ruleId": string, "action"?: string, "enabled"?: boolean }`, referenced by the *vendor's* rule id within that ruleset, not a doorman id |
181
+ | Property | Type | Required | Description |
182
+ | ----------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
+ | `id` | string | No | Doorman's diff/sync identifier for this deployment. Omit for a new declaration — doorman assigns one on first sync. |
184
+ | `ruleset` | string | Yes | The vendor ruleset id to deploy (e.g. Cloudflare Managed Ruleset's well-known id shown above) |
185
+ | `name` | string | No | Human label |
186
+ | `enabled` | boolean | Yes | Whether this deployment is active |
187
+ | `action` | string | No | Ruleset-wide override — downgrade every rule in the group to this action. One of `log`, `deny`, `challenge`, `allow` |
188
+ | `overrides` | array | No | Per-rule overrides within the ruleset — `{ "ruleId": string, "action"?: string, "enabled"?: boolean }`, referenced by the _vendor's_ rule id within that ruleset, not a doorman id |
185
189
 
186
190
  Managed rule groups deploy in Cloudflare's separate managed-rules phase, evaluated independently of custom `rules` — no ordering interaction to think about between the two. `getChanges`/`syncRules` diff `managedRules` the same way as `rules`/`ips` — `doorman diff`/`doorman sync` cover it with no extra flags.
187
191
 
@@ -115,12 +115,12 @@ Unlike classic Fastly VCL services, Next-Gen WAF rule and list writes take effec
115
115
 
116
116
  ## Limitations & Differences
117
117
 
118
- | Feature | Vercel | Fastly | Notes |
119
- | -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------- |
120
- | Geo targeting | Country/city/continent/region | Country only | No sub-country condition field |
121
- | Rule ordering | Best-effort (insertion order) | None | Next-Gen WAF rules are evaluated independently — `priority` has no effect |
118
+ | Feature | Vercel | Fastly | Notes |
119
+ | -------------------------- | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
120
+ | Geo targeting | Country/city/continent/region | Country only | No sub-country condition field |
121
+ | Rule ordering | Best-effort (insertion order) | None | Next-Gen WAF rules are evaluated independently — `priority` has no effect |
122
122
  | Managed/vendor rule groups | CRS (enterprise) | Templated signal rules | Doorman-managed on Cloudflare only ([cloudflare.md](cloudflare.md)); not yet configurable through Doorman for Vercel or Fastly |
123
- | Signal/exclusion rules | — | `type: signal` | Not managed by Doorman — see below |
123
+ | Signal/exclusion rules | — | `type: signal` | Not managed by Doorman — see below |
124
124
 
125
125
  Fastly rules of type `signal` or `templated_signal` (which tag or exclude WAF signals rather than allow/block/redirect a request) are skipped entirely by `doorman download`/`fetchConfig` rather than forced into a lossy `UnifiedRule` — they simply don't appear in the local config.
126
126
 
@@ -23,7 +23,7 @@ GCP_POLICY_NAME=your-policy-name # Required — Cloud Armor sec
23
23
  2. **Authenticate** — two options:
24
24
  - **Application Default Credentials** (recommended for local/dev use): `gcloud auth application-default login`. Uses your own Google account, no key file to create, store, or later revoke. Leave `serviceAccountKeyPath`/`GOOGLE_APPLICATION_CREDENTIALS` unset — `GoogleAuth` falls through to ADC automatically.
25
25
  - **Service account key** (for CI/production): create a service account with `roles/compute.securityAdmin` (the narrowest predefined role covering `securityPolicies.get/insert/patch/addRule/patchRule/removeRule/list`), generate a JSON key, and point `GOOGLE_APPLICATION_CREDENTIALS`/`serviceAccountKeyPath` at it.
26
- 3. **Create the security policy itself** — Doorman manages rules *within* an existing policy, it does not create the policy resource:
26
+ 3. **Create the security policy itself** — Doorman manages rules _within_ an existing policy, it does not create the policy resource:
27
27
  ```bash
28
28
  gcloud compute security-policies create YOUR_POLICY_NAME --description="managed by doorman"
29
29
  ```
@@ -67,37 +67,37 @@ Cloud Armor rules match on CEL (Common Expression Language) — an expression-st
67
67
 
68
68
  ### Field Mapping
69
69
 
70
- | Doorman Field | Cloud Armor CEL | Notes |
71
- | -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
72
- | `ip` | `origin.ip == '...'` or `inIpRange(origin.ip, '...')` | Bare address uses `==`; a CIDR value (contains `/`) uses `inIpRange()` |
73
- | `path` | `request.path` | |
74
- | `method` | `request.method` | |
75
- | `country` | `origin.region_code` | Country-level only |
76
- | `asn` | `origin.asn` | Numeric comparison |
77
- | `host` | `request.headers['host']` | Guarded with `has(...)` — indexing an absent header key is a CEL runtime error |
78
- | `user_agent` | `request.headers['user-agent']` | Guarded with `has(...)` |
79
- | `referer` | `request.headers['referer']` | Guarded with `has(...)` |
80
- | `header` | `request.headers['<key>']` | Requires `key` (the header name); guarded with `has(...)` |
81
- | `query` | `request.query` | Raw, undecoded query string — no parsed per-parameter map |
82
- | `cookie` | `request.headers['cookie']` | No parsed cookie map — see Cookie Matching below |
70
+ | Doorman Field | Cloud Armor CEL | Notes |
71
+ | ------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ |
72
+ | `ip` | `origin.ip == '...'` or `inIpRange(origin.ip, '...')` | Bare address uses `==`; a CIDR value (contains `/`) uses `inIpRange()` |
73
+ | `path` | `request.path` | |
74
+ | `method` | `request.method` | |
75
+ | `country` | `origin.region_code` | Country-level only |
76
+ | `asn` | `origin.asn` | Numeric comparison |
77
+ | `host` | `request.headers['host']` | Guarded with `has(...)` — indexing an absent header key is a CEL runtime error |
78
+ | `user_agent` | `request.headers['user-agent']` | Guarded with `has(...)` |
79
+ | `referer` | `request.headers['referer']` | Guarded with `has(...)` |
80
+ | `header` | `request.headers['<key>']` | Requires `key` (the header name); guarded with `has(...)` |
81
+ | `query` | `request.query` | Raw, undecoded query string — no parsed per-parameter map |
82
+ | `cookie` | `request.headers['cookie']` | No parsed cookie map — see Cookie Matching below |
83
83
 
84
84
  Fields with **no CEL equivalent at all** (`region`, `city`, `port`, `scheme`) fail loudly with a clear error rather than being silently dropped or approximated — Cloud Armor has no continent/region/city-level geo attribute (only country-level `origin.region_code`), and no per-request port attribute (Cloud Armor operates at L7 behind a load balancer). A rule using one of these doesn't sync at all; there's no partial/lossy version of it to fall back to.
85
85
 
86
86
  ### Cookie Matching
87
87
 
88
- Cloud Armor has no parsed per-cookie map, only the raw `Cookie` header string. Without a `key`, a cookie condition checks the whole header. *With* a `key` (the common case — "is this specific cookie set to this value") Doorman composes a `key=value` substring and searches for it in the raw header — the only thing CEL can actually check. Only `eq`/`ne`/`contains`/`not_contains` have an unambiguous meaning against that synthesized substring; any other operator on a keyed cookie condition throws rather than emitting a check that looks precise but isn't.
88
+ Cloud Armor has no parsed per-cookie map, only the raw `Cookie` header string. Without a `key`, a cookie condition checks the whole header. _With_ a `key` (the common case — "is this specific cookie set to this value") Doorman composes a `key=value` substring and searches for it in the raw header — the only thing CEL can actually check. Only `eq`/`ne`/`contains`/`not_contains` have an unambiguous meaning against that synthesized substring; any other operator on a keyed cookie condition throws rather than emitting a check that looks precise but isn't.
89
89
 
90
90
  ### Action Mapping
91
91
 
92
- | Doorman Action | Cloud Armor Action | Notes |
93
- | --------------- | -------------------- | ----------------------------------------------------------------------- |
94
- | `deny`, `block` | `deny(403)` | |
95
- | `allow` | `allow` | |
96
- | `bypass` | `allow` | |
97
- | `rate_limit` | `throttle` | Requires `rateLimitOptions` — see below |
98
- | `redirect` | `redirect` | Requires `redirectOptions` (target URL) |
99
- | `log` | `allow` + `preview: true` | No dedicated log-only action — evaluates and logs without enforcing |
100
- | `challenge` | `deny(403)` (with a warning) | No standalone challenge action for ordinary custom rules |
92
+ | Doorman Action | Cloud Armor Action | Notes |
93
+ | --------------- | ---------------------------- | ------------------------------------------------------------------- |
94
+ | `deny`, `block` | `deny(403)` | |
95
+ | `allow` | `allow` | |
96
+ | `bypass` | `allow` | |
97
+ | `rate_limit` | `throttle` | Requires `rateLimitOptions` — see below |
98
+ | `redirect` | `redirect` | Requires `redirectOptions` (target URL) |
99
+ | `log` | `allow` + `preview: true` | No dedicated log-only action — evaluates and logs without enforcing |
100
+ | `challenge` | `deny(403)` (with a warning) | No standalone challenge action for ordinary custom rules |
101
101
 
102
102
  ## The Priority Model
103
103
 
@@ -109,13 +109,13 @@ Cloud Armor's real structural difference from every other provider: there is no
109
109
 
110
110
  ## No Dedicated IP-Blocking Resource
111
111
 
112
- Unlike Vercel/Cloudflare/Fastly, Cloud Armor has no separate IP-list resource — an `ips[]` entry is just an ordinary rule under the hood, with a single `ip == X` (or `inIpRange(...)` for CIDR) CEL condition and nothing else layered on. Doorman classifies a fetched rule as an IP entry only when it has *exactly* that shape (one condition, `allow`/`deny(403)` action, nothing else) — a hand-authored `rules[]` entry that happens to match on `ip` alone is left as a rule, not silently reclassified into `ips[]`.
112
+ Unlike Vercel/Cloudflare/Fastly, Cloud Armor has no separate IP-list resource — an `ips[]` entry is just an ordinary rule under the hood, with a single `ip == X` (or `inIpRange(...)` for CIDR) CEL condition and nothing else layered on. Doorman classifies a fetched rule as an IP entry only when it has _exactly_ that shape (one condition, `allow`/`deny(403)` action, nothing else) — a hand-authored `rules[]` entry that happens to match on `ip` alone is left as a rule, not silently reclassified into `ips[]`.
113
113
 
114
114
  ## The Mandatory Default Rule
115
115
 
116
116
  Every real Cloud Armor security policy carries one rule Doorman never manages: a server-injected default catch-all at the maximum priority (`2147483647`), using a different match shape (`versionedExpr`/`config.srcIpRanges`, not CEL) — it cannot be removed, only its action changed. Doorman skips it entirely on fetch; it never appears in your local config and is never a candidate for deletion. If you need to change its action (rarely necessary — it only fires when nothing else matches), use `gcloud compute security-policies describe`/`update` directly.
117
117
 
118
- Any *other* rule using that same basic (non-CEL) match shape — e.g. hand-created via `gcloud ... --src-ip-ranges` or the Console — gets an explicit warning on fetch rather than a crash or a silent drop: Doorman can't represent it, and leaves it unmanaged.
118
+ Any _other_ rule using that same basic (non-CEL) match shape — e.g. hand-created via `gcloud ... --src-ip-ranges` or the Console — gets an explicit warning on fetch rather than a crash or a silent drop: Doorman can't represent it, and leaves it unmanaged.
119
119
 
120
120
  ## Rate Limiting
121
121
 
@@ -127,15 +127,15 @@ Every mutating call (`addRule`/`patchRule`/`removeRule`) returns a long-running
127
127
 
128
128
  ## Limitations & Differences
129
129
 
130
- | Feature | Vercel | GCP Cloud Armor | Notes |
131
- | ----------------------------- | -------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
132
- | Rule ordering | Best-effort (insertion order) | Explicit required `priority` | Lower priority number evaluates first; collisions rejected outright |
133
- | IP blocking | Dedicated resource | Just a specially-shaped `rules[]` entry | No separate list/resource — see above |
134
- | Geo targeting | Country/city/continent/region | Country only (`origin.region_code`) | `region`/`city` fields throw rather than silently drop |
135
- | Port / scheme conditions | Yes | No CEL equivalent | Throw rather than silently drop |
136
- | Managed/vendor rule groups | — | Preconfigured WAF rules exist on the real API | Not yet configurable through Doorman for any provider (tracked separately) |
137
- | Standalone challenge action | Yes | No | Falls back to `deny(403)` with a warning |
138
- | Change a rule's priority | N/A (no priority concept) | Remove + re-add under the new priority | No in-place relocation on the real API |
130
+ | Feature | Vercel | GCP Cloud Armor | Notes |
131
+ | --------------------------- | ----------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
132
+ | Rule ordering | Best-effort (insertion order) | Explicit required `priority` | Lower priority number evaluates first; collisions rejected outright |
133
+ | IP blocking | Dedicated resource | Just a specially-shaped `rules[]` entry | No separate list/resource — see above |
134
+ | Geo targeting | Country/city/continent/region | Country only (`origin.region_code`) | `region`/`city` fields throw rather than silently drop |
135
+ | Port / scheme conditions | Yes | No CEL equivalent | Throw rather than silently drop |
136
+ | Managed/vendor rule groups | — | Preconfigured WAF rules exist on the real API | Not yet configurable through Doorman for any provider (tracked separately) |
137
+ | Standalone challenge action | Yes | No | Falls back to `deny(403)` with a warning |
138
+ | Change a rule's priority | N/A (no priority concept) | Remove + re-add under the new priority | No in-place relocation on the real API |
139
139
 
140
140
  ## Manual End-to-End Verification
141
141
 
@@ -73,30 +73,33 @@ Each condition has:
73
73
 
74
74
  ### Condition Types
75
75
 
76
- | Type | Description | Example Value |
77
- | -------------------- | -------------------------------- | ----------------------- |
78
- | `path` | URL path | `"/api/users"` |
79
- | `method` | HTTP method | `"POST"` |
80
- | `host` | Hostname | `"example.com"` |
81
- | `user_agent` | User-Agent header | `"Googlebot"` |
82
- | `ip_address` | Client IP | `"192.168.1.1"` |
83
- | `header` | HTTP header (requires `key`) | `"application/json"` |
84
- | `query` | Query parameter (requires `key`) | `"true"` |
85
- | `cookie` | Cookie value (requires `key`) | `"session_abc"` |
86
- | `geo_country` | Country code (ISO 3166-1) | `"US"` or `["US","CA"]` |
87
- | `geo_city` | City name | `"New York"` |
88
- | `geo_continent` | Continent code | `"NA"` |
89
- | `geo_country_region` | Region/state code | `"CA"` |
90
- | `geo_as_number` | ASN number | `13335` |
91
- | `scheme` | URL scheme | `"https"` |
92
- | `protocol` | HTTP protocol version | `"HTTP/2"` |
93
-
94
- **Vercel-only types** (not available on Cloudflare):
95
-
76
+ These translate cleanly to the unified format's condition fields (`mapVercelTypeToUnified` in `src/lib/providers/vercel/translator.ts`), so a rule built from them survives migration to Cloudflare or Fastly:
77
+
78
+ | Type | Description | Example Value |
79
+ | --------------- | -------------------------------- | ----------------------- |
80
+ | `path` | URL path | `"/api/users"` |
81
+ | `method` | HTTP method | `"POST"` |
82
+ | `host` | Hostname | `"example.com"` |
83
+ | `user_agent` | User-Agent header | `"Googlebot"` |
84
+ | `ip_address` | Client IP | `"192.168.1.1"` |
85
+ | `header` | HTTP header (requires `key`) | `"application/json"` |
86
+ | `query` | Query parameter (requires `key`) | `"true"` |
87
+ | `cookie` | Cookie value (requires `key`) | `"session_abc"` |
88
+ | `geo_country` | Country code (ISO 3166-1) | `"US"` or `["US","CA"]` |
89
+ | `geo_city` | City name | `"New York"` |
90
+ | `geo_as_number` | ASN number | `13335` |
91
+ | `scheme` | URL scheme | `"https"` |
92
+
93
+ **Vercel-only types** — these round-trip fine between the legacy shape and the unified format (Vercel stays Vercel), but have no _other_ provider's equivalent: migrating to Fastly drops them with a translation warning, while migrating to Cloudflare currently produces a broken filter expression with no warning at all (`mapUnifiedFieldToCloudflare` in `src/lib/translators/ExpressionBuilder.ts` has no entry for them and falls through to the bare field name, which isn't valid Wirefilter syntax) — avoid these in a rule you intend to sync to Cloudflare:
94
+
95
+ - `geo_continent` — continent code (`"NA"`)
96
+ - `geo_country_region` — region/state code (`"CA"`) — distinct from `region` below, not interchangeable
97
+ - `protocol` — HTTP protocol version (`"HTTP/2"`) — distinct from `scheme` above, not interchangeable
98
+ - `target_path` — URL path, alternate to `path` (no unified mapping despite the similar name)
99
+ - `region` — Vercel edge region (e.g. `"sfo1"`)
96
100
  - `environment` — deployment environment (`"production"`, `"preview"`)
97
101
  - `ja3_digest` — TLS fingerprint
98
102
  - `ja4_digest` — TLS fingerprint v4
99
- - `region` — Vercel edge region
100
103
  - `rate_limit_api_id` — rate limit API identifier
101
104
 
102
105
  ### Operators