# Desired vs live config

> How the local desired JSON relates to the active Vercel Firewall config fetched and written by the API client.

- 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/config.ts`
- `src/api.ts`
- `src/types.ts`
- `src/index.ts`

---

---
title: "Desired vs live config"
description: "How the local desired JSON relates to the active Vercel Firewall config fetched and written by the API client."
---

vwaffle keeps two `FirewallConfig` objects in play: the **desired** config from a local JSON file after `loadDesiredConfig`, and the **live** (active) config from `getActiveConfig`. `plan` and `apply --yes` compare them with `diff(live, desired)`. `apply --yes` then `PUT`s the interpolated desired object as the full request body. The client does not compute a patch or merge live fields into the payload.

## Two representations

| | Desired | Live (active) |
| --- | --- | --- |
| Store | Local file, default `firewall.config.json` | Vercel Firewall API |
| Loader | `loadDesiredConfig(path, { strict })` | `getActiveConfig(context)` |
| Writer | `init` (starter) or `pull --output` (copy of live) | `putConfig(context, config)` |
| Transforms | `JSON.parse`, `${VAR}` interpolation, optional rule drop | Unwrap `response.active` when present |
| Used by | `plan`, `apply` | `pull`, `plan`, `apply --yes` |

Both sides share the same TypeScript shape (`FirewallConfig`): `firewallEnabled`, `managedRules`, `crs`, `rules`, `ips`, plus an index signature so extra API keys pass through. Runtime load is `JSON.parse` cast to that type. There is no schema validation.

<ParamField body="config" type="string" default="firewall.config.json">
Path to the desired file. Set with `-c` / `--config`. Resolved as `resolve(process.cwd(), options.config)`.
</ParamField>

<ParamField body="FirewallConfig" type="object">
Shared body for the desired file, the unwrapped GET result, and the PUT payload. Optional keys: `firewallEnabled`, `managedRules`, `crs`, `rules`, `ips`. `CustomRule` and `IpRule` may include a server-assigned `id`.
</ParamField>

## Ownership and data flow

Desired is repo-owned JSON. Live is whatever the API returns for the resolved project. Interpolation and rule-skipping happen only on the desired path. Live is never interpolated.

```mermaid
flowchart LR
  subgraph Local["Local workspace"]
    File["firewall.config.json"]
    Cwd["process.cwd() + --config"]
  end

  subgraph CLI["vwaffle"]
    Load["loadDesiredConfig"]
    Interp["interpolate"]
    Drop["removeRulesWithMissingEnv"]
    Get["getActiveConfig"]
    DiffFn["diff(live, desired)"]
    Put["putConfig"]
  end

  subgraph API["Vercel Firewall API"]
    Active["GET .../config/active"]
    Replace["PUT .../config"]
  end

  Cwd --> File
  File --> Load
  Load --> Interp
  Interp --> Drop
  Drop --> DiffFn
  Get --> DiffFn
  Active --> Get
  DiffFn -->|"apply --yes and drifted"| Put
  Put --> Replace
```

`apply --dry-run` stops after `loadDesiredConfig` and prints the redacted desired payload. It does not call `resolveContext`, `getActiveConfig`, or `putConfig`.

## Desired config

`init` writes `STARTER_CONFIG` (tab-indented JSON plus a trailing newline) and refuses to overwrite an existing file. `pull --output FILE` can seed or replace a desired file with the current live object.

### Load path

`loadDesiredConfig` always:

1. Reads the file with `readJson` (`readFile` + `JSON.parse`).
2. Walks the tree with `interpolate` against `process.env`.
3. Records missing names as `NAME ($.json.path)` and interpolated values in a `secrets` map.

Then it branches on `strict`:

| Caller | `strict` | Missing `${VAR}` |
| --- | --- | --- |
| `plan` | `false` | Warn on stderr; drop matching `rules` entries; continue |
| `apply --dry-run` | `false` | Same as `plan` |
| `apply --yes` | `true` | Throw: `missing environment variables: …` |

`removeRulesWithMissingEnv` filters only `config.rules`. A missing variable in `ips`, `managedRules`, or another field stays as the literal `${VAR}` on non-strict loads and still fails a strict apply.

The object returned as `config` is what `plan` diffs and what `apply` would PUT. It is not a byte-for-byte copy of the file.

### File vs loaded desired

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

After a successful interpolate, `value` is the environment string. CLI output (`apply --dry-run`, the apply diff) replaces those secret strings with `[REDACTED]`. Live JSON from `pull` has no placeholders; it contains whatever the API last stored.

## Live config

Live always comes from the Firewall API client after `resolveContext` supplies `VERCEL_TOKEN`, `projectId`, and optional `teamId`.

:::endpoint GET /v1/security/firewall/config/active
Fetch the active firewall configuration for the resolved project.

**Base URL:** `process.env.VERCEL_API_URL` or `https://api.vercel.com`.

**Query**

| Name | Required | Source |
| --- | --- | --- |
| `projectId` | yes | `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json` `projectId` |
| `teamId` | no | `--team`, `VERCEL_TEAM_ID`, or `.vercel/project.json` `orgId` |

