# Configuration reference

> Desired-file path default, JSON body accepted by PUT /v1/security/firewall/config, and loadDesiredConfig interpolation rules.

- Repository: jaredpalmer/vwaffle
- GitHub: https://github.com/jaredpalmer/vwaffle
- Human docs: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581
- Complete Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms-full.txt

## Source Files

- `src/types.ts`
- `src/config.ts`
- `src/config.test.ts`
- `README.md`

---

---
title: "Configuration reference"
description: "Desired-file path default, JSON body accepted by PUT /v1/security/firewall/config, and loadDesiredConfig interpolation rules."
---

`loadDesiredConfig` reads the desired firewall JSON from disk, expands `${VAR_NAME}` placeholders from `process.env`, and returns the object `putConfig` sends as the body of `PUT /v1/security/firewall/config`. The default path is `firewall.config.json` relative to the process working directory. vwaffle does not validate the file against a schema: it `JSON.parse`s UTF-8, interpolates string fields, then PUTs the result as-is.

## Desired file path

`parseArgs` initializes `CliOptions.config` to `firewall.config.json`. `init`, `plan`, and `apply` resolve that value with `resolve(process.cwd(), options.config)`.

<ParamField body="--config" type="string" default="firewall.config.json">
Alias `-c`. Path to the desired config. Relative paths are resolved from `process.cwd()`, not from a project root or the binary location.
</ParamField>

| Surface | Uses `--config` | Notes |
| --- | --- | --- |
| `vwaffle init` | Yes | Writes the starter object to that path; refuses to overwrite an existing file |
| `vwaffle plan` | Yes | `loadDesiredConfig(path, { strict: false })` |
| `vwaffle apply` | Yes | `loadDesiredConfig(path, { strict: !dryRun })` |
| `vwaffle pull` | No | Writes live config to `--output` / `-o`, or stdout. Independent of `--config` |

The file must be valid JSON (`readJson` is `JSON.parse(await readFile(path, 'utf8'))`). Comments, trailing commas, and non-UTF-8 content fail the parse and surface as `vwaffle: <message>` with exit code 1. A missing file fails at `readFile`.

`init` pretty-prints the starter with tab indentation and a trailing newline. `pull --output` uses the same serialization for the live payload.

## PUT body

`putConfig` sends the interpolated `FirewallConfig` as `JSON.stringify(config)` to:

```text
${VERCEL_API_URL ?? 'https://api.vercel.com'}/v1/security/firewall/config
```

:::endpoint PUT /v1/security/firewall/config Write the interpolated desired config
The CLI path segment appended by `putConfig` is empty, so the request targets that URL directly.

**Query**

| Name | Required | Source |
| --- | --- | --- |
| `projectId` | Yes | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` |
| `teamId` | No | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` |

**Headers**

| Name | Value |
| --- | --- |
| `Authorization` | `Bearer ${VERCEL_TOKEN}` |
| `Content-Type` | `application/json` |

**Body**

The interpolated desired object. Unknown keys are preserved (`FirewallConfig` and nested types use `[key: string]: unknown`). vwaffle does not strip fields returned by `pull`.

**Success shape used by the CLI**

`{ version?: number; [key: string]: unknown } | null`. After a PUT, apply prints `Applied firewall configuration` and appends ` (version N)` when `version` is present.
:::

`apply --yes` skips the PUT when `diff(live, desired)` equals `No drift detected.` `apply --dry-run` never calls the API.

GET `/v1/security/firewall/config/active` is the live counterpart (`getActiveConfig` unwraps `.active` when present). That response is not the desired file; see [Desired vs live config](/desired-vs-live-config).

### Top-level `FirewallConfig`

<ParamField body="firewallEnabled" type="boolean">
Whether the firewall is enabled. Starter config sets `true`.
</ParamField>

<ParamField body="managedRules" type="Record<string, ManagedRule>">
Managed-rule map. Starter and README use `owasp: { active: false }`.
</ParamField>

<ParamField body="crs" type="Record<string, ManagedRule>">
Typed CRS map with the same `ManagedRule` shape. Not present in the `init` starter.
</ParamField>

<ParamField body="rules" type="CustomRule[]">
Custom rules. This is the only array `removeRulesWithMissingEnv` can drop entries from.
</ParamField>

<ParamField body="ips" type="IpRule[]">
IP entries. Starter writes `[]`. Unset `${VAR_NAME}` values here are not dropped in non-strict mode.
</ParamField>

### Nested objects

| Object | Fields |
| --- | --- |
| `ManagedRule` | `active` (boolean), optional `action` (string), plus unknown keys |
| `CustomRule` | optional `id`, `name`, optional `description`, `active`, `conditionGroup[]`, `action.mitigate`, plus unknown keys |
| `ConditionGroup` | `conditions` (`RuleCondition[]`), plus unknown keys |
| `RuleCondition` | `type`, `op`, optional `neg`, optional `key`, optional `value` (`string \| number \| string[]`), plus unknown keys |
| `MitigateAction` | `action` (`log` \| `challenge` \| `deny` \| `bypass` \| `rate_limit` \| `redirect` \| string), optional `rateLimit`, optional `redirect`, optional `actionDuration`, plus unknown keys |
| `rateLimit` | `algo`, `window`, `limit`, `keys`, optional `action`; may be `null` |
| `redirect` | `location`, `permanent`; may be `null` |
| `IpRule` | optional `id`, `ip`, `hostname`, `action` (`deny` \| `challenge` \| `log` \| `bypass` \| string), optional `notes`, plus unknown keys |

Authoring details for conditions, OWASP, and IP lists live on [Firewall rule model](/firewall-rule-model).

### Starter object (`vwaffle init`)

```json
{
	"firewallEnabled": true,
	"managedRules": {
		"owasp": { "active": false }
	},
	"rules": [
		{
			"name": "Block sensitive paths",
			"description": "Deny requests to paths that should never be publicly reachable.",
			"active": true,
			"conditionGroup": [
				{
					"conditions": [{ "type": "path", "op": "pre", "value": "/.git" }]
				}
			],
			"action": { "mitigate": { "action": "deny" } }
		}
	],
	"ips": []
}
```

A typical secret-bearing rule uses a placeholder in a string field:

```json
{
	"name": "Bypass for internal service",
	"active": true,
	"conditionGroup": [
		{
			"conditions": [
				{
					"type": "header",
					"op": "eq",
					"key": "x-internal-token",
					"value": "${INTERNAL_TOKEN}"
				}
			]
		}
	],
	"action": { "mitigate": { "action": "bypass" } }
}
```

## `loadDesiredConfig`

```ts
loadDesiredConfig(configPath: string, { strict }: { strict: boolean }): Promise<InterpolationResult>
```

<ResponseField name="config" type="FirewallConfig">
Interpolated object. In non-strict mode, `rules` entries that still contain unset placeholders may have been removed.
</ResponseField>

<ResponseField name="missing" type="Set<string>">
Entries shaped `NAME ($.jsonPath)` for each unresolved placeholder.
</ResponseField>

<ResponseField name="secrets" type="Map<string, string>">
Resolved variable name → replacement value. Apply uses this map to replace occurrences with `[REDACTED]` in `--dry-run` JSON and in the apply diff. Plan discards this map and prints the raw diff.
</ResponseField>

```mermaid
flowchart TD
  subgraph file [Desired file]
    Path["resolve(cwd, --config)"]
    Read["readJson / JSON.parse"]
  end
  subgraph interp [interpolate]
    Walk["Recurse strings, arrays, objects"]
    Env["Replace ${NAME} from process.env"]
    Miss["missing.add NAME ($.path)"]
    Sec["secrets.set NAME, value"]
  end
  subgraph policy [strict]
    HasMissing{"missing.size > 0?"}
    Throw["throw if strict"]
    Warn["warn + removeRulesWithMissingEnv"]
    Out["return InterpolationResult"]
  end
  Path --> Read --> Walk --> Env
  Env --> Miss
  Env --> Sec
  Miss --> HasMissing
  HasMissing -->|yes and strict| Throw
  HasMissing -->|yes and not strict| Warn --> Out
  HasMissing -->|no| Out
```

### Interpolation rules

`interpolate` walks the parsed JSON with a JSONPath-style cursor that starts at `$`.

| Input | Behavior |
| --- | --- |
| String | Global replace of `/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g` |
| Array | Recurse each element at `$[i]` / `$.key[i]` |
| Object | Recurse each value at `$.key` |
| Number, boolean, `null` | Returned unchanged |

Replacement source is `process.env`. `loadDesiredConfig` does not accept an env overlay, `--env-file`, or nested expressions.

| Placeholder result | Effect |
| --- | --- |
| `env[name]` is a string, including `""` | Substitute the value; `secrets.set(name, value)` |
| `env[name]` is `undefined` | Leave `${NAME}` in the string; `missing.add("NAME ($.path)")` |