**Headers:** `Authorization: Bearer <VERCEL_TOKEN>`, `Content-Type: application/json`.

**Client unwrap:** `getActiveConfig` returns `response.active` when that property is present; otherwise the whole JSON body is treated as `FirewallConfig`. Wrapper keys such as a GET-side `version` are not part of the compared live object when `active` exists.
:::

:::endpoint PUT /v1/security/firewall/config
Write the desired `FirewallConfig` as the JSON body.

Same base URL, query params, and headers as GET. Path suffix is empty (`putConfig` calls `request('PUT', '', …)`).

**Body:** the interpolated desired object, not a live-vs-desired delta.

**Success signal:** `Applied firewall configuration` and, when the response includes it, `(version N)`.
:::

A non-OK response throws `Vercel API <status> <statusText>: <body>`.

## Command matrix

| Command | Reads desired | Reads live | Writes |
| --- | --- | --- | --- |
| `init` | no (refuses if file exists) | no | starter desired file |
| `pull` | no | yes | stdout, or `--output` file |
| `pull --output firewall.config.json` | no | yes | overwrites the desired path with unwrapped live JSON |
| `plan` / `plan --check` | yes, `strict: false` | yes | none (`--check` sets `process.exitCode = 1` on drift) |
| `apply --dry-run` | yes, `strict: false` | no | none (prints redacted desired JSON) |
| `apply --yes` | yes, `strict: true` | yes | PUT desired body when `diff` is not `No drift detected.` |

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

## Comparison

`diff(live, desired)` stringifies both values with `JSON.stringify(value ?? null, null, '\t')` and runs a line LCS. Identical strings return `No drift detected.` Otherwise the header is:

```text
--- live firewall configuration
+++ desired firewall configuration
```

Minus lines are live; plus lines are desired. `apply --yes` redacts secret values in that text, then returns without PUT when the result is `No drift detected.`

<Warning>
Equality is serialized JSON, not a semantic firewall model. Key order, extra live fields (including rule `id`s from a dashboard or a prior `pull`), and plan-time dropped rules all count as drift.
</Warning>

Common mismatches:

| Situation | What `plan` sees |
| --- | --- |
| Dashboard edit not in the file | live lines absent from desired |
| File edit not applied | desired lines absent from live |
| `pull` then delete `id` fields | live has `id`, desired does not |
| Unset `${VAR}` on `plan` | those custom rules omitted from desired; live still has them |
| Unset `${VAR}` on `apply --yes` | command fails before GET/PUT |
| Extra keys on the live object | they appear as removals if the file omits them |

`putConfig` sends the desired object only. Fields that exist on live and not on desired are not copied into the PUT body by vwaffle.

## Bootstrap: live into desired

The usual way to make the file match production is to copy live onto the desired path, then add placeholders where secrets should not be committed.

<Steps>
  <Step title="Fetch live">
    `vwaffle pull --output firewall.config.json` writes the unwrapped active object (tab-indented JSON plus newline). No interpolation runs.
  </Step>
  <Step title="Replace secrets with placeholders">
    Edit string fields to `${VAR_NAME}`. The file now differs from live until interpolate restores the same values.
  </Step>
  <Step title="Confirm">
    With those variables set, `vwaffle plan` should print `No drift detected.` if live still matches the interpolated file.
  </Step>
</Steps>

`init` does not fetch live. Its starter (`firewallEnabled: true`, `managedRules.owasp.active: false`, one `/.git` deny rule, empty `ips`) will drift against any real project until you edit or overwrite it.

## Errors that mix the two sides

| Failure | When | Effect |
| --- | --- | --- |
| Missing `VERCEL_TOKEN` / project | `pull`, `plan`, `apply --yes` | Throws before any config compare |
| Missing desired file | `plan`, `apply` | `readFile` / parse error from `loadDesiredConfig` |
| Missing env (strict) | `apply --yes` | Throws; live is not fetched |
| Missing env (non-strict) | `plan`, `apply --dry-run` | Warning; compare or print a possibly reduced desired |
| API error | GET or PUT | `Vercel API <status> <statusText>: …` |
| Drift in CI | `plan --check` | Prints the diff and sets exit code `1` |

## Related pages

<CardGroup>
  <Card title="Firewall API client" href="/firewall-api-client">
    `resolveContext`, `getActiveConfig`, `putConfig`, and the `/v1/security/firewall/config` request helper.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Desired-file default path, PUT body shape, and `loadDesiredConfig` rules.
  </Card>
  <Card title="Secret interpolation" href="/secret-interpolation">
    `${VAR_NAME}` expansion, rule dropping, and `[REDACTED]` output.
  </Card>
  <Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
    `--check`, `--dry-run`, `--yes`, and when PUT is skipped.
  </Card>
  <Card title="Diff output" href="/diff-output">
    How `diff` renders live-versus-desired lines.
  </Card>
  <Card title="Scaffold and pull a config" href="/scaffold-and-pull">
    `init` starter file versus `pull --output`.
  </Card>
</CardGroup>