Names must match `[A-Za-z_][A-Za-z0-9_]*`. `${FOO-BAR}`, `${123}`, and `${process.env.FOO}` are not placeholders.

Missing-path examples from tests and the walker:

| Location | Recorded as |
| --- | --- |
| Root string | `NAME ($)` |
| Object field | `NAME ($.a)` |
| Array element | `NAME ($.a[0])` |
| Nested rule value | `NAME ($.rules[1].conditionGroup[0].conditions[0].value)` |

### Strict versus skip

| Caller | `strict` | Unset `${VAR_NAME}` |
| --- | --- | --- |
| `vwaffle plan` | `false` | Warn and drop matching **rules** |
| `vwaffle apply --dry-run` | `false` | Same as plan, then print `redacted(config, secrets)` |
| `vwaffle apply --yes` | `true` | Throw before `resolveContext` / PUT |

Non-strict warning:

```text
vwaffle: warning: skipping rules that reference unset variables: GONE ($.rules[1])
```

Strict error:

```text
missing environment variables: GONE ($.rules[1]). Set them before applying the firewall configuration.
```

`apply` without `--yes` or `--dry-run` throws `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` before `loadDesiredConfig` runs.

### `removeRulesWithMissingEnv`

Runs only when `missing.size > 0` and `config.rules` is an array.

1. Take the first token of each missing entry (`GONE ($.rules[1])` → `GONE`).
2. `JSON.stringify` each rule.
3. Drop the rule if that text includes `` `${GONE}` `` (the still-unresolved placeholder).
4. If any rule was dropped, return `{ ...config, rules }`. Otherwise return the same object.

<Warning>
Only `rules` is filtered. Unset placeholders in `ips`, `managedRules`, `crs`, or other string fields stay in the object during `plan` and `apply --dry-run`. `apply --yes` still fails because `missing` is non-empty.
</Warning>

A rule that does not contain the unresolved placeholder is kept even when another rule or field recorded that variable as missing.

### Redaction of interpolated values

`redacted(value, secrets)` is `JSON.stringify(value, null, '\t')` with every **truthy** secret substring replaced by `[REDACTED]`. Empty-string replacements are stored in `secrets` but not redacted.

`apply --yes` runs the same replacement over the live-versus-desired diff text. `plan` does not.

## Load failures

| Condition | Result |
| --- | --- |
| Desired file missing | `readFile` error, exit 1 |
| Invalid JSON | `JSON.parse` error, exit 1 |
| `init` target already exists | `FILE already exists; refusing to overwrite.` |
| Unset placeholders, `strict: true` | `missing environment variables: …` |
| Unset placeholders, `strict: false` | Warning; `rules` that still contain those placeholders are omitted |
| PUT rejected | `Vercel API <status> <statusText>: <body>` from `request` |

<RequestExample>
```bash
vwaffle plan -c firewall.config.json
vwaffle apply --dry-run -c firewall.config.json
vwaffle apply --yes -c firewall.config.json
```
</RequestExample>

<ResponseExample>
```text
# apply --dry-run after interpolating INTERNAL_TOKEN
{
	"firewallEnabled": true,
	"rules": [
		{
			"name": "Bypass for internal service",
			"action": { "mitigate": { "action": "bypass" } },
			"conditionGroup": [
				{
					"conditions": [
						{
							"type": "header",
							"op": "eq",
							"key": "x-internal-token",
							"value": "[REDACTED]"
						}
					]
				}
			]
		}
	]
}
```
</ResponseExample>

## Related pages

<CardGroup>
  <Card title="Desired vs live config" href="/desired-vs-live-config">
    How the local file relates to GET /active and the PUT body.
  </Card>
  <Card title="Secret interpolation" href="/secret-interpolation">
    Placeholder expansion, skip-versus-fail, and `[REDACTED]` output.
  </Card>
  <Card title="Firewall rule model" href="/firewall-rule-model">
    `rules`, `managedRules`, `conditionGroup`, mitigate actions, and `ips`.
  </Card>
  <Card title="Environment variables" href="/environment-variables">
    `VERCEL_TOKEN`, project/team IDs, and interpolated `${VAR_NAME}` values.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `--config`, `--output`, `--dry-run`, `--yes`, and the other flags.
  </Card>
  <Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
    Unset variables on plan versus apply, and `plan --check` exit 1.
  </Card>
</CardGroup>
