# vwaffle Documentation

> CLI for treating Vercel Firewall, WAF, and bot-protection configuration as versioned JSON. Covers install, project context, secret interpolation, pull/plan/apply, the Firewall API client, and CI drift checks.

## Context Links

- [Agent index](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/llms.txt)
- [Human interactive docs](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581)
- [GitHub repository](https://github.com/jaredpalmer/vwaffle)

## Repository Metadata

- Repository: jaredpalmer/vwaffle

- Generated: 2026-08-13T01:30:01.592Z
- Updated: 2026-08-13T01:33:20.954Z
- Runtime: Grok CLI
- Format: Documentation
- Pages: 22

## Page Index

- 01. [Overview](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/01-overview.md) - What vwaffle exposes, who should run it, the vwaffle binary entry point, and the shortest pull / plan / apply path.
- 02. [Installation](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/02-installation.md) - Node >=18, global npm install, npx and bunx one-shot invocation, and the published dist/index.js binary.
- 03. [Quickstart](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/03-quickstart.md) - Set VERCEL_TOKEN, scaffold or pull firewall.config.json, preview with plan, then apply --yes, including the first success signal.
- 04. [Desired vs live config](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/04-desired-vs-live-config.md) - How the local desired JSON relates to the active Vercel Firewall config fetched and written by the API client.
- 05. [Project and team context](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/05-project-and-team-context.md) - How resolveContext selects VERCEL_TOKEN, project ID, and team ID from flags, environment variables, and .vercel/project.json.
- 06. [Secret interpolation](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/06-secret-interpolation.md) - How ${VAR_NAME} placeholders are expanded, when missing variables drop rules versus fail apply, and how values are redacted in CLI output.
- 07. [Plan and apply lifecycle](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/07-plan-and-apply-lifecycle.md) - plan versus apply, --check drift exit codes, --dry-run payload print, and the --yes confirmation required to PUT.
- 08. [Firewall rule model](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/08-firewall-rule-model.md) - Repo-backed shape of firewallEnabled, managedRules, rules, conditionGroup, mitigate actions, and ips entries sent to Vercel.
- 09. [Scaffold and pull a config](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/09-scaffold-and-pull-a-config.md) - Write a starter firewall.config.json with init, or fetch the live config with pull and --output.
- 10. [Author firewall rules](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/10-author-firewall-rules.md) - Edit the desired JSON for custom rules, OWASP managed rules, IP denylists, and environment placeholders without committing secrets.
- 11. [Preview and apply changes](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/11-preview-and-apply-changes.md) - Diff live versus desired with plan, inspect a redacted payload with apply --dry-run, then PUT with apply --yes.
- 12. [Detect drift in CI](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/12-detect-drift-in-ci.md) - Run plan --check so a dashboard edit that diverges from the versioned file fails the build.
- 13. [Apply from CI](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/13-apply-from-ci.md) - Promote firewall.config.json as source of truth by running apply --yes on merge with project, team, and token inputs.
- 14. [CLI reference](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/14-cli-reference.md) - vwaffle commands init, pull, plan, apply, and help, plus flags --config, --output, --check, --dry-run, --yes, --project, and --team.
- 15. [Configuration reference](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/15-configuration-reference.md) - Desired-file path default, JSON body accepted by PUT /v1/security/firewall/config, and loadDesiredConfig interpolation rules.
- 16. [Environment variables](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/16-environment-variables.md) - VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID, and arbitrary ${VAR_NAME} values interpolated into string fields.
- 17. [Firewall API client](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/17-firewall-api-client.md) - resolveContext, getActiveConfig, putConfig, request, and requestUrl used to read and write the Vercel Firewall config.
- 18. [Diff output](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/18-diff-output.md) - How diff, diffLines, lcsMatrix, and withContext render live-versus-desired changes, including redacted secret values.
- 19. [Authentication and context errors](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/19-authentication-and-context-errors.md) - Missing VERCEL_TOKEN, unresolved project or team IDs, and failed Firewall API requests from resolveContext and request.
- 20. [Interpolation and check failures](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/20-interpolation-and-check-failures.md) - Unset ${VAR_NAME} behavior on plan versus apply, rules skipped by removeRulesWithMissingEnv, and plan --check exit 1 on drift.
- 21. [Build and test](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/21-build-and-test.md) - Bun scripts for typecheck, bun test, bun build of src/index.ts to dist, and the local bun run src/index.ts dev path.
- 22. [Contributing](https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/22-contributing.md) - prepublishOnly gates, Node engine, Apache-2.0 package metadata, and the files published as the vwaffle CLI.

## Source File Index

- `package.json`
- `README.md`
- `src/api.ts`
- `src/config.test.ts`
- `src/config.ts`
- `src/diff.ts`
- `src/index.ts`
- `src/types.ts`

---

## 01. Overview

> What vwaffle exposes, who should run it, the vwaffle binary entry point, and the shortest pull / plan / apply path.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/01-overview.md
- Generated: 2026-08-13T01:21:23.152Z

### Source Files

- `package.json`
- `src/index.ts`
- `src/api.ts`
- `README.md`

---
title: "Overview"
description: "What vwaffle exposes, who should run it, the vwaffle binary entry point, and the shortest pull / plan / apply path."
---

`vwaffle` is a Node CLI that treats a local `firewall.config.json` as the desired Vercel Firewall config. The published binary is `dist/index.js` (`bin.vwaffle`); source entry is `src/index.ts` (`#!/usr/bin/env node`). Commands read or write `GET`/`PUT /v1/security/firewall/config` after resolving `VERCEL_TOKEN`, project ID, and optional team ID.

<Note>
`init` writes a starter file and does not call Vercel. `pull`, `plan`, and `apply` require `VERCEL_TOKEN` plus a project ID. `apply` without `--yes` or `--dry-run` throws.
</Note>

## Who runs it

Use `vwaffle` when the Vercel Firewall should live in git and change through a reviewed file rather than dashboard-only edits.

| Operator | Typical command |
| --- | --- |
| Author a first file | `vwaffle init` or `vwaffle pull --output firewall.config.json` |
| Preview a local edit | `vwaffle plan` |
| Fail CI on dashboard drift | `vwaffle plan --check` |
| Push the file as source of truth | `vwaffle apply --yes` |
| Inspect the interpolated PUT body | `vwaffle apply --dry-run` |

Package metadata: `name` `vwaffle`, `version` `0.1.0`, `engines.node` `>=18`, license Apache-2.0. Published tarball contents are `dist`, `README.md`, and `LICENSE`.

## Binary and commands

`parseArgs(process.argv.slice(2))` accepts one command and the flags below. Unknown commands and flags fail with `process.exitCode = 1`.

| Command | API | Default file | Exit / guard |
| --- | --- | --- | --- |
| `init` | none | writes `--config` (default `firewall.config.json`) | refuses to overwrite an existing path |
| `pull` | `GET …/active` | stdout, or `--output FILE` | needs token + project |
| `plan` | `GET …/active` | reads `--config` | `--check` sets `exitCode = 1` when the diff is not `No drift detected.` |
| `apply` | `GET …/active`, then `PUT …` | reads `--config` | requires `--yes` or `--dry-run`; no PUT when there is no drift |
| `help` / `-h` / `--help` | none | — | prints usage |
| `version` / `-v` / `--version` | none | — | prints `0.1.0` |

<ParamField body="--config" type="string" required={false}>
Path to the desired JSON. Short flag `-c`. Default `firewall.config.json`, resolved from `process.cwd()`.
</ParamField>

<ParamField body="--output" type="string" required={false}>
`pull` only. Short flag `-o`. Write the live config instead of printing it.
</ParamField>

<ParamField body="--project" type="string" required={false}>
Vercel project ID. Overrides `VERCEL_PROJECT_ID` and `.vercel/project.json` `projectId`.
</ParamField>

<ParamField body="--team" type="string" required={false}>
Vercel team ID. Overrides `VERCEL_TEAM_ID` and `.vercel/project.json` `orgId`.
</ParamField>

<ParamField body="--check" type="boolean" required={false}>
`plan` only. Non-zero exit when live JSON differs from interpolated desired JSON.
</ParamField>

<ParamField body="--dry-run" type="boolean" required={false}>
`apply` only. Print the redacted desired payload and return. No `resolveContext`, no GET, no PUT.
</ParamField>

<ParamField body="--yes" type="boolean" required={false}>
`apply` only. Required to PUT unless `--dry-run` is set.
</ParamField>

## Runtime layout

:::files
package.json          bin.vwaffle → ./dist/index.js; engines.node >=18
src/index.ts          parseArgs, init / pull / plan / apply, help, version
src/api.ts            resolveContext, getActiveConfig, putConfig
src/config.ts         loadDesiredConfig, interpolate, redacted
src/diff.ts           diff(live, desired); NO_DRIFT
src/types.ts          FirewallConfig, CliOptions, ResolvedContext
dist/index.js         published Node target (bun build --target node)
:::

```mermaid
flowchart LR
  subgraph cwd [Working directory]
    CFG["firewall.config.json"]
    LINK[".vercel/project.json"]
  end
  subgraph bin ["vwaffle → dist/index.js"]
    IDX["src/index.ts"]
    CFGLOAD["src/config.ts"]
    DIFF["src/diff.ts"]
    API["src/api.ts"]
  end
  subgraph vercel ["VERCEL_API_URL or api.vercel.com"]
    GET["GET /v1/security/firewall/config/active"]
    PUT["PUT /v1/security/firewall/config"]
  end
  CFG --> CFGLOAD
  LINK --> API
  IDX --> CFGLOAD
  IDX --> DIFF
  IDX --> API
  API --> GET
  API --> PUT
```

`resolveContext` requires `VERCEL_TOKEN`. Project ID is `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId`. Team ID is `--team`, then `VERCEL_TEAM_ID`, then `orgId` from the same file; team is optional and omitted from the query string when unset. The API base is `${VERCEL_API_URL ?? 'https://api.vercel.com'}/v1/security/firewall/config`. Requests send `Authorization: Bearer <token>`. `getActiveConfig` unwraps a `{ active }` envelope when present.

## Desired file

The desired file is the JSON body sent on PUT. `FirewallConfig` fields used by the starter and types:

| Key | Role |
| --- | --- |
| `firewallEnabled` | enable/disable the firewall |
| `managedRules` | managed sets such as `owasp: { active }` |
| `rules` | custom rules (`name`, `active`, `conditionGroup`, `action.mitigate`) |
| `ips` | IP entries (`ip`, `hostname`, `action`, optional `notes`) |
| `crs` | optional CRS managed-rule map |

String values may contain `${VAR_NAME}` (`[A-Za-z_][A-Za-z0-9_]*`). `interpolate` expands them from the environment and records values for redaction as `[REDACTED]`.

| Command | `loadDesiredConfig` `strict` | Missing `${VAR}` |
| --- | --- | --- |
| `plan` | `false` | warn, drop matching `rules` entries, continue |
| `apply --dry-run` | `false` | same as `plan` |
| `apply --yes` | `true` | throw `missing environment variables: …` |

## Shortest pull / plan / apply path

<Steps>
<Step title="Set a token and project">
Export `VERCEL_TOKEN`. Supply a project with `--project`, `VERCEL_PROJECT_ID`, or a prior `vercel link` (`.vercel/project.json`). Add `--team` / `VERCEL_TEAM_ID` when the token is team-scoped.
</Step>
<Step title="Create or fetch the file">
`vwaffle init` writes the bundled starter (OWASP managed rule off, one `/.git` deny rule, empty `ips`) and refuses to overwrite. `vwaffle pull --output firewall.config.json` writes the live active config instead.
</Step>
<Step title="Preview">
`vwaffle plan` diffs live JSON against interpolated desired JSON. Identical configs print `No drift detected.` Drift prints a tab-indented unified-style patch headed `--- live firewall configuration` / `+++ desired firewall configuration`.
</Step>
<Step title="Apply">
`vwaffle apply --yes` reprints the redacted diff, then PUTs unless the result is `No drift detected.` Success prints `Applied firewall configuration` and appends `(version N)` when the API returns `version`.
</Step>
</Steps>

<RequestExample>
```sh
export VERCEL_TOKEN=...
vwaffle pull --output firewall.config.json
vwaffle plan
vwaffle apply --yes
```
</RequestExample>

<ResponseExample>
```text
No drift detected.
```
</ResponseExample>

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

## Failure signals

| Condition | Message / exit |
| --- | --- |
| Missing token | `VERCEL_TOKEN is required. …` |
| Missing project | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` …` |
| Existing init target | `<config> already exists; refusing to overwrite.` |
| Unset vars on apply | `missing environment variables: NAME ($.path), …` |
| HTTP error | `Vercel API <status> <statusText>: <body>` |
| `plan --check` drift | printed diff; `process.exitCode = 1` |
| Unknown command / flag | `unknown command …` / `unknown option …`; `exitCode = 1` |

Install and invoke the published binary on the [Installation](/installation) page. Walk the first successful apply on [Quickstart](/quickstart).

## Next

<CardGroup>
<Card title="Installation" href="/installation">
Node >=18, global npm install, npx/bunx, and `dist/index.js`.
</Card>
<Card title="Quickstart" href="/quickstart">
Token, scaffold or pull, plan, apply --yes, first success signal.
</Card>
<Card title="Project and team context" href="/project-context">
How resolveContext picks token, project ID, and team ID.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
--check, --dry-run, --yes, and when PUT runs.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands, flags, and help text as implemented.
</Card>
</CardGroup>

---

## 02. Installation

> Node >=18, global npm install, npx and bunx one-shot invocation, and the published dist/index.js binary.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/02-installation.md
- Generated: 2026-08-13T01:22:06.004Z

### Source Files

- `package.json`
- `src/index.ts`
- `README.md`

---
title: "Installation"
description: "Node >=18, global npm install, npx and bunx one-shot invocation, and the published dist/index.js binary."
---

`vwaffle` 0.1.0 is an ESM Node CLI. `package.json` maps the `vwaffle` bin to `./dist/index.js`, declares `engines.node` as `>=18`, and publishes only `dist`, `README.md`, and `LICENSE`. The published file is a Bun-bundled Node target (`bun build src/index.ts --outdir dist --target node`) with shebang `#!/usr/bin/env node`. There are no runtime `dependencies`.

## Requirements

| Constraint | Value |
| --- | --- |
| Runtime | Node.js `>=18` (`package.json` `engines`) |
| Module format | `"type": "module"` (ESM) |
| Production npm deps | none |
| License | Apache-2.0 |
| Token for install | not required |

<ParamField body="node" type="string" required>
Declared engine: `>=18`. The CLI uses `node:` built-ins and `fetch`. npm warns on older Node unless `engine-strict` is set.
</ParamField>

<ParamField body="bin.vwaffle" type="string" required>
Published entry: `./dist/index.js`. After a global install this is the `vwaffle` command on `PATH`.
</ParamField>

<Note>
`VERCEL_TOKEN`, `--project` / `VERCEL_PROJECT_ID`, and `--team` / `VERCEL_TEAM_ID` are required only for `pull`, `plan`, and `apply`. `help`, `--version`, and `init` run without Vercel credentials.
</Note>

## Install the CLI

<Tabs>
<Tab title="Global npm">

```sh
npm install -g vwaffle
vwaffle help
```

</Tab>
<Tab title="npx (no install)">

```sh
npx vwaffle help
```

</Tab>
<Tab title="bunx (no install)">

```sh
bunx vwaffle help
```

</Tab>
</Tabs>

<Info>
CI in the README invokes the published bin without a global install: `npx vwaffle plan --check`.
</Info>

## Published package

`files` is the npm tarball allowlist. Source, tests, `tsconfig.json`, and `bun.lock` are not published.

| Field | Value |
| --- | --- |
| `name` | `vwaffle` |
| `version` | `0.1.0` |
| `type` | `module` |
| `bin.vwaffle` | `./dist/index.js` |
| `files` | `dist`, `README.md`, `LICENSE` |
| `prepublishOnly` | `bun run typecheck && bun run test && bun run build` |

:::files
vwaffle@0.1.0 (npm tarball)
├── dist/index.js   # bin name: vwaffle; shebang #!/usr/bin/env node
├── README.md
└── LICENSE
:::

`prepublishOnly` rebuilds `dist/` immediately before publish. A git checkout does **not** contain that file: `.gitignore` lists `dist/`.

## The `dist/index.js` binary

`bun build src/index.ts --outdir dist --target node` emits a single Node entry that inlines `src/index.ts`, `src/api.ts`, `src/config.ts`, and `src/diff.ts`. A local build produces an executable `dist/index.js` (~12.5 KB) that starts with:

```js
#!/usr/bin/env node
```

`src/index.ts` hardcodes `VERSION = '0.1.0'` (same string as `package.json`). `--version` / `-v` prints that value to stdout. Default command when no subcommand is given is `help`.

```text
git checkout                 npm tarball / npx / bunx
─────────────                ────────────────────────
src/index.ts  ── bun build ──►  dist/index.js
  shebang + CLI                  Node ESM, executable
dist/ is gitignored              files[] ships dist only
```

<Warning>
`npm install -g .` or `npm link` from a clone fails until `bun run build` (or `prepublishOnly`) has created `dist/index.js`. Use `bun run src/index.ts` for a source-tree run without a build.
</Warning>

## Verify the install

<Steps>
<Step title="Print help">
Run `vwaffle`, `vwaffle help`, or `vwaffle --help` / `-h`. Expected first line:

```text
vwaffle 0.1.0 — config-as-code for Vercel WAF / Security / Bot Protection
```

Usage line: `vwaffle <command> [options]`.
</Step>
<Step title="Print version">
```sh
vwaffle --version
# or
vwaffle -v
```

Expected stdout: `0.1.0`.
</Step>
</Steps>

<RequestExample>
```sh
npx vwaffle --version
npx vwaffle help
```
</RequestExample>

<ResponseExample>
```text
0.1.0
```
</ResponseExample>

## Commands after install

| Command | Needs Vercel context | Behavior |
| --- | --- | --- |
| `help` (default) | no | Print usage |
| `--version` / `-v` | no | Print `0.1.0` |
| `init` | no | Write starter `firewall.config.json` |
| `pull` | yes | GET live firewall config |
| `plan` | yes | Diff live vs desired JSON |
| `apply --yes` | yes | PUT desired config |

Global flags on the published bin: `-c` / `--config`, `-o` / `--output`, `--check`, `--dry-run`, `--yes`, `--project`, `--team`, `-h` / `--help`, `-v` / `--version`.

## From a git checkout

Bun is the development toolchain, not a runtime requirement for the published package.

```sh
bun install
bun run build          # writes dist/index.js (Node target)
bun run src/index.ts help
```

| Script | Command |
| --- | --- |
| `build` | `bun build src/index.ts --outdir dist --target node` |
| `dev` | `bun run src/index.ts` |
| `typecheck` | `tsc --noEmit` |
| `test` | `bun test` |

`tsconfig.json` is `noEmit` with `moduleResolution: "bundler"` and `allowImportingTsExtensions`. Typecheck does not produce the bin; only `bun run build` (or `prepublishOnly`) does.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `vwaffle: command not found` | Global bin not on `PATH`, or no install | Re-run `npm install -g vwaffle`, or use `npx` / `bunx` |
| `Cannot find module` / missing `dist/index.js` | Clone or `npm install .` without a build | `bun run build`, or install from the published package |
| npm `EBADENGINE` / engine warning | Node older than 18 | Install Node `>=18` |
| `vwaffle: unknown command …` | Typo or extra positional | `vwaffle help` |
| `vwaffle: unknown option …` | Flag not in the parser | Use documented flags only |
| `vwaffle: VERCEL_TOKEN is required.` | `pull` / `plan` / `apply` without a token | Export `VERCEL_TOKEN`; not an install failure |

Fatal CLI errors go to stderr as `vwaffle: <message>` and set `process.exitCode = 1`.

## Next

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Set `VERCEL_TOKEN`, scaffold or pull `firewall.config.json`, then `plan` and `apply --yes`.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands, flags, and help text for the published `vwaffle` bin.
</Card>
<Card title="Environment variables" href="/environment-variables">
`VERCEL_TOKEN`, project/team IDs, and `${VAR_NAME}` interpolation after the CLI is installed.
</Card>
<Card title="Build and test" href="/build-and-test">
Bun typecheck, `bun test`, and the `src/index.ts` → `dist` bundle used to produce the binary.
</Card>
<Card title="Contributing" href="/contributing">
`prepublishOnly` gates, Node engine, Apache-2.0 metadata, and the files npm publishes.
</Card>
</CardGroup>

---

## 03. Quickstart

> Set VERCEL_TOKEN, scaffold or pull firewall.config.json, preview with plan, then apply --yes, including the first success signal.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/03-quickstart.md
- Generated: 2026-08-13T01:19:41.185Z

### Source Files

- `src/index.ts`
- `src/config.ts`
- `src/api.ts`
- `README.md`

---
title: "Quickstart"
description: "Set VERCEL_TOKEN, scaffold or pull firewall.config.json, preview with plan, then apply --yes, including the first success signal."
---

`vwaffle` reaches a reviewed Firewall `PUT` through `init` or `pull`, `plan`, then `apply --yes`. The default desired file is `firewall.config.json`. `pull`, `plan`, and `apply` require `VERCEL_TOKEN` and a project ID from `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json`.

<Info>
`init` writes a local starter file and does not call Vercel. Every other command in this path uses `resolveContext` and the Firewall API.
</Info>

## Prerequisites

| Input | Required for | Resolution (first match wins) |
| --- | --- | --- |
| `VERCEL_TOKEN` | `pull`, `plan`, `apply` | Environment only. Create a token at `https://vercel.com/account/tokens`. |
| Project ID | `pull`, `plan`, `apply` | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` (`vercel link`) |
| Team ID | Optional API scope | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` |

Install once, or invoke without installing:

<CodeGroup>

```sh title="global install"
npm install -g vwaffle
vwaffle help
```

```sh title="npx"
npx vwaffle help
```

```sh title="bunx"
bunx vwaffle help
```

</CodeGroup>

<ParamField body="VERCEL_TOKEN" type="string" required>
Bearer token sent as `Authorization: Bearer …` on `GET /v1/security/firewall/config/active` and `PUT /v1/security/firewall/config`. Missing token fails before any request.
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Project to target when `--project` is omitted and `.vercel/project.json` is absent.
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Optional `teamId` query parameter. Omitted when no flag, env, or linked `orgId` is present.
</ParamField>

## First run

<Steps>
<Step title="Export the API token">

```sh
export VERCEL_TOKEN=...        # https://vercel.com/account/tokens
```

`resolveContext` throws if this is unset:

```text
VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.
```

</Step>

<Step title="Point at a project">

Use a linked project, or pass IDs explicitly:

```sh
# after `vercel link` — reads .vercel/project.json
vwaffle plan

# or without a link
export VERCEL_PROJECT_ID=prj_...
export VERCEL_TEAM_ID=team_...   # optional
# equivalent flags: --project ID --team ID
```

Missing project fails with:

```text
A project is required. Pass --project, set VERCEL_PROJECT_ID, or run `vercel link` so .vercel/project.json exists.
```

</Step>

<Step title="Get a desired file">

Pick one. Default path is `firewall.config.json` (`-c, --config FILE` overrides it).

<Tabs>
<Tab title="Scaffold with init">

```sh
vwaffle init
```

Writes tab-indented `STARTER_CONFIG` and prints:

```text
Wrote firewall.config.json. Edit it, then run `vwaffle plan`.
```

`init` refuses to overwrite an existing file:

```text
firewall.config.json already exists; refusing to overwrite.
```

Starter body (`src/index.ts`):

```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": []
}
```

</Tab>
<Tab title="Pull the live config">

```sh
vwaffle pull --output firewall.config.json
```

`GET`s `/v1/security/firewall/config/active`, writes the active body, and prints:

```text
Wrote active firewall configuration to firewall.config.json.
```

Omit `--output` to print the same JSON on stdout. The file is the exact JSON body accepted by `PUT /v1/security/firewall/config`.

</Tab>
</Tabs>

</Step>

<Step title="Preview with plan">

```sh
vwaffle plan
```

`plan` loads the desired file with `strict: false`, fetches live config, and prints `diff(live, desired)`. Unset `${VAR_NAME}` placeholders warn and drop the rules that still contain those placeholders:

```text
vwaffle: warning: skipping rules that reference unset variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value)
```

For CI, `vwaffle plan --check` sets `process.exitCode = 1` when the printed result is not `NO_DRIFT`.

</Step>

<Step title="Inspect, then apply">

`apply` without `--yes` or `--dry-run` throws and does not call Vercel:

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

<CodeGroup>

```sh title="inspect redacted payload"
vwaffle apply --dry-run
```

```sh title="PUT the desired config"
vwaffle apply --yes
```

</CodeGroup>

`--dry-run` loads with `strict: false`, prints `redacted(config, secrets)`, and returns. `--yes` loads with `strict: true`, prints the redacted live-versus-desired diff, then `PUT`s only when that diff is not `NO_DRIFT`.

</Step>
</Steps>

## First success signal

On a successful write, `apply --yes` prints the redacted diff, then:

```text
Applied firewall configuration.
```

If the PUT response includes `version`, the same line carries it:

```text
Applied firewall configuration (version 3).
```

That string is the apply success signal. Interpolated secret values appear as `[REDACTED]` in the printed diff and in `--dry-run` JSON.

If the live config already matches the desired file, `apply --yes` still prints the no-drift diff and returns without `PUT`. There is no `Applied firewall configuration` line in that case.

```text
apply
├── missing --yes and --dry-run  →  error, no API call
├── --dry-run                    →  print redacted desired JSON, return
└── --yes
    ├── missing ${VAR}           →  error (strict load)
    ├── diff === NO_DRIFT        →  print diff, no PUT
    └── drift                    →  PUT config  →  Applied firewall configuration[ (version N)].
```

<Check>
First green path: `Wrote firewall.config.json…` or `Wrote active firewall configuration to …`, a `plan` diff, then `Applied firewall configuration` (optionally with a version) after `apply --yes`.
</Check>

## Desired file and secrets

The desired file is the PUT body. String fields may contain `${VAR_NAME}` (`[A-Za-z_][A-Za-z0-9_]*`). Expansion comes from `process.env`; resolved values are tracked as secrets and replaced with `[REDACTED]` in CLI output.

| Command | Missing `${VAR_NAME}` |
| --- | --- |
| `plan`, `apply --dry-run` | Warning, then `removeRulesWithMissingEnv` — rules whose serialized JSON still contain the unset placeholder are dropped so the rest of the plan can run. |
| `apply --yes` | Throws: `missing environment variables: NAME ($.path), …. Set them before applying the firewall configuration.` |

Do not commit the expanded values. Keep placeholders in git and export the variables in the shell that runs `apply --yes`.

<Warning>
`apply --yes` fails closed on unset placeholders. `plan` does not: a local plan can omit rules that CI would reject on apply.
</Warning>

## First-run errors

CLI failures print `vwaffle: <message>` and set `process.exitCode = 1`.

| Symptom | Cause |
| --- | --- |
| `VERCEL_TOKEN is required…` | Token unset. `init` does not need it; `pull` / `plan` / `apply` do. |
| `A project is required…` | No `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json` `projectId`. |
| `apply requires --yes…` | `apply` invoked with neither `--yes` nor `--dry-run`. |
| `<file> already exists; refusing to overwrite.` | `init` will not replace an existing desired file. Use `pull --output` or edit in place. |
| `missing environment variables: …` | `apply --yes` hit an unset `${VAR_NAME}`. |
| `Vercel API <status> <statusText>: …` | Non-OK response from `GET /active` or `PUT` on `/v1/security/firewall/config`. |
| `unknown command <name>. Run \`vwaffle help\`.` | First non-flag argument is not `init`, `pull`, `plan`, `apply`, `help`, or `version`. |

`VERCEL_API_URL` overrides the API host when set; the default base is `https://api.vercel.com/v1/security/firewall/config`. Requests always send `projectId`; `teamId` is added only when resolved.

## Next

<CardGroup>
<Card title="Scaffold and pull a config" href="/scaffold-and-pull">
`init` starter file versus `pull --output`, including overwrite behavior.
</Card>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
`plan` diffs, `apply --dry-run` payloads, and `apply --yes` PUT.
</Card>
<Card title="Project and team context" href="/project-context">
How `resolveContext` picks token, project, and team from flags, env, and `.vercel/project.json`.
</Card>
<Card title="Authentication and context errors" href="/authentication-errors">
Missing token, unresolved project, and Firewall API request failures.
</Card>
</CardGroup>

---

## 04. Desired vs live config

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

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/04-desired-vs-live-config.md
- Generated: 2026-08-13T01:22:22.566Z

### 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>

---

## 05. Project and team context

> How resolveContext selects VERCEL_TOKEN, project ID, and team ID from flags, environment variables, and .vercel/project.json.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/05-project-and-team-context.md
- Generated: 2026-08-13T01:21:52.888Z

### Source Files

- `src/api.ts`
- `src/index.ts`
- `src/types.ts`
- `README.md`

---
title: "Project and team context"
description: "How resolveContext selects VERCEL_TOKEN, project ID, and team ID from flags, environment variables, and .vercel/project.json."
---

`resolveContext` in `src/api.ts` builds the `ResolvedContext` used by every Vercel Firewall API call. It reads `VERCEL_TOKEN` from the process environment, then selects `projectId` and optional `teamId` with first-match `??` precedence: CLI overrides, then environment variables, then `.vercel/project.json` from `vercel link`.

## When resolveContext runs

`parseArgs` stores `--project` and `--team` on `CliOptions`. `cmdPull`, `cmdPlan`, and confirmed `cmdApply` pass that object into `resolveContext(options)`. Extra CLI fields (`command`, `config`, `--yes`, `--check`, `--output`) are ignored.

| Command | Calls `resolveContext` | Notes |
| --- | --- | --- |
| `pull` | Yes, first | Context is required before `getActiveConfig` |
| `plan` / `plan --check` | Yes, after `loadDesiredConfig` | A missing desired file fails before token or project resolution |
| `apply --yes` | Yes, after loading the desired config | Used for `getActiveConfig` and `putConfig` |
| `apply --dry-run` | No | Prints the redacted payload and returns without contacting Vercel |
| `init`, `help`, `version` | No | Local file or usage output only |

<Note>
`apply --dry-run` does not require `VERCEL_TOKEN`, a project ID, or a team ID. Any other `pull` / `plan` / `apply` path that reaches the API does.
</Note>

## ResolvedContext

```ts
interface ResolvedContext {
  token: string;
  projectId: string;
  teamId?: string;
}
```

<ParamField body="token" type="string" required>
Value of `process.env.VERCEL_TOKEN`. Sent as `Authorization: Bearer ${context.token}` on every Firewall request.
</ParamField>

<ParamField body="projectId" type="string" required>
Target Vercel project. Always appended as the `projectId` query parameter.
</ParamField>

<ParamField body="teamId" type="string">
Optional team scope. When truthy, appended as the `teamId` query parameter. Omitted from the URL when unset or empty.
</ParamField>

## Precedence

`resolveContext(overrides)` uses nullish coalescing (`??`), so only `null` and `undefined` fall through. Empty strings do not.

```text
token     : VERCEL_TOKEN
projectId : overrides.project  ??  VERCEL_PROJECT_ID  ??  .vercel/project.json#projectId
teamId    : overrides.team     ??  VERCEL_TEAM_ID     ??  .vercel/project.json#orgId
```

| Field | 1. CLI flag | 2. Environment | 3. Linked project | Required |
| --- | --- | --- | --- | --- |
| Token | none | `VERCEL_TOKEN` | none | Yes |
| Project | `--project ID` | `VERCEL_PROJECT_ID` | `projectId` | Yes |
| Team | `--team ID` | `VERCEL_TEAM_ID` | `orgId` | No |

There is no `--token` flag and no fallback to Vercel CLI login credentials. Team resolution maps the linked file's `orgId` key onto `ResolvedContext.teamId`.

<Warning>
`VERCEL_PROJECT_ID=` (empty) wins over `.vercel/project.json` and then fails the required-project check. `VERCEL_TEAM_ID=` wins over `orgId` and is treated as no team when building the request URL (`if (context.teamId)`).
</Warning>

## Token

`VERCEL_TOKEN` is the only accepted credential.

```sh
export VERCEL_TOKEN=...        # https://vercel.com/account/tokens
```

A missing or empty token throws before the linked-project file is considered:

```text
VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.
```

`main` prefixes CLI errors with `vwaffle:` and sets `process.exitCode = 1`:

```text
vwaffle: VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.
```

## Project ID

<ParamField body="--project" type="string">
Explicit project ID. `parseArgs` requires a following value (`--project requires a value`).
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Used when `--project` is omitted.
</ParamField>

<ParamField body="projectId" type="string">
Read from `process.cwd()/.vercel/project.json` when both the flag and env var are unset.
</ParamField>

If all three sources miss, `resolveContext` throws:

```text
A project is required. Pass --project, set VERCEL_PROJECT_ID, or run `vercel link` so .vercel/project.json exists.
```

Help text for `--project` documents the same three sources: flag, `VERCEL_PROJECT_ID`, or `.vercel/project.json`.

## Team ID

Team scope is optional. Personal-account projects can omit `--team`, `VERCEL_TEAM_ID`, and `orgId`.

<ParamField body="--team" type="string">
Explicit team ID. Requires a following value (`--team requires a value`).
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Used when `--team` is omitted. Help text: "Team scope for the API token".
</ParamField>

<ParamField body="orgId" type="string">
Read from `.vercel/project.json`. This is the Vercel link field name; `resolveContext` stores it as `teamId`.
</ParamField>

No error is raised when every team source is missing. `requestUrl` simply skips the `teamId` query parameter.

## .vercel/project.json

`resolveContext` always attempts to load the linked project from the current working directory, even when flags or env vars already supply IDs.

| Item | Value |
| --- | --- |
| Path | `resolve(process.cwd(), '.vercel/project.json')` |
| Reader | `readJson` (`readFile` + `JSON.parse` in `src/config.ts`) |
| Fields used | `projectId`, `orgId` |
| Missing / invalid file | Caught; `linked` stays `{}` |
| Git | `.vercel/` is listed in `.gitignore` |

```json
{
  "projectId": "prj_xxxxxxxxxxxxxxxxxxxxxxxx",
  "orgId": "team_xxxxxxxxxxxxxxxxxxxxxxxx"
}
```

Any other keys written by `vercel link` are ignored. A missing file is not an error when `--project` / `VERCEL_PROJECT_ID` already provide a project.

<Info>
The catch comment in `resolveContext` states that a linked project is optional when explicit flags or environment variables are set. The file is still opened on every call; only a thrown `readJson` failure (ENOENT, parse error, permissions) is swallowed.
</Info>

## How context reaches the Firewall API

`getActiveConfig` and `putConfig` both call `request`, which builds the URL from the resolved IDs:

```ts
url.searchParams.set('projectId', context.projectId);
if (context.teamId) url.searchParams.set('teamId', context.teamId);
```

The API root is `${VERCEL_API_URL ?? 'https://api.vercel.com'}/v1/security/firewall/config`. `VERCEL_API_URL` is not selected by `resolveContext`; it only changes the host used after context exists.

<RequestExample>
```sh
# Linked project in cwd (.vercel/project.json)
export VERCEL_TOKEN=...
vwaffle plan

# CI / unlinked checkout
export VERCEL_TOKEN=...
export VERCEL_PROJECT_ID=prj_...
export VERCEL_TEAM_ID=team_...
vwaffle plan --check

# Flag override of a linked or env-selected project
vwaffle pull --project prj_other --team team_other --output firewall.config.json
```
</RequestExample>

## Local versus CI

<Steps>
<Step title="Local linked checkout">
Run `vercel link` so `.vercel/project.json` contains `projectId` and, for team projects, `orgId`. Export `VERCEL_TOKEN`. Omit `--project` / `--team` unless targeting a different project than the link file.
</Step>
<Step title="Unlinked or CI checkout">
Pass IDs as environment variables or flags. `.vercel/` is gitignored, so CI cannot rely on a committed link file.

```yaml
- run: npx vwaffle plan --check
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
    VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
```
</Step>
<Step title="Verify the target">
A successful `pull` or `plan` that reaches Vercel used the resolved `projectId` (and `teamId` when set). Token, project, and HTTP failures print `vwaffle: …` and exit `1`.
</Step>
</Steps>

## Context errors

| Condition | Message | Exit |
| --- | --- | --- |
| `VERCEL_TOKEN` unset or empty | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` | `1` |
| No `--project`, no `VERCEL_PROJECT_ID`, and no usable `projectId` | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.` | `1` |
| `--project` or `--team` with no value | `--project requires a value` / `--team requires a value` | `1` |
| API request fails after context resolves | `Vercel API ${status} ${statusText}: …` from `request` | `1` |

Missing team is not an error. Failed Firewall responses after a valid `ResolvedContext` are covered on the authentication-errors page.

## Related pages

<CardGroup>
<Card title="Environment variables" href="/environment-variables">
`VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, and interpolated `${VAR_NAME}` values.
</Card>
<Card title="Firewall API client" href="/firewall-api-client">
How `resolveContext`, `requestUrl`, `getActiveConfig`, and `putConfig` call Vercel.
</Card>
<Card title="Authentication and context errors" href="/authentication-errors">
Missing token, unresolved project, and failed Firewall requests.
</Card>
<Card title="Apply from CI" href="/apply-from-ci">
Promote `firewall.config.json` with `apply --yes` plus project, team, and token inputs.
</Card>
<Card title="CLI reference" href="/cli-reference">
`--project`, `--team`, and the commands that consume them.
</Card>
</CardGroup>

---

## 06. Secret interpolation

> How ${VAR_NAME} placeholders are expanded, when missing variables drop rules versus fail apply, and how values are redacted in CLI output.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/06-secret-interpolation.md
- Generated: 2026-08-13T01:22:16.899Z

### Source Files

- `src/config.ts`
- `src/config.test.ts`
- `src/index.ts`
- `src/types.ts`

---
title: "Secret interpolation"
description: "How ${VAR_NAME} placeholders are expanded, when missing variables drop rules versus fail apply, and how values are redacted in CLI output."
---

`loadDesiredConfig` reads the desired firewall JSON, walks every value with `interpolate`, and replaces `${VAR_NAME}` tokens from `process.env`. Expanded values are recorded in a `secrets` map. Unset names stay in the tree as the original placeholder and are recorded in `missing` as `NAME ($.json.path)`. `plan` and `apply --dry-run` continue after a warning and drop matching `rules` entries. `apply --yes` throws and never PUTs.

## Placeholder syntax

Only brace form is expanded, and only inside **string values**. Object keys, numbers, booleans, `null`, and `$NAME` without braces are left unchanged.

<ParamField body="placeholder" type="string" required>
`${VAR_NAME}` — `VAR_NAME` must match `[A-Za-z_][A-Za-z0-9_]*`. The first character is a letter or `_`; later characters may be letters, digits, or `_`. Hyphens, dots, spaces, and default syntax such as `${VAR-default}` are not matched.
</ParamField>

Walk rules in `interpolate`:

| Value kind | Behavior |
|---|---|
| string | Global replace of each `${NAME}` from `env[NAME]` (default `process.env`) |
| array | Recurse each element; JSON path becomes `path[index]` |
| object | Recurse each value; JSON path becomes `path.key` |
| other | Returned as-is |

A set variable is written into `secrets` under its name. An unset variable (`=== undefined`) is added to `missing` as `NAME ($.path)` and the `${NAME}` text is left in place. An empty-string environment value is a successful replacement, not a miss.

`loadDesiredConfig` always interpolates from `process.env`. There is no CLI flag to pass a different env map.

## Command modes

| Command | `strict` | Unset `${VAR}` | Config used for diff / PUT | Printed output |
|---|---|---|---|---|
| `vwaffle plan` / `plan --check` | `false` | Warn, drop matching `rules` | Interpolated, rules possibly removed | Raw `diff` of live vs desired — **not** redacted |
| `vwaffle apply --dry-run` | `false` | Warn, drop matching `rules` | Interpolated, rules possibly removed | `redacted(config, secrets)` JSON — no API call |
| `vwaffle apply --yes` | `true` | Throw; exit `1` | No PUT | Error on stderr |

`init` and `pull` do not run `loadDesiredConfig`. `pull` writes the live API body as received.

```mermaid
flowchart TD
  subgraph load ["loadDesiredConfig"]
    READ["readJson desired file"] --> WALK["interpolate via process.env"]
    WALK --> MISS{"missing.size > 0?"}
  end
  MISS -->|no| RESULT["InterpolationResult"]
  MISS -->|yes| MODE{"strict?"}
  MODE -->|"true — apply --yes"| FAIL["throw missing environment variables"]
  MODE -->|"false — plan / apply --dry-run"| WARN["console.warn + removeRulesWithMissingEnv"]
  WARN --> RESULT
  subgraph consume ["CLI consumers"]
    RESULT --> PLAN["plan: diff live vs desired, print unredacted"]
    RESULT --> DRY["apply --dry-run: print redacted JSON"]
    RESULT --> APPLY["apply --yes: redactText diff, then putConfig"]
  end
```

<Warning>
`apply --dry-run` can print a payload that `apply --yes` will refuse. Dry-run is non-strict and may omit rules; `--yes` fails the whole apply if any placeholder is unset.
</Warning>

## Missing variables

### Non-strict: warn and drop rules

When `strict` is `false` and `missing` is non-empty, `loadDesiredConfig` writes:

```text
vwaffle: warning: skipping rules that reference unset variables: NOPE ($.a[0])
```

Then `removeRulesWithMissingEnv` filters `config.rules` only.

1. Take the variable name from each missing entry (`split(' ')[0]`, so `GONE ($.rules[1])` → `GONE`).
2. `JSON.stringify` each custom rule.
3. Drop the rule if that text still contains `` `${NAME}` `` for any missing name.
4. If the array length changes, return `{ ...config, rules }` (possibly `rules: []`). Other keys are copied through.

`managedRules`, `crs`, `ips`, and top-level strings are **not** removed. After a warning they still contain the literal `${NAME}` in the in-memory config used for plan / dry-run.

If `config.rules` is missing or not an array, the function returns the config unchanged.

### Strict: fail apply

`apply --yes` sets `strict: true`. Any missing name throws before `resolveContext` or `putConfig`:

```text
vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.
```

`main` prefixes the message with `vwaffle: ` and sets `process.exitCode = 1`.

### Plan `--check` interaction

`plan --check` diffs the **post-drop** desired config against live. A rule that exists live but was skipped locally looks like drift (exit `1`). A rule that exists only in the file and was skipped is absent from desired, so `--check` can pass even though the file still references an unset variable.

## Redaction

Interpolated values go to Vercel in the clear. Redaction is CLI stdout only, and only on apply.

| Surface | Function | What is replaced |
|---|---|---|
| `apply --dry-run` | `redacted(config, secrets)` | Tab-indented `JSON.stringify` of the desired payload |
| `apply --yes` diff | `redactText(result, secrets)` | The live-versus-desired diff string, including live lines |
| `plan` | none | Interpolated values print as-is |
| `putConfig` body | none | Raw interpolated JSON |

Replacement is literal substring split/join of each non-empty `secrets` value with `[REDACTED]`. Empty-string secrets are skipped (`if (secret)`). The map key is unused at print time: any occurrence of the value in the serialized text is covered, including substrings of other fields.

<RequestExample>
```bash
INTERNAL_TOKEN=s3cret vwaffle apply --dry-run
```
</RequestExample>

<ResponseExample>
```json
{
	"rules": [
		{
			"name": "Bypass for internal service",
			"conditionGroup": [
				{
					"conditions": [
						{
							"type": "header",
							"op": "eq",
							"key": "x-internal-token",
							"value": "[REDACTED]"
						}
					]
				}
			]
		}
	]
}
```
</ResponseExample>

Help text says interpolated values are “redacted in all output.” `cmdPlan` prints `diff(live, config)` without `redacted` or `redactText`. Treat plan logs as secret-bearing.

## Authoring

Commit placeholders, not values. Typical site is a header or path condition `value`.

```json
{
	"firewallEnabled": true,
	"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" } }
		}
	],
	"ips": []
}
```

<Steps>
<Step title="Write the placeholder in a string field">
Use `${INTERNAL_TOKEN}` (or another valid name) only in string values. Multiple tokens in one string are all replaced, e.g. `token-${API_KEY}`.
</Step>
<Step title="Export the variable for apply">
`export INTERNAL_TOKEN=...` in the shell, or inject it in CI `env`. `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID` authenticate the API; they are not substitutes for config placeholders unless the JSON actually references those names.
</Step>
<Step title="Preview, then apply">
`vwaffle plan` can run without every secret (rules that still contain unset `${NAME}` are omitted). `vwaffle apply --dry-run` prints the redacted remaining payload. `vwaffle apply --yes` requires every referenced name to be set, then PUTs the interpolated config.
</Step>
</Steps>

The starter file from `vwaffle init` has no placeholders. The README sample rule is the in-repo pattern.

## Load result

<ResponseField name="config" type="FirewallConfig">
Desired body after interpolation, and after rule drops when `strict` is false.
</ResponseField>

<ResponseField name="missing" type="Set<string>">
Entries shaped `NAME ($.path)`, e.g. `NOPE ($.a[0])`. Paths start at `$`.
</ResponseField>

<ResponseField name="secrets" type="Map<string, string>">
Last seen replacement per variable name. Used only for apply-side redaction; the PUT body uses `config`.
</ResponseField>

Default desired path is `firewall.config.json` (`-c` / `--config`).

## Failures

| Signal | Meaning |
|---|---|
| `vwaffle: warning: skipping rules that reference unset variables: …` | Non-strict load. Named custom rules are omitted from desired. |
| `vwaffle: missing environment variables: NAME ($.path). Set them before applying…` | `apply --yes` with at least one unset placeholder. No PUT. |
| Literal `${NAME}` still in dry-run JSON | Placeholder sits outside `rules` (or in a kept rule that does not contain that token). It is sent as that literal if you later apply without fixing it — but `--yes` will fail first if the name is still missing. |
| `plan --check` exit `1` after a skip | Post-drop desired differs from live. |

## Related pages

<CardGroup>
<Card title="Environment variables" href="/environment-variables">
`VERCEL_TOKEN`, project/team IDs, and arbitrary `${VAR_NAME}` values used as string fields.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Unset-variable behavior on plan versus apply, skipped rules, and `plan --check` exit 1.
</Card>
<Card title="Author firewall rules" href="/author-firewall-rules">
Custom rules, OWASP managed rules, IP lists, and placeholders without committing secrets.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Desired-file path, PUT body shape, and `loadDesiredConfig` rules.
</Card>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
`plan`, `apply --dry-run`, and `apply --yes`.
</Card>
<Card title="Diff output" href="/diff-output">
How live-versus-desired diffs are rendered, including redacted apply output.
</Card>
</CardGroup>

---

## 07. Plan and apply lifecycle

> plan versus apply, --check drift exit codes, --dry-run payload print, and the --yes confirmation required to PUT.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/07-plan-and-apply-lifecycle.md
- Generated: 2026-08-13T01:22:33.212Z

### Source Files

- `src/index.ts`
- `src/diff.ts`
- `src/api.ts`
- `src/config.ts`

---
title: "Plan and apply lifecycle"
description: "plan versus apply, --check drift exit codes, --dry-run payload print, and the --yes confirmation required to PUT."
---

`vwaffle plan` and `vwaffle apply` share one desired file (`firewall.config.json` by default) and one live snapshot from `GET /v1/security/firewall/config/active`. `plan` always diffs and never writes. `apply` is gated: `--dry-run` prints a redacted payload and returns with no Vercel request; `--yes` interpolates strictly, prints the redacted diff, and `PUT`s the full desired body only when `diff` is not `No drift detected.`. Bare `apply` throws.

## Plan versus apply

| Surface | `plan` | `apply --dry-run` | `apply --yes` |
| --- | --- | --- | --- |
| Desired load | `loadDesiredConfig(..., { strict: false })` | same as `plan` | `loadDesiredConfig(..., { strict: true })` |
| Unset `${VAR_NAME}` | warn + drop matching `rules` | warn + drop matching `rules` | throw, no API call |
| `resolveContext` / `VERCEL_TOKEN` | required | not called | required |
| Live fetch | `getActiveConfig` (`GET /active`) | none | `getActiveConfig` (`GET /active`) |
| Stdout | raw `diff(live, desired)` | `redacted(config, secrets)` JSON | `redactText(diff, secrets)` |
| Write | none | none | `putConfig` (`PUT` empty path) only on drift |
| Extra flags | `--check` sets exit code `1` on drift | `--dry-run` wins if `--yes` is also set | `--yes` required unless `--dry-run` |

`--check` is parsed globally but only read in `cmdPlan`. `--yes` is only read in `cmdApply`. `vwaffle --yes` with no command still runs `help`.

## Lifecycle

```mermaid
stateDiagram-v2
    [*] --> parseArgs
    parseArgs --> cmdPlan: plan
    parseArgs --> cmdApply: apply

    state cmdPlan {
        [*] --> loadDesiredLoose
        loadDesiredLoose --> getActiveConfig: resolveContext
        getActiveConfig --> printDiff: diff(live, desired)
        printDiff --> planExit0: not --check or NO_DRIFT
        printDiff --> planExit1: --check and drift
    }

    state cmdApply {
        [*] --> confirmGate
        confirmGate --> applyError: neither --yes nor --dry-run
        confirmGate --> dryRun: --dry-run
        confirmGate --> applyYes: --yes without --dry-run
        dryRun --> printPayload: loadDesiredConfig strict false
        applyYes --> fetchLive: loadDesiredConfig strict true
        fetchLive --> printRedactedDiff: GET /active then diff
        printRedactedDiff --> noPut: NO_DRIFT
        printRedactedDiff --> putConfig: drift
    }

    planExit0 --> [*]
    planExit1 --> [*]
    applyError --> [*]
    printPayload --> [*]
    noPut --> [*]
    putConfig --> [*]
```

`putConfig` sends the entire interpolated desired object, not a patch. The live GET exists so `apply --yes` can print a diff and skip the PUT when there is no drift.

## `plan`

```bash
vwaffle plan
vwaffle plan --check
vwaffle plan --config path/to/firewall.config.json --project prj_xxx --team team_xxx
```

`cmdPlan` sequence:

1. `loadDesiredConfig(resolve(cwd, options.config), { strict: false })`
2. `resolveContext({ project, team })`
3. `getActiveConfig` → unwraps `response.active` when present
4. `console.log(diff(live, config))`
5. If `--check` and the printed string is not `No drift detected.`, set `process.exitCode = 1`

Drift comparison is tab-indented `JSON.stringify` of live versus desired. Identical serialization prints exactly:

```text
No drift detected.
```

Otherwise stdout is a context-trimmed line diff:

```text
--- live firewall configuration
+++ desired firewall configuration
  {
- 	"firewallEnabled": false
+ 	"firewallEnabled": true
  }
```

<Note>
`plan` does not call `redactText`. Interpolated secret values appear in the desired side of the printed diff. Redaction runs on `apply --dry-run` (payload) and `apply --yes` (diff text).
</Note>

### `--check`

<ParamField body="--check" type="boolean" default="false">
Plan-only. After printing the same diff as a normal plan, set exit code `1` when the result is not `No drift detected.`. Does not change stdout and does not call `putConfig`.
</ParamField>

Use in CI so a dashboard edit that diverges from the versioned file fails the job. Without `--check`, drift still prints and the process exits `0`.

## `apply` confirmation

<ParamField body="--yes" type="boolean" default="false">
Confirm a real apply. Required unless `--dry-run` is set.
</ParamField>

<ParamField body="--dry-run" type="boolean" default="false">
Print the interpolated, redacted desired JSON and return. Skips `resolveContext`, `getActiveConfig`, and `putConfig`. If both `--dry-run` and `--yes` are passed, `--dry-run` wins after the load.
</ParamField>

### No confirmation

```bash
vwaffle apply
```

Throws before loading the file:

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

`main` writes `vwaffle: ` plus that message to stderr and sets `process.exitCode = 1`.

### `apply --dry-run`

```bash
vwaffle apply --dry-run
vwaffle apply --dry-run --config firewall.config.json
```

- Loads desired config with `strict: false` (same missing-env policy as `plan`).
- Prints `redacted(config, secrets)`: tab-indented JSON with every recorded secret value replaced by `[REDACTED]`.
- Empty secret strings are not redacted.
- Does not require `VERCEL_TOKEN`, `--project`, or a linked `.vercel/project.json`.

<RequestExample>
```bash
vwaffle apply --dry-run
```
</RequestExample>

<ResponseExample>
```json
{
	"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>

### `apply --yes`

```bash
vwaffle apply --yes
vwaffle apply --yes --config firewall.config.json --project prj_xxx --team team_xxx
```

<Steps>
<Step title="Load desired config strictly">
`loadDesiredConfig(..., { strict: true })`. Any unset `${VAR_NAME}` throws `missing environment variables: NAME ($.path), .... Set them before applying the firewall configuration.`
</Step>
<Step title="Resolve project and fetch live">
`resolveContext` then `GET {VERCEL_API_URL}/v1/security/firewall/config/active?projectId=...` (`teamId` query when a team is resolved). Default host is `https://api.vercel.com`.
</Step>
<Step title="Print the redacted diff">
`diff(live, config)` then `redactText`. Same `No drift detected.` short-circuit string as `plan`.
</Step>
<Step title="PUT only on drift">
If the diff is `No drift detected.`, return with no write. Otherwise `putConfig` sends `PUT {VERCEL_API_URL}/v1/security/firewall/config` with `Authorization: Bearer ${VERCEL_TOKEN}` and `Content-Type: application/json`.
</Step>
</Steps>

Success line after a write:

```text
Applied firewall configuration.
```

If the PUT JSON includes `version`, the line becomes `Applied firewall configuration (version N).`

There is no version precondition, lock, or rollback. The GET is only used for the printed diff and the no-drift skip.

## Exit codes

| Situation | Exit code |
| --- | --- |
| `plan` with no drift | `0` |
| `plan` with drift, no `--check` | `0` |
| `plan --check` with drift | `1` (`process.exitCode`) |
| `apply --dry-run` after a successful load | `0` |
| `apply --yes` with no drift (no PUT) | `0` |
| `apply --yes` after a successful PUT | `0` |
| `apply` without `--yes` or `--dry-run` | `1` |
| Missing `VERCEL_TOKEN` / project on `plan` or `apply --yes` | `1` |
| Unset `${VAR_NAME}` on `apply --yes` | `1` |
| Non-OK Firewall API response | `1` |
| Unknown command or flag, missing file, invalid JSON | `1` |

Thrown errors are printed as `vwaffle: ${message}` on stderr.

## Interpolation during the lifecycle

| Command | `strict` | Missing `${VAR_NAME}` |
| --- | --- | --- |
| `plan` | `false` | `console.warn` then `removeRulesWithMissingEnv` |
| `apply --dry-run` | `false` | same as `plan` |
| `apply --yes` | `true` | throw before `resolveContext` |

Warning on the loose path:

```text
vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)
```

`removeRulesWithMissingEnv` drops a `rules[]` entry when `JSON.stringify(rule)` contains `` `${NAME}` `` for any missing name. `managedRules` and `ips` are not filtered. `plan --check` therefore diffs the **filtered** desired object: an unset secret can hide a custom rule from the comparison or make live look drifted because that rule is absent on the desired side. `apply --yes` refuses to proceed instead.

## Diff and PUT contract

`diff` in `src/diff.ts` serializes both sides with `JSON.stringify(..., null, '\t')`, computes an LCS line diff, and keeps three lines of context around each change (`withContext`). Unchanged spans collapse to `  ...`.

`getActiveConfig` reads `GET /active` and returns `body.active` when that field exists, otherwise the body itself. `putConfig` PUTs the desired `FirewallConfig` to the collection URL (path `''` on `https://api.vercel.com/v1/security/firewall/config`, overridable with `VERCEL_API_URL`). Query params are always `projectId` and, when resolved, `teamId`.

Non-OK responses throw:

```text
Vercel API ${status} ${statusText}: ${body}
```

## Shared inputs

<ParamField body="--config" type="string" default="firewall.config.json">
Desired file path, also `-c`. Resolved with `resolve(process.cwd(), options.config)` for both commands.
</ParamField>

<ParamField body="--project" type="string">
Project ID override. Else `VERCEL_PROJECT_ID`, else `.vercel/project.json` `projectId`. Required for `plan` and `apply --yes`.
</ParamField>

<ParamField body="--team" type="string">
Team ID override. Else `VERCEL_TEAM_ID`, else `.vercel/project.json` `orgId`. Optional; omitted from the query string when unset.
</ParamField>

`VERCEL_TOKEN` is required for every path that calls `resolveContext` (`plan`, `apply --yes`, and `pull`). It is not read on `apply --dry-run`.

<Warning>
`apply --check` is a no-op: the flag is accepted by `parseArgs` and ignored by `cmdApply`. Drift gating for CI is `plan --check`, not apply.
</Warning>

## Related pages

<CardGroup>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
Operator path: plan, inspect with apply --dry-run, then PUT with apply --yes.
</Card>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run plan --check so a dashboard edit that diverges from the versioned file fails the build.
</Card>
<Card title="Apply from CI" href="/apply-from-ci">
Promote firewall.config.json by running apply --yes on merge.
</Card>
<Card title="Diff output" href="/diff-output">
How diff, diffLines, and withContext render live-versus-desired changes.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Unset ${VAR_NAME} on plan versus apply, skipped rules, and plan --check exit 1.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands, flags, and help text for init, pull, plan, and apply.
</Card>
</CardGroup>

---

## 08. Firewall rule model

> Repo-backed shape of firewallEnabled, managedRules, rules, conditionGroup, mitigate actions, and ips entries sent to Vercel.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/08-firewall-rule-model.md
- Generated: 2026-08-13T01:22:17.598Z

### Source Files

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

---
title: "Firewall rule model"
description: "Repo-backed shape of firewallEnabled, managedRules, rules, conditionGroup, mitigate actions, and ips entries sent to Vercel."
---

`FirewallConfig` is the JSON object stored in the desired file (default `firewall.config.json`) and sent as the body of `PUT /v1/security/firewall/config`. `vwaffle init` writes a starter of that object. `vwaffle apply --yes` interpolates string placeholders, then `putConfig` JSON-serializes the same object. There is no runtime schema validator: `loadDesiredConfig` parses JSON and types it as `FirewallConfig`. Every modeled interface also accepts extra keys (`[key: string]: unknown`), so fields returned by a `pull` survive a later `apply`.

## Payload ownership

```mermaid
classDiagram
    class FirewallConfig {
        +boolean firewallEnabled
        +Record managedRules
        +Record crs
        +CustomRule[] rules
        +IpRule[] ips
    }
    class CustomRule {
        +string id
        +string name
        +string description
        +boolean active
        +ConditionGroup[] conditionGroup
    }
    class ConditionGroup {
        +RuleCondition[] conditions
    }
    class RuleCondition {
        +string type
        +string op
        +boolean neg
        +string key
        +value
    }
    class MitigateAction {
        +string action
        +object rateLimit
        +object redirect
        +string actionDuration
    }
    class ManagedRule {
        +boolean active
        +string action
    }
    class IpRule {
        +string id
        +string ip
        +string hostname
        +string action
        +string notes
    }
    FirewallConfig --> ManagedRule : managedRules, crs
    FirewallConfig --> CustomRule : rules
    FirewallConfig --> IpRule : ips
    CustomRule --> ConditionGroup : conditionGroup
    CustomRule --> MitigateAction : action.mitigate
    ConditionGroup --> RuleCondition : conditions
```

`getActiveConfig` reads `GET /v1/security/firewall/config/active` and unwraps `response.active` when that wrapper is present. The desired file is the inner object, not the wrapper.

:::endpoint PUT /v1/security/firewall/config
Send the interpolated `FirewallConfig` as the JSON body. Query params come from `resolveContext`, not from the config file.

**Base URL:** `VERCEL_API_URL` if set, otherwise `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`.

**Body:** the desired `FirewallConfig` after `${VAR_NAME}` interpolation. `apply --dry-run` prints that body with secret values replaced by `[REDACTED]` and does not call Vercel.
:::

## Top-level `FirewallConfig`

<ParamField body="firewallEnabled" type="boolean">
When present, included in the PUT body. The `init` starter sets `true`.
</ParamField>

<ParamField body="managedRules" type="Record<string, ManagedRule>">
Named managed-rule entries. The starter has a single `owasp` key.
</ParamField>

<ParamField body="crs" type="Record<string, ManagedRule>">
Typed as the same `ManagedRule` map as `managedRules`. Not written by `init`. Survives `pull` / `apply` because extra and typed keys are passed through.
</ParamField>

<ParamField body="rules" type="CustomRule[]">
Custom WAF rules. This is the only array `removeRulesWithMissingEnv` can drop entries from when a `${VAR_NAME}` is unset on a non-strict load.
</ParamField>

<ParamField body="ips" type="IpRule[]">
IP / CIDR entries. The starter writes `[]`. Missing-env filtering does not remove `ips` rows.
</ParamField>

All top-level keys are optional in the TypeScript type. Additional keys on the object are preserved.

## Starter object

`vwaffle init` refuses to overwrite an existing file, then writes this object with tab indentation:

<RequestExample>
```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": []
}
```
</RequestExample>

A typical authored file adds IP rows and `${VAR_NAME}` placeholders in string fields:

```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" } }
		},
		{
			"name": "Bypass for internal service",
			"active": true,
			"conditionGroup": [
				{
					"conditions": [
						{
							"type": "header",
							"op": "eq",
							"key": "x-internal-token",
							"value": "${INTERNAL_TOKEN}"
						}
					]
				}
			],
			"action": { "mitigate": { "action": "bypass" } }
		}
	],
	"ips": [
		{ "ip": "203.0.113.0/24", "hostname": "*", "action": "deny", "notes": "abuse" }
	]
}
```

## Custom rules

Each `rules[]` entry is a `CustomRule`.

<ParamField body="id" type="string">
Optional. Usually present on a pulled live config. Sent back unchanged if you leave it in the file.
</ParamField>

<ParamField body="name" type="string" required>
Rule name. Used in CLI interpolation warnings only as part of the serialized rule, not as a lookup key.
</ParamField>

<ParamField body="description" type="string">
Optional human-readable note. Included in the PUT body when set.
</ParamField>

<ParamField body="active" type="boolean" required>
Typed as required. Runtime JSON is not checked; a missing `active` is still sent if you omit it.
</ParamField>

<ParamField body="conditionGroup" type="ConditionGroup[]" required>
Array of groups. Each group has a `conditions` array of `RuleCondition` objects. Groups are AND/OR semantics as defined by the Vercel API, not reinterpreted by vwaffle.
</ParamField>

<ParamField body="action" type="object" required>
Must contain `mitigate` (`MitigateAction`). Extra keys on `action` are preserved.
</ParamField>

### `conditionGroup` and `RuleCondition`

<ParamField body="conditions" type="RuleCondition[]" required>
One or more matchers inside a group.
</ParamField>

<ParamField body="type" type="string" required>
Matcher kind. The repo only authors `path` and `header` examples. Any string is accepted.
</ParamField>

<ParamField body="op" type="string" required>
Operator. Starter / README examples: `pre` (prefix) and `eq`. Any string is accepted.
</ParamField>

<ParamField body="neg" type="boolean">
Optional negation flag.
</ParamField>

<ParamField body="key" type="string">
Optional. Used for header matchers (`x-internal-token` in the README example).
</ParamField>

<ParamField body="value" type="string | number | string[]">
Match value. String values may contain `${VAR_NAME}` placeholders.
</ParamField>

vwaffle does not enumerate Vercel condition types or operators. Unknown `type` / `op` values are forwarded in the PUT body.

### `action.mitigate`

<ParamField body="action" type="'log' | 'challenge' | 'deny' | 'bypass' | 'rate_limit' | 'redirect' | string" required>
Mitigation. The union is open-ended: any other string is still typed and sent.
</ParamField>

<ParamField body="rateLimit" type="{ algo: string; window: number; limit: number; keys: string[]; action?: string } | null">
Optional rate-limit block. Not present in the starter.
</ParamField>

<ParamField body="redirect" type="{ location: string; permanent: boolean } | null">
Optional redirect target. Not present in the starter.
</ParamField>

<ParamField body="actionDuration" type="string | null">
Optional duration string. Not present in the starter.
</ParamField>

Repo-authored examples use only `{ "mitigate": { "action": "deny" } }` and `{ "mitigate": { "action": "bypass" } }`.

## Managed rules

`managedRules` and `crs` are `Record<string, ManagedRule>`.

<ParamField body="active" type="boolean" required>
Whether that named managed rule is on.
</ParamField>

<ParamField body="action" type="string">
Optional action override for that managed rule.
</ParamField>

The only named entry vwaffle writes is `owasp: { "active": false }`. Other keys from a `pull` are kept because the map is a free record plus an index signature.

## IP entries

Each `ips[]` row is an `IpRule`.

<ParamField body="id" type="string">
Optional. Typically appears after `pull`.
</ParamField>

<ParamField body="ip" type="string" required>
Address or CIDR. README example: `203.0.113.0/24`.
</ParamField>

<ParamField body="hostname" type="string" required>
Host scope. README example: `*`.
</ParamField>

<ParamField body="action" type="'deny' | 'challenge' | 'log' | 'bypass' | string" required>
IP action. Open-ended string fallback, same as custom-rule mitigations except `rate_limit` / `redirect` are not in this union.
</ParamField>

<ParamField body="notes" type="string">
Optional note. README example: `abuse`.
</ParamField>

`removeRulesWithMissingEnv` never filters `ips`. An IP row whose string fields still contain `${UNSET}` is left in the object on `plan` and `apply --dry-run`.

## Interpolation and which rows drop

`interpolate` walks every string in the JSON tree (`${[A-Za-z_][A-Za-z0-9_]*}`). Replaced values are recorded in a secrets map and later printed as `[REDACTED]`.

| Command | Missing `${VAR_NAME}` | Effect on the model |
| --- | --- | --- |
| `plan`, `plan --check` | warn | Drop only `rules[]` entries whose serialized JSON still contains `${NAME}` |
| `apply --dry-run` | warn (not strict) | Same rule-drop as `plan`; print the resulting object |
| `apply --yes` | throw | No PUT. Error: `missing environment variables: NAME ($.path), ...` |

Unset variables in `managedRules`, `crs`, `ips`, or top-level strings are not stripped. On `plan` they remain as the literal `${NAME}` in the desired object used for the diff.

<Warning>
`loadDesiredConfig` does not reject unknown fields, missing TypeScript-required keys, or invalid `type` / `op` / `action` strings. Invalid shapes fail at the Vercel API (`Vercel API <status> ...`), not in local parsing.
</Warning>

## What is not modeled

- No local catalog of Vercel condition types, OWASP CRS rule IDs, or bot-protection presets beyond the typed maps.
- No transformation between dashboard UI names and JSON keys. `pull` is the supported way to capture the live shape.
- No per-field defaults except what `init` writes. Omitting `firewallEnabled`, `managedRules`, `rules`, or `ips` sends a body without those keys.

## Next

<CardGroup>
<Card title="Author firewall rules" href="/author-firewall-rules">
Edit custom rules, OWASP managed rules, IP denylists, and placeholders.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Default file path, PUT body contract, and `loadDesiredConfig` interpolation rules.
</Card>
<Card title="Desired vs live config" href="/desired-vs-live-config">
How the local file relates to the object `getActiveConfig` unwraps from `/active`.
</Card>
<Card title="Secret interpolation" href="/secret-interpolation">
When missing variables drop rules versus fail `apply`.
</Card>
<Card title="Scaffold and pull a config" href="/scaffold-and-pull">
Write the starter object with `init`, or capture the live object with `pull`.
</Card>
<Card title="Firewall API client" href="/firewall-api-client">
`putConfig` and `getActiveConfig` request paths used to read and write this object.
</Card>
</CardGroup>

---

## 09. Scaffold and pull a config

> Write a starter firewall.config.json with init, or fetch the live config with pull and --output.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/09-scaffold-and-pull-a-config.md
- Generated: 2026-08-13T01:23:33.434Z

### Source Files

- `src/index.ts`
- `src/api.ts`
- `src/config.ts`
- `README.md`

---
title: "Scaffold and pull a config"
description: "Write a starter firewall.config.json with init, or fetch the live config with pull and --output."
---

`vwaffle init` writes a local starter `firewall.config.json` from the hardcoded `STARTER_CONFIG` object and never calls Vercel. `vwaffle pull` resolves project context, `GET`s `/v1/security/firewall/config/active`, unwraps an `active` envelope when present, and either prints tab-indented JSON to stdout or writes `--output FILE`.

## Choose init or pull

| | `init` | `pull` |
| --- | --- | --- |
| Source | Built-in `STARTER_CONFIG` | Live Vercel Firewall config |
| Auth / project | Not required | `VERCEL_TOKEN` plus a project ID |
| Default path | `firewall.config.json` via `-c, --config` | stdout unless `-o, --output FILE` |
| Parent directories | `mkdir(..., { recursive: true })` | Not created |
| Existing file | Refuses: `<path> already exists; refusing to overwrite.` | Overwrites `--output` |
| Secrets | None in the starter | Writes live values as returned; no `${VAR}` interpolation or `[REDACTED]` |
| Success stdout | `Wrote <path>. Edit it, then run \`vwaffle plan\`.` | `Wrote active firewall configuration to <file>.` or the JSON itself |

Use `init` for a new versioned file. Use `pull --output firewall.config.json` when the dashboard already holds the policy you want to version. Both commands resolve relative paths against `process.cwd()`.

<Note>
`--config` is the write target for `init` only. `pull` ignores `--config` and writes only to stdout or `--output`. `--project`, `--team`, `--yes`, `--dry-run`, and `--check` are unused by `init`.
</Note>

## Prerequisites

`init` needs a writable working directory. `pull` needs context from `resolveContext`:

| Value | Resolution order |
| --- | --- |
| Token | `VERCEL_TOKEN` (required) |
| Project | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` |
| Team | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` (optional) |

Missing token:

```text
VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.
```

Missing project:

```text
A project is required. Pass --project, set VERCEL_PROJECT_ID, or run `vercel link` so .vercel/project.json exists.
```

CLI failures print `vwaffle: <message>` on stderr and set `process.exitCode = 1`.

## Scaffold with init

<Steps>
<Step title="Write the starter file">

```sh
vwaffle init
```

Or a non-default path (parent directories are created):

```sh
vwaffle init --config path/to/firewall.config.json
```

</Step>
<Step title="Confirm it was written">

Stdout:

```text
Wrote firewall.config.json. Edit it, then run `vwaffle plan`.
```

The file is `JSON.stringify(..., null, '\t')` plus a trailing newline.

</Step>
<Step title="Handle an existing file">

If the target already exists, `init` does not overwrite:

```text
vwaffle: firewall.config.json already exists; refusing to overwrite.
```

Pick a new `--config` path, or start from live state with `pull --output` instead.

</Step>
</Steps>

### Starter body

`STARTER_CONFIG` is the exact object written:

```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": []
}
```

That single custom rule is a sample deny on path prefix `/.git`. OWASP managed rules start inactive. `ips` is an empty array.

<ParamField body="--config" type="string" required={false}>
Path written by `init`. Short flag `-c`. Default `firewall.config.json`.
</ParamField>

## Pull the live config

```mermaid
sequenceDiagram
  participant CLI as vwaffle pull
  participant CTX as resolveContext
  participant API as GET /active
  participant OUT as stdout or --output
  CLI->>CTX: VERCEL_TOKEN, --project/--team or env or .vercel/project.json
  CTX-->>CLI: token, projectId, teamId?
  CLI->>API: Authorization Bearer, projectId, optional teamId
  API-->>CLI: { active } or FirewallConfig
  Note over CLI: unwrap live.active when present
  alt --output FILE
    CLI->>OUT: tab-indented JSON + newline
  else no --output
    CLI->>OUT: print JSON
  end
```

<Steps>
<Step title="Set token and project">

```sh
export VERCEL_TOKEN=...
# optional if the directory is already `vercel link`ed
export VERCEL_PROJECT_ID=...
export VERCEL_TEAM_ID=...
```

</Step>
<Step title="Print or write the active config">

<CodeGroup>

```sh title="stdout"
vwaffle pull
vwaffle pull --project prj_... --team team_...
```

```sh title="file"
vwaffle pull --output firewall.config.json
vwaffle pull -o firewall.config.json --project prj_... --team team_...
```

</CodeGroup>

</Step>
<Step title="Confirm the write">

With `--output`:

```text
Wrote active firewall configuration to firewall.config.json.
```

Without `--output`, the tab-indented JSON is the entire stdout payload.

</Step>
</Steps>

<ParamField body="--output" type="string" required={false}>
Destination file for `pull`. Short flag `-o`. Relative paths resolve from `process.cwd()`. Parent directories are not created.
</ParamField>

<ParamField body="--project" type="string" required={false}>
Vercel project ID override. Else `VERCEL_PROJECT_ID`, else `.vercel/project.json` `projectId`.
</ParamField>

<ParamField body="--team" type="string" required={false}>
Vercel team ID override. Else `VERCEL_TEAM_ID`, else `.vercel/project.json` `orgId`. Omitted from the query string when unset.
</ParamField>

:::endpoint GET /v1/security/firewall/config/active Fetch the live firewall config
`getActiveConfig` calls `GET` on `${VERCEL_API_URL ?? "https://api.vercel.com"}/v1/security/firewall/config/active`.

**Query**

| Name | Required | Source |
| --- | --- | --- |
| `projectId` | yes | resolved project |
| `teamId` | no | resolved team, omitted when absent |

**Headers**

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

**Response handling**

If the JSON body has an `active` object, that object is written. Otherwise the whole parsed body is treated as the config. The written file is that unwrapped object, not the outer envelope.

**Errors**

Non-OK responses throw:

```text
Vercel API <status> <statusText>: <body>
```
:::

<Warning>
`pull` does not interpolate `${VAR_NAME}` and does not redact values. A live config that already contains secrets is written in the clear. Prefer placeholders in the desired file after the first pull. `pull --output` overwrites the target with no confirmation and does not create missing parent directories.
</Warning>

## After the file exists

`plan` and `apply` load the desired file with `--config` (default `firewall.config.json`). They interpolate `${VAR_NAME}` from the environment; `pull` does not.

<RequestExample>

```sh
vwaffle plan
vwaffle apply --dry-run
vwaffle apply --yes
```

</RequestExample>

Typical next edits: keep or replace the starter `/.git` deny, turn on `managedRules.owasp`, add `ips` entries, and replace live secret strings with `${VAR_NAME}` placeholders.

## Troubleshooting

| Symptom | Cause | What to do |
| --- | --- | --- |
| `<file> already exists; refusing to overwrite.` | `init` found the target via `access` | Use a new `--config` path, or replace via `pull --output` |
| `VERCEL_TOKEN is required...` | `pull` without a token | Export `VERCEL_TOKEN` |
| `A project is required...` | No `--project`, `VERCEL_PROJECT_ID`, or linked `projectId` | Pass `--project`, set the env var, or run `vercel link` |
| `Vercel API <status> ...` | Firewall API rejected the GET | Check token scope, project, team, and `VERCEL_API_URL` |
| `ENOENT` on `--output` | Parent directory missing | `init --config` creates parents; `pull --output` does not |
| `unknown option ...` | Flag not in the parser | Valid flags: `--config`/`-c`, `--output`/`-o`, `--project`, `--team`, `--yes`, `--dry-run`, `--check`, `--help`/`-h`, `--version`/`-v` |
| `unknown command ...` | First non-flag token is not `init`, `pull`, `plan`, `apply`, `help`, or `version` | Run `vwaffle help` |

<Info>
Override the API host with `VERCEL_API_URL`. The client appends `/v1/security/firewall/config` to that value before `/active`.
</Info>

## Next

<CardGroup>
<Card title="Author firewall rules" href="/author-firewall-rules">
Edit the desired JSON after init or pull.
</Card>
<Card title="Desired vs live config" href="/desired-vs-live-config">
How the local file relates to the active config `pull` writes.
</Card>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
Diff with `plan`, then PUT with `apply --yes`.
</Card>
<Card title="Project and team context" href="/project-context">
How `resolveContext` picks token, project, and team.
</Card>
</CardGroup>

---

## 10. Author firewall rules

> Edit the desired JSON for custom rules, OWASP managed rules, IP denylists, and environment placeholders without committing secrets.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/10-author-firewall-rules.md
- Generated: 2026-08-13T01:25:48.169Z

### Source Files

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

---
title: "Author firewall rules"
description: "Edit the desired JSON for custom rules, OWASP managed rules, IP denylists, and environment placeholders without committing secrets."
---

`firewall.config.json` is the desired Vercel Firewall body `vwaffle` loads on `plan` and `apply`. `loadDesiredConfig` parses that file, expands `${VAR_NAME}` placeholders in string values from the process environment, then either diffs the result against live or PUTs it to `/v1/security/firewall/config`. The file is valid JSON only — no comments, no trailing commas — and is the same object shape `FirewallConfig` describes: `firewallEnabled`, `managedRules`, optional `crs`, `rules`, and `ips`.

```text
firewall.config.json          # default path; override with -c / --config
├── firewallEnabled           # boolean
├── managedRules              # map; starter key is owasp
├── crs                       # optional map; same ManagedRule values as managedRules
├── rules[]                   # custom CustomRule objects (placeholders live here)
└── ips[]                     # IpRule denylist / challenge / log / bypass entries
```

<Note>
`.gitignore` ignores `.env` and `.vercel/` but not `firewall.config.json`. Commit the desired file. Keep secret values in the environment, not in the JSON.
</Note>

## Start from a file

`vwaffle` does not generate rules from flags. Author by editing JSON.

<Steps>
<Step title="Create or fetch the file">
`vwaffle init` writes the starter object to `firewall.config.json` (or `--config FILE`) and refuses to overwrite an existing path. `vwaffle pull --output firewall.config.json` writes the live `active` config, including any `id` fields Vercel already assigned.
</Step>
<Step title="Edit JSON, not secrets">
Add or change `rules`, `managedRules`, and `ips`. Put tokens and other secret string values in `${VAR_NAME}` placeholders. Extra keys Vercel returns survive `interpolate` and are sent back on apply.
</Step>
<Step title="Load and inspect">
Run `vwaffle plan` to diff live versus the interpolated desired file. Run `vwaffle apply --dry-run` to print the interpolated payload with secret values replaced by `[REDACTED]`.
</Step>
</Steps>

<Warning>
`vwaffle plan` interpolates first, then diffs. It does **not** redact secret values. `apply --yes` redacts the printed diff via `redactText`. `apply --dry-run` redacts the printed payload via `redacted`.
</Warning>

## Top-level keys

`loadDesiredConfig` does not validate this schema. Missing required fields or invalid condition types fail at the Vercel API on `apply`, not at parse time.

<ParamField body="firewallEnabled" type="boolean">
Starter and README set `true`. Optional on `FirewallConfig`.
</ParamField>

<ParamField body="managedRules" type="Record<string, ManagedRule>">
Named managed-rule entries. Each value needs `active` and may include `action` plus extra keys.
</ParamField>

<ParamField body="crs" type="Record<string, ManagedRule>">
Same value shape as `managedRules`. Present on the TypeScript type; the starter file omits it. Keep it if `pull` returned it.
</ParamField>

<ParamField body="rules" type="CustomRule[]">
Custom rules. This is the only array `removeRulesWithMissingEnv` can drop when a referenced variable is unset and load is not strict.
</ParamField>

<ParamField body="ips" type="IpRule[]">
Per-IP (or CIDR) entries. Starter writes `[]`. Placeholders here are interpolated but never dropped by `removeRulesWithMissingEnv`.
</ParamField>

<ParamField body="id" type="string">
Optional on `CustomRule` and `IpRule`. Pulled live configs often include ids; keep them when you started from `pull`.
</ParamField>

## Custom rules

Each `rules[]` item is a `CustomRule`:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | yes | Display name |
| `active` | `boolean` | yes | Inactive rules still travel in the desired body |
| `conditionGroup` | `ConditionGroup[]` | yes | Each group has `conditions: RuleCondition[]` |
| `action` | `{ mitigate: MitigateAction }` | yes | Mitigate payload is required by the type |
| `description` | `string` | no | Starter uses this for the path-deny rule |
| `id` | `string` | no | Present after `pull` |

A `RuleCondition` has `type`, `op`, optional `neg`, optional `key`, and optional `value` (`string | number | string[]`). The repo types those as open `string`s. The starter and README only demonstrate:

- `type: "path"`, `op: "pre"`, `value: "/.git"`
- `type: "header"`, `op: "eq"`, `key: "x-internal-token"`, `value: "${INTERNAL_TOKEN}"`

`MitigateAction.action` is typed as `'log' | 'challenge' | 'deny' | 'bypass' | 'rate_limit' | 'redirect' | string`. Optional companions:

| Field | Shape |
| --- | --- |
| `rateLimit` | `{ algo, window, limit, keys, action? } \| null` |
| `redirect` | `{ location, permanent } \| null` |
| `actionDuration` | `string \| null` |

```json
{
	"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" } }
}
```

```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" } }
}
```

## OWASP managed rules

`vwaffle init` writes a single managed-rule entry and leaves OWASP off:

```json
{
	"firewallEnabled": true,
	"managedRules": {
		"owasp": { "active": false }
	}
}
```

`ManagedRule` requires `active` and allows optional `action` plus additional keys. The CLI does not expand OWASP CRS categories or generate managed-rule IDs. To turn OWASP on, set `managedRules.owasp.active` to `true` (and any `action` the live API expects). If `pull` returns a richer `managedRules` or `crs` map, edit those objects in place rather than replacing them with the starter stub.

## IP entries

Each `ips[]` item is an `IpRule`:

| Field | Type | Required |
| --- | --- | --- |
| `ip` | `string` | yes — address or CIDR |
| `hostname` | `string` | yes — README uses `"*"` |
| `action` | `'deny' \| 'challenge' \| 'log' \| 'bypass' \| string` | yes |
| `notes` | `string` | no |
| `id` | `string` | no |

```json
{
	"ips": [
		{ "ip": "203.0.113.0/24", "hostname": "*", "action": "deny", "notes": "abuse" }
	]
}
```

Placeholders in `ip`, `hostname`, or `notes` interpolate like any other string. Unset variables in `ips` do **not** drop the entry on `plan`; they fail `apply --yes` because `loadDesiredConfig` still records them in `missing`.

## Environment placeholders

`interpolate` walks every string in the desired object (nested objects and arrays included) and replaces `${VAR_NAME}` when `VAR_NAME` matches `[A-Za-z_][A-Za-z0-9_]*`. Object keys, numbers, and booleans are not expanded. `${ VAR }` (spaces) and `$VAR` do not match.

| Command | `strict` | Unset `${VAR_NAME}` |
| --- | --- | --- |
| `vwaffle plan` | `false` | Warns `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)`. Drops matching `rules[]` items. Leaves `ips` / `managedRules` in place. |
| `vwaffle apply --dry-run` | `false` | Same non-strict load, then prints the (possibly rule-stripped) redacted payload. No PUT. |
| `vwaffle apply --yes` | `true` | Throws `missing environment variables: NAME ($.path). Set them before applying the firewall configuration.` |

`removeRulesWithMissingEnv` stringifies each custom rule after interpolation. A missing variable is left as the literal `${NAME}` in that JSON, so the whole rule is dropped if that substring is present. A rule that never referenced the unset name is kept.

<Tip>
Export tokens in the shell or a gitignored `.env` that your process already loads. `apply --yes` requires every referenced name to be set. Local `plan` can run without every secret because those rules are skipped.
</Tip>

A resolved value is stored in the `secrets` map and later replaced with `[REDACTED]` in apply output. An empty-string env value interpolates as empty and is not redacted (`if (secret)` is false).

## Verify the authored file

<RequestExample>
```bash
# preview live vs desired (interpolated; not redacted)
vwaffle plan

# inspect the payload that would be PUT (redacted)
vwaffle apply --dry-run

# PUT after printing a redacted diff
vwaffle apply --yes
```
</RequestExample>

Expected signals:

- `plan` / `apply --yes` print `No drift detected.` when live JSON equals interpolated desired JSON.
- `apply --dry-run` prints tab-indented JSON with interpolated secret substrings replaced by `[REDACTED]`.
- `apply --yes` after a real change prints `Applied firewall configuration` and, when the API returns it, `(version N)`.
- Missing vars on apply: `vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.`

## Authoring constraints

- Path default is `firewall.config.json` relative to `process.cwd()`. Use `-c` / `--config` for another file.
- `init` writes tab-indented JSON plus a trailing newline and exits with `Wrote FILE. Edit it, then run \`vwaffle plan\`.`
- Interpolation is one pass. A replacement that itself contains `${OTHER}` is not expanded again.
- `vwaffle` does not synthesize condition operators, OWASP rule packs, or IP lists. Those values must already be valid for `PUT /v1/security/firewall/config`.

## Next

<CardGroup>
<Card title="Firewall rule model" href="/firewall-rule-model">
Field-level `FirewallConfig`, `CustomRule`, `MitigateAction`, and `IpRule` shapes sent to Vercel.
</Card>
<Card title="Secret interpolation" href="/secret-interpolation">
How `${VAR_NAME}` expands, when missing names drop rules versus fail apply, and how `[REDACTED]` is applied.
</Card>
<Card title="Preview and apply changes" href="/preview-and-apply-changes">
Diff with `plan`, inspect with `apply --dry-run`, PUT with `apply --yes`.
</Card>
<Card title="Scaffold and pull a config" href="/scaffold-and-pull">
Write the starter file with `init`, or fetch live JSON with `pull --output`.
</Card>
</CardGroup>

---

## 11. Preview and apply changes

> Diff live versus desired with plan, inspect a redacted payload with apply --dry-run, then PUT with apply --yes.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/11-preview-and-apply-changes.md
- Generated: 2026-08-13T01:25:53.872Z

### Source Files

- `src/index.ts`
- `src/diff.ts`
- `src/api.ts`
- `src/config.ts`

---
title: "Preview and apply changes"
description: "Diff live versus desired with plan, inspect a redacted payload with apply --dry-run, then PUT with apply --yes."
---

`vwaffle plan` diffs the active Vercel Firewall config against the interpolated desired file. `vwaffle apply --dry-run` prints that payload with secret values replaced by `[REDACTED]` and never calls the API. `vwaffle apply --yes` reprints a redacted live-versus-desired diff, then `PUT`s the desired JSON to `/v1/security/firewall/config` unless the configs already match.

## Command surfaces

| Command | Reads desired file | Calls Vercel | Writes Vercel | Interpolation | Printed output |
| --- | --- | --- | --- | --- | --- |
| `vwaffle plan` | Yes (`strict: false`) | `GET .../config/active` | No | Missing `${VAR}` warns; matching `rules` entries are dropped | Line diff, or `No drift detected.` |
| `vwaffle apply --dry-run` | Yes (`strict: false`) | No | No | Same skip-and-warn as `plan` | Redacted desired JSON |
| `vwaffle apply --yes` | Yes (`strict: true`) | `GET .../config/active`, then `PUT .../config` on drift | Yes, when the diff is not `No drift detected.` | Missing `${VAR}` fails the command | Redacted diff, then an apply line |

Default desired path is `firewall.config.json` in the current working directory (`-c` / `--config FILE`). `plan` and `apply --yes` resolve `VERCEL_TOKEN`, project ID, and optional team ID through `resolveContext`. `apply --dry-run` never calls `resolveContext`, so it does not require a token.

<Warning>
`apply` has no interactive prompt. Without `--yes` or `--dry-run` the process throws `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` and exits `1`.
</Warning>

## Prerequisites

- A desired file (scaffold with `vwaffle init`, or fetch with `vwaffle pull --output firewall.config.json`).
- For `plan` and `apply --yes`: `VERCEL_TOKEN`, plus a project ID from `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json` (`projectId` after `vercel link`).
- Optional team ID from `--team`, `VERCEL_TEAM_ID`, or `.vercel/project.json` (`orgId`).
- Environment values for every `${VAR_NAME}` placeholder you intend to send. `apply --yes` refuses unset variables; `plan` and `apply --dry-run` warn and omit the affected custom rules.

## Preview then apply

<Steps>
<Step title="Diff live against desired">

```sh
vwaffle plan
vwaffle plan -c path/to/firewall.config.json
vwaffle plan --project prj_xxx --team team_xxx
```

`cmdPlan` loads the desired file with `loadDesiredConfig(..., { strict: false })`, fetches the live config, and prints `diff(live, desired)`.

</Step>
<Step title="Inspect the PUT body without writing">

```sh
vwaffle apply --dry-run
vwaffle apply --dry-run --yes
```

`--dry-run` short-circuits after interpolation. Combined `--yes --dry-run` still prints the payload and does not `PUT`.

</Step>
<Step title="Confirm and PUT">

```sh
vwaffle apply --yes
```

The command prints the redacted diff first. If the result is exactly `No drift detected.`, it returns without `PUT`.

</Step>
</Steps>

## Lifecycle

```mermaid
sequenceDiagram
    participant File as firewall.config.json
    participant CLI as vwaffle
    participant API as Vercel Firewall API

    alt plan
        CLI->>File: loadDesiredConfig(strict false)
        CLI->>API: GET /v1/security/firewall/config/active
        API-->>CLI: active config
        Note over CLI: print diff(live, desired)
    else apply --dry-run
        CLI->>File: loadDesiredConfig(strict false)
        Note over CLI: print redacted(desired)<br/>no resolveContext, no HTTP
    else apply --yes
        CLI->>File: loadDesiredConfig(strict true)
        CLI->>API: GET /v1/security/firewall/config/active
        API-->>CLI: active config
        Note over CLI: print redactText(diff)
        alt No drift detected.
            Note over CLI: return, no PUT
        else drift
            CLI->>API: PUT /v1/security/firewall/config
            API-->>CLI: version?
        end
    end
```

## `plan` output

`diff` pretty-prints both sides with `JSON.stringify(..., null, '\t')`. Identical strings yield `No drift detected.` Otherwise the printer emits a two-line header and an LCS line diff with three lines of unchanged context. Unchanged spans collapse to `  ...`.

<ResponseExample>

```text
--- live firewall configuration
+++ desired firewall configuration
  	"firewallEnabled": true,
  	"rules": [
  		{
- 			"name": "Block sensitive paths",
+ 			"name": "Block admin and git",
  			"active": true,
```

</ResponseExample>

Prefixes:

| Prefix | Meaning |
| --- | --- |
| `  ` (two spaces) | Line present on both sides |
| `- ` | Present on live, absent from desired |
| `+ ` | Present on desired, absent from live |
| `  ...` | Omitted unchanged region |

`plan` interpolates `${VAR_NAME}` into the desired object before the compare, then prints that diff **without** running `redactText`. Interpolated secret values can appear on `+` lines. Use `apply --dry-run` or `apply --yes` when the printed text must hide those values.

`plan --check` still prints the same result. When it is not `No drift detected.`, `process.exitCode` is set to `1`. That path belongs to CI drift detection, not to apply.

## `apply --dry-run` payload

`apply --dry-run` prints `redacted(config, secrets)`: tab-indented JSON of the interpolated desired object, with every recorded secret string replaced by `[REDACTED]`. Empty secret values are not replaced. There is no live fetch and no `--- live` / `+++ desired` header.

<RequestExample>

```sh
vwaffle apply --dry-run
```

</RequestExample>

<ResponseExample>

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

</ResponseExample>

<Note>
Dry-run uses the same non-strict interpolation as `plan`. Unset variables produce `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)` on stderr, and `removeRulesWithMissingEnv` drops only `rules` entries whose serialized body still contains `${NAME}`. `managedRules` and `ips` are not filtered that way.
</Note>

## `apply --yes` write path

After a successful strict load, `apply --yes` fetches live config, prints `redactText(diff(live, desired), secrets)`, and only then writes.

:::endpoint PUT /v1/security/firewall/config Write the interpolated desired config
Base URL is `${VERCEL_API_URL ?? 'https://api.vercel.com'}/v1/security/firewall/config`. Query params: required `projectId`, optional `teamId`. Header `Authorization: Bearer ${VERCEL_TOKEN}`. Body is the interpolated `FirewallConfig` JSON (`firewallEnabled`, `managedRules`, `rules`, `ips`, and any other keys the file carries). Live config is read from `GET /v1/security/firewall/config/active` on the same base; `getActiveConfig` uses `response.active` when present.
:::

Success on stdout:

```text
Applied firewall configuration.
```

or, when the PUT JSON includes `version`:

```text
Applied firewall configuration (version 12).
```

No-op success is a single line:

```text
No drift detected.
```

There is no rollback, no version pin on the request, and no confirmation beyond the `--yes` flag.

## Flags used on this path

<ParamField body="--config" type="string">
Alias `-c`. Desired file path, resolved from `process.cwd()`. Default `firewall.config.json`.
</ParamField>

<ParamField body="--dry-run" type="boolean">
`apply` only. Print the redacted desired payload and return. Overrides `--yes` when both are set.
</ParamField>

<ParamField body="--yes" type="boolean">
Required to `PUT` (unless `--dry-run`). There is no TTY prompt.
</ParamField>

<ParamField body="--project" type="string">
Project ID for `plan` and `apply --yes`. Overrides `VERCEL_PROJECT_ID` and `.vercel/project.json`.
</ParamField>

<ParamField body="--team" type="string">
Team ID for those same commands. Overrides `VERCEL_TEAM_ID` and `.vercel/project.json` `orgId`.
</ParamField>

<ParamField body="--check" type="boolean">
`plan` only. Exit `1` on drift. Ignored by `apply`.
</ParamField>

## Interpolation on preview versus apply

`${VAR_NAME}` in string fields matches `[A-Za-z_][A-Za-z0-9_]*`. Replacements are recorded in a `secrets` map used by `redacted` and `redactText`.

| Mode | `loadDesiredConfig` `strict` | Unset `${VAR}` |
| --- | --- | --- |
| `plan` | `false` | Warn; drop matching `rules` |
| `apply --dry-run` | `false` | Same as `plan` |
| `apply --yes` | `true` | Throw `missing environment variables: NAME ($.path). Set them before applying the firewall configuration.` |

A dry-run payload can therefore omit rules that a later `apply --yes` will refuse to send until the variable is set.

## Errors and exit codes

Failures print `vwaffle: <message>` on stderr and set `process.exitCode` to `1`.

| Condition | Message / signal |
| --- | --- |
| `apply` without `--yes` or `--dry-run` | `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` |
| Missing `VERCEL_TOKEN` on `plan` / `apply --yes` | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` |
| Unresolved project ID | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.` |
| Unset `${VAR}` on `apply --yes` | `missing environment variables: NAME ($.path). Set them before applying the firewall configuration.` |
| Firewall API error | `Vercel API <status> <statusText>: <body>` |
| `plan --check` with drift | Diff printed; exit code `1` (not used by `apply`) |

## Verification

| After | Expect |
| --- | --- |
| `vwaffle plan` with matching configs | `No drift detected.` and exit `0` |
| `vwaffle plan` after a local edit | `--- live firewall configuration` / `+++ desired firewall configuration` plus `- ` / `+ ` lines |
| `vwaffle apply --dry-run` | Tab-indented JSON; secrets as `[REDACTED]`; no `Applied firewall configuration` line |
| `vwaffle apply --yes` with drift | Redacted diff, then `Applied firewall configuration` (optional `(version N)`) |
| `vwaffle apply --yes` with no drift | `No drift detected.` only |
| Immediate `vwaffle plan` after a successful apply | `No drift detected.` |

## Related pages

<CardGroup>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
plan versus apply, --check exit codes, --dry-run, and the --yes gate.
</Card>
<Card title="Diff output" href="/diff-output">
How diff, diffLines, and withContext render live-versus-desired changes.
</Card>
<Card title="Secret interpolation" href="/secret-interpolation">
When ${VAR_NAME} is expanded, skipped, or fails apply, and how [REDACTED] is written.
</Card>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run plan --check so a dashboard edit fails the build.
</Card>
<Card title="Apply from CI" href="/apply-from-ci">
Promote firewall.config.json with apply --yes on merge.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands and flags: plan, apply, --dry-run, --yes, --config, --project, --team.
</Card>
</CardGroup>

---

## 12. Detect drift in CI

> Run plan --check so a dashboard edit that diverges from the versioned file fails the build.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/12-detect-drift-in-ci.md
- Generated: 2026-08-13T01:29:18.391Z

### Source Files

- `src/index.ts`
- `src/diff.ts`
- `src/api.ts`
- `README.md`

---
title: "Detect drift in CI"
description: "Run plan --check so a dashboard edit that diverges from the versioned file fails the build."
---

`vwaffle plan --check` compares the active Vercel Firewall config (`GET /v1/security/firewall/config/active`) with the interpolated desired file (default `firewall.config.json`) and sets `process.exitCode = 1` when `diff()` does not return `No drift detected.` The command never calls `putConfig`. A dashboard edit, a missing interpolated rule, or any extra live field that is not in the versioned JSON fails the job.

## Command

<CodeGroup>

```sh title="npx"
npx vwaffle plan --check
```

```sh title="bunx"
bunx vwaffle plan --check
```

```sh title="installed binary"
vwaffle plan --check
```

</CodeGroup>

`--check` is only consumed by `cmdPlan`. `vwaffle plan` without `--check` prints the same stdout and still exits `0` on drift. `apply` ignores `--check`.

<ParamField body="--check" type="boolean">
When the printed result is not `No drift detected.`, set `process.exitCode = 1` after writing the diff. Does not call `process.exit(1)` immediately, so the full diff reaches CI logs.
</ParamField>

<ParamField body="-c, --config" type="string" default="firewall.config.json">
Path to the desired JSON, resolved from `process.cwd()`.
</ParamField>

<ParamField body="--project" type="string">
Vercel project ID. Overrides `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId`.
</ParamField>

<ParamField body="--team" type="string">
Vercel team ID. Overrides `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId`. Optional; omitted from the query string when unset.
</ParamField>

## Prerequisites

- Node `>=18` (package `engines`).
- A versioned desired file, typically produced by `vwaffle pull --output firewall.config.json` so the JSON shape matches what `getActiveConfig` returns.
- `VERCEL_TOKEN` in the job environment. `resolveContext` throws without it.
- A project ID via `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json`. The repo `.gitignore` lists `.vercel/`, so CI cannot rely on a local `vercel link` unless the runner recreates that file.
- Every `${VAR_NAME}` referenced by rules you want compared. `plan` loads the file with `strict: false`.

<Warning>
`.vercel/` is gitignored. Pass `--project` / `--team` or set `VERCEL_PROJECT_ID` / `VERCEL_TEAM_ID` in the job. A missing project fails with `A project is required.` and exit `1` before any diff.
</Warning>

## What the job does

```mermaid
sequenceDiagram
    participant Job as CI job
    participant Plan as vwaffle plan --check
    participant File as firewall.config.json
    participant API as GET /v1/security/firewall/config/active

    Job->>Plan: invoke
    Plan->>File: loadDesiredConfig(path, strict false)
    File-->>Plan: interpolated FirewallConfig
    Plan->>Plan: resolveContext(token, project, team)
    Plan->>API: getActiveConfig
    API-->>Plan: live.active or live body
    Plan->>Plan: diff(live, desired)
    alt identical tab-indented JSON
        Plan-->>Job: No drift detected. / exit 0
    else any line difference
        Plan-->>Job: unified diff / exit 1
    end
```

`diff()` stringifies both sides with `JSON.stringify(..., null, '\t')`. Key order, extra live fields (`id`, CRS entries, notes), and dropped rules all count as drift. There is no field allowlist.

<Info>
`plan --check` is detect-only. To overwrite live from the file on merge, use `vwaffle apply --yes` on a separate job. See [Apply from CI](/apply-from-ci).
</Info>

## GitHub Actions step

The repository does not ship a workflow file. The README documents this step:

```yaml title=".github/workflows/firewall.yml (example)"
- run: npx vwaffle plan --check
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
    VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
```

Add the same `env` entries for every `${VAR_NAME}` used in `firewall.config.json`. Optional flags:

```sh
npx vwaffle plan --check \
  --config firewall.config.json \
  --project "$VERCEL_PROJECT_ID" \
  --team "$VERCEL_TEAM_ID"
```

`VERCEL_API_URL` overrides the API host (default `https://api.vercel.com`). The path remains `/v1/security/firewall/config/active?projectId=…` with `teamId` when set.

## Exit codes

| Condition | stdout / stderr | Exit |
| --- | --- | --- |
| Live JSON equals desired JSON | `No drift detected.` | `0` |
| Any difference and `--check` | Unified diff starting `--- live firewall configuration` | `1` |
| Difference without `--check` | Same unified diff | `0` |
| Missing `VERCEL_TOKEN` | `vwaffle: VERCEL_TOKEN is required. …` | `1` |
| Unresolved project | `vwaffle: A project is required. …` | `1` |
| Missing or invalid desired file | `vwaffle: …` from `readFile` / `JSON.parse` | `1` |
| Firewall API non-OK | `vwaffle: Vercel API {status} {statusText}: …` | `1` |
| Unset `${VAR_NAME}` on plan | `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)` then a possible diff | `0` or `1` |

Errors go through `main().catch`, which prints `vwaffle: {message}` and sets `process.exitCode = 1`. Unset interpolation variables do **not** fail `plan` by themselves.

## Expected output

<ResponseExample>

```text title="In sync"
No drift detected.
```

```text title="Drift (check fails)"
--- live firewall configuration
+++ desired firewall configuration
  {
  	"firewallEnabled": true,
- 	"managedRules": {
- 		"owasp": {
- 			"active": false
- 		}
- 	},
+ 	"managedRules": {
+ 		"owasp": {
+ 			"active": true
+ 		}
+ 	},
  ...
```

</ResponseExample>

`-` lines are live. `+` lines are desired. Unchanged lines use two spaces; `withContext` keeps three lines of context and inserts `  ...` between hunks.

<Warning>
`cmdPlan` prints `diff(live, config)` without `redactText`. Interpolated secret values appear in CI logs whenever those lines differ. `apply` redacts secrets as `[REDACTED]`; `plan` does not. A green check (`No drift detected.`) does not print rule bodies.
</Warning>

## Interpolation in CI

`loadDesiredConfig(..., { strict: false })` expands `${VAR_NAME}` from `process.env`. Missing names stay as the literal placeholder and are recorded as `NAME ($.json.path)`.

| Surface | Behavior |
| --- | --- |
| `config.rules` | `removeRulesWithMissingEnv` drops any rule whose serialized text still contains `${NAME}` |
| Other fields (`ips`, `managedRules`, arbitrary strings) | Placeholder text is left in place and compared to live |
| `apply` (not this command) | `strict: true` throws `missing environment variables: …` |

If CI omits a secret that a versioned rule references, that rule disappears from the desired side while live still has it. `--check` then fails even though the committed file is the intended source of truth.

<Check>
Set every interpolation variable the compared rules need. Treat those values as required CI secrets, not optional local-only env.
</Check>

## Add the check

<Steps>
<Step title="Commit the live-shaped file">
Author from `vwaffle pull --output firewall.config.json` (or `vwaffle init` then pull) so extra API fields are not a permanent diff.
</Step>
<Step title="Inject token, project, team, and ${VAR_NAME} secrets">
`VERCEL_TOKEN` is required. Project is required. Team is optional but needed when the token is team-scoped.
</Step>
<Step title="Run plan --check on the PR or main pipeline">
Use `npx vwaffle plan --check` or `bunx vwaffle plan --check`. Fail the job on exit `1`.
</Step>
<Step title="Read the job log">
Green: `No drift detected.` Red: the unified diff, or a `vwaffle:` auth/API/file error.
</Step>
</Steps>

## Troubleshooting

<AccordionGroup>
<Accordion title="Check fails after a dashboard-only edit">
Expected. Live no longer matches the versioned file. Either revert the dashboard change or `vwaffle pull --output firewall.config.json`, review, and commit — or run `apply --yes` from CI to push the file.
</Accordion>
<Accordion title="Check fails with no dashboard change">
Common causes: unset `${VAR_NAME}` dropped a rule; desired file missing live-only keys; different key order; `--config` pointing at the wrong path; `VERCEL_PROJECT_ID` / `--project` targeting another project.
</Accordion>
<Accordion title="Job fails before a diff">
`VERCEL_TOKEN is required`, `A project is required`, or `Vercel API {status}` come from `resolveContext` / `request`, not from drift. Fix credentials and IDs first.
</Accordion>
<Accordion title="Warning about unset variables, then exit 1">
`plan` skipped rules that still contain `${NAME}`. Add those variables to the job `env` so the desired side includes the same rules as live.
</Accordion>
<Accordion title="plan without --check is always green">
The diff still prints. Only `--check` maps `result !== 'No drift detected.'` to exit `1`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Apply from CI" href="/apply-from-ci">
Promote `firewall.config.json` with `apply --yes` on merge.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
`--check` versus `--dry-run` versus `--yes`.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Unset `${VAR_NAME}` on plan versus apply, and exit `1` on drift.
</Card>
<Card title="Diff output" href="/diff-output">
How `diff`, `diffLines`, and `withContext` render live versus desired.
</Card>
<Card title="Project and team context" href="/project-context">
How `resolveContext` picks token, project, and team.
</Card>
</CardGroup>

---

## 13. Apply from CI

> Promote firewall.config.json as source of truth by running apply --yes on merge with project, team, and token inputs.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/13-apply-from-ci.md
- Generated: 2026-08-13T01:30:01.559Z

### Source Files

- `src/index.ts`
- `src/api.ts`
- `src/config.ts`
- `README.md`

---
title: "Apply from CI"
description: "Promote firewall.config.json as source of truth by running apply --yes on merge with project, team, and token inputs."
---

`vwaffle apply --yes` is the non-interactive promotion path. `cmdApply` loads the desired file (default `firewall.config.json`) with `loadDesiredConfig(..., { strict: true })`, resolves `VERCEL_TOKEN` plus project and team through `resolveContext`, diffs `GET /v1/security/firewall/config/active` against the interpolated JSON, then `putConfig` issues `PUT /v1/security/firewall/config`. Without `--yes` (and without `--dry-run`) the command throws and the process exits `1`, so a merge job must pass `--yes`. When the live and desired JSON stringify identically, apply prints `No drift detected.` and skips the PUT.

<Warning>
`apply` without `--yes` is not a no-op plan. It fails immediately with `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.`
</Warning>

## When to run apply in CI

The README positions two complementary jobs:

| Job | Command | Role |
| --- | --- | --- |
| Pull request / branch check | `npx vwaffle plan --check` | Exit `1` when live config differs from the versioned file |
| Merge to the default branch | `npx vwaffle apply --yes` | PUT the versioned file so `firewall.config.json` wins over dashboard edits |

`plan --check` only reports drift. `apply --yes` overwrites the live Vercel Firewall config with the interpolated desired file. Re-running apply after a successful PUT is idempotent: the next run prints `No drift detected.` and does not call PUT.

## Prerequisites

- Node `>=18` (`package.json` `engines.node`) so `npx` / `bunx` can run the published `vwaffle` binary (`dist/index.js`).
- A committed desired file. Default path is `firewall.config.json` relative to `process.cwd()`. Override with `-c` / `--config FILE`.
- A Vercel API token in `VERCEL_TOKEN` ([account tokens](https://vercel.com/account/tokens)).
- A project ID. CI checkouts usually do not include `.vercel/project.json`, so set `VERCEL_PROJECT_ID` or pass `--project`.
- A team ID when the project is team-scoped: `VERCEL_TEAM_ID` or `--team`. `teamId` is omitted from the API URL when unset.
- Every `${VAR_NAME}` referenced in the desired JSON must be present in the apply job environment. Strict apply fails instead of skipping rules.

<Note>
`resolveContext` also reads `.vercel/project.json` (`projectId`, `orgId`) when that file exists. Prefer explicit env vars or flags in CI so the job does not depend on a linked local checkout.
</Note>

## Inputs

Context resolution is first-match:

| Value | Sources |
| --- | --- |
| Token | `VERCEL_TOKEN` only (required) |
| Project | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` |
| Team | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` |
| Desired file | `-c` / `--config` (default `firewall.config.json`, resolved from `process.cwd()`) |
| API host | `VERCEL_API_URL` (default `https://api.vercel.com`) |

<ParamField body="--yes" type="boolean" required>
Confirms the live PUT. Required unless `--dry-run` is set.
</ParamField>

<ParamField body="--dry-run" type="boolean">
Prints the redacted interpolated payload and returns without `resolveContext`, GET, or PUT. Interpolation is non-strict (`strict: false`), so missing `${VAR_NAME}` values warn and drop matching `rules` instead of failing.
</ParamField>

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

<ParamField body="--project" type="string">
Vercel project ID. Overrides `VERCEL_PROJECT_ID` and `.vercel/project.json`.
</ParamField>

<ParamField body="--team" type="string">
Vercel team ID. Overrides `VERCEL_TEAM_ID` and `.vercel/project.json` `orgId`.
</ParamField>

<ParamField body="VERCEL_TOKEN" type="string" required>
Bearer token for `Authorization: Bearer ${token}` on both GET `/active` and PUT.
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Project query parameter when `--project` is omitted.
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Team query parameter when `--team` is omitted.
</ParamField>

Any other environment variable named in a `${VAR_NAME}` placeholder is interpolated into string fields before the PUT body is sent. Interpolated values are replaced with `[REDACTED]` in the printed diff.

## Merge job

The published repository does not ship a workflow file. The documented pattern is a GitHub Actions step that injects token, project, and team, then runs `apply --yes` after merge.

<Steps>
<Step title="Keep the desired file in the checkout">
Commit `firewall.config.json` (or pass `--config` to a non-default path). `cmdApply` reads from `process.cwd()`, so run the job from the repository root that contains that file.
</Step>
<Step title="Inject token, project, team, and interpolation secrets">
Map CI secrets/vars onto `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, and every `${VAR_NAME}` used in the JSON. Do not commit secret values.
</Step>
<Step title="Run apply --yes">
Use `npx`, `bunx`, or a globally installed `vwaffle`. The `--yes` flag is what allows the PUT.
</Step>
<Step title="Confirm the success line">
Expect either `Applied firewall configuration` / `Applied firewall configuration (version N)` after a PUT, or `No drift detected.` when live already matches desired.
</Step>
</Steps>

<CodeGroup>

```yaml title="GitHub Actions merge step"
# .github/workflows/firewall.yml — apply on merge (README pattern)
- run: npx vwaffle apply --yes
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
    VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
    # Add every ${VAR_NAME} referenced in firewall.config.json, e.g.:
    # INTERNAL_TOKEN: ${{ secrets.INTERNAL_TOKEN }}
```

```sh title="npx with flags"
npx vwaffle apply --yes \
  --project "$VERCEL_PROJECT_ID" \
  --team "$VERCEL_TEAM_ID"
```

```sh title="bunx"
bunx vwaffle apply --yes
```

```sh title="Inspect payload only"
npx vwaffle apply --dry-run
```

</CodeGroup>

Pair this with `npx vwaffle plan --check` on pull requests so dashboard-only edits fail the branch build before merge. After merge, `apply --yes` makes the file the source of truth.

## Apply lifecycle

```mermaid
sequenceDiagram
  participant Job as CI job
  participant Apply as cmdApply
  participant Load as loadDesiredConfig
  participant Ctx as resolveContext
  participant API as Vercel Firewall API

  Job->>Apply: vwaffle apply --yes
  alt missing --yes and not --dry-run
    Apply-->>Job: exit 1 apply requires --yes
  else --dry-run
    Apply->>Load: strict false
    Load-->>Apply: interpolated config
    Apply-->>Job: redacted JSON, no API
  else --yes
    Apply->>Load: strict true
    alt missing ${VAR_NAME}
      Load-->>Job: exit 1 missing environment variables
    else interpolated
      Apply->>Ctx: token, project, team
      Ctx->>API: GET /v1/security/firewall/config/active
      API-->>Apply: live config
      alt No drift detected.
        Apply-->>Job: print message, skip PUT
      else live differs
        Apply->>API: PUT /v1/security/firewall/config
        API-->>Apply: optional version
        Apply-->>Job: Applied firewall configuration
      end
    end
  end
```

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

**Query**

| Name | Source |
| --- | --- |
| `projectId` | Resolved project ID (required) |
| `teamId` | Resolved team ID (omitted when unset) |

**Headers**

- `Authorization: Bearer ${VERCEL_TOKEN}`
- `Content-Type: application/json`

**Body** — interpolated desired config (`firewallEnabled`, `managedRules`, `rules`, `ips`, and other Firewall API fields). Placeholders such as `${INTERNAL_TOKEN}` are expanded before serialize.

**CLI stdout after a successful PUT**

```text
--- live firewall configuration
+++ desired firewall configuration
  ...
Applied firewall configuration (version 12).
```

Secret substrings in the diff are replaced with `[REDACTED]`. The version suffix is included only when the PUT response has `version`.
:::

:::endpoint GET /v1/security/firewall/config/active
Fetched on every live `apply --yes` before the PUT so the printed diff compares live versus desired. `getActiveConfig` unwraps `{ active: FirewallConfig }` when that wrapper is present.
:::

## Verification

<Check>
A successful apply that changed live config prints `Applied firewall configuration` or `Applied firewall configuration (version N)` and exits `0`.
</Check>

<Check>
A successful apply with no change prints `No drift detected.` and exits `0` without PUT.
</Check>

Failures print `vwaffle: <message>` to stderr and set `process.exitCode = 1`.

<RequestExample>
```sh
npx vwaffle apply --yes
```
</RequestExample>

<ResponseExample>
```text
No drift detected.
```
</ResponseExample>

## Interpolation in the apply job

`apply --yes` calls `loadDesiredConfig` with `strict: true`. Unset `${VAR_NAME}` values abort before any Vercel request:

```text
vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.
```

That is different from `plan` and `apply --dry-run`, which warn, run `removeRulesWithMissingEnv`, and continue. A merge job that omits a secret used by a bypass or allow rule will fail closed rather than PUT a config with that rule dropped.

## Failure modes

<AccordionGroup>
<Accordion title="apply requires --yes">
The job invoked `vwaffle apply` without `--yes` or `--dry-run`. Add `--yes` on the merge job.
</Accordion>
<Accordion title="VERCEL_TOKEN is required">
`resolveContext` found no `VERCEL_TOKEN`. Export it in the job environment.
</Accordion>
<Accordion title="A project is required">
No `--project`, no `VERCEL_PROJECT_ID`, and no `.vercel/project.json` `projectId`. Set the project in CI.
</Accordion>
<Accordion title="missing environment variables">
A `${VAR_NAME}` in the desired file is unset. Strict apply refuses to PUT. Add the variable to the job `env`.
</Accordion>
<Accordion title="Vercel API status statusText">
`request` received a non-OK response. The message includes the HTTP status and parsed body. Check token scope, `projectId` / `teamId`, and that the interpolated JSON is a valid Firewall config body.
</Accordion>
<Accordion title="unknown option / unknown command">
`parseArgs` rejected a flag or command. Apply accepts `--yes`, `--dry-run`, `--config` / `-c`, `--project`, `--team`, `--help`, and `--version`. `--check` is a `plan` flag only.
</Accordion>
</AccordionGroup>

<Warning>
`apply --yes` always fetches live config first. A missing token or project fails even when the desired file is unchanged. `--dry-run` is the only apply mode that skips authentication.
</Warning>

## Next

<CardGroup>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run `plan --check` on pull requests so dashboard edits fail the build before merge.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
How `--check`, `--dry-run`, and `--yes` change exit codes and whether PUT runs.
</Card>
<Card title="Project and team context" href="/project-context">
How `resolveContext` picks token, project, and team from flags, env, and `.vercel/project.json`.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Strict apply vs skipped rules on plan, and `plan --check` exit `1` on drift.
</Card>
</CardGroup>

---

## 14. CLI reference

> vwaffle commands init, pull, plan, apply, and help, plus flags --config, --output, --check, --dry-run, --yes, --project, and --team.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/14-cli-reference.md
- Generated: 2026-08-13T01:26:44.575Z

### Source Files

- `src/index.ts`
- `package.json`
- `src/api.ts`
- `README.md`

---
title: "CLI reference"
description: "vwaffle commands init, pull, plan, apply, and help, plus flags --config, --output, --check, --dry-run, --yes, --project, and --team."
---

`vwaffle` is the published binary (`dist/index.js`, shebang `#!/usr/bin/env node`) that parses `process.argv`, then dispatches `init`, `pull`, `plan`, `apply`, `help`, or `version`. The parser lives in `src/index.ts`. The first non-flag argument is the command. With no arguments, the command defaults to `help`.

<CodeGroup>

```sh title="installed binary"
vwaffle <command> [options]
```

```sh title="npx"
npx vwaffle <command> [options]
```

```sh title="bunx"
bunx vwaffle <command> [options]
```

```sh title="from source"
bun run src/index.ts <command> [options]
```

</CodeGroup>

Package `bin` maps `vwaffle` → `./dist/index.js`. Version printed by `-v` / `--version` is the string `0.1.0` in `src/index.ts` (same as `package.json`).

```text
vwaffle <command> [options]
        │
        ├─ init     write starter JSON (no API)
        ├─ pull     GET /v1/security/firewall/config/active
        ├─ plan     diff live vs desired  [--check → exit 1]
        ├─ apply    --dry-run | --yes PUT
        ├─ help     usage text (default)
        └─ version  print 0.1.0
```

## Invocation

| Rule | Behavior |
| --- | --- |
| First non-flag token | Becomes `command`. Later non-flag tokens are `unknown option`. |
| No command | `help` |
| `-h` / `--help` | Sets `command` to `help`, even after another command |
| `-v` / `--version` | Sets `command` to `version` |
| Unknown flag | Throws `unknown option <arg>` |
| Flag missing its value | Throws `<flag> requires a value` |
| Combined shorts (`-cv`) or `--config=file` | Not supported; treated as unknown options |
| Flags before the command | Accepted; parse is a single left-to-right pass |

Flags are global. The parser does not reject a flag on the wrong command. Unused flags are ignored (`--check` on `apply`, `--output` on `plan`, `--yes` on `pull`).

<RequestExample>

```sh
vwaffle help
vwaffle --version
vwaffle plan --check -c firewall.config.json --project prj_xxx --team team_xxx
```

</RequestExample>

## Commands

| Command | API | Config file | Confirmation | Exit 1 |
| --- | --- | --- | --- | --- |
| `init` | none | writes `--config` | refuses overwrite | file already exists |
| `pull` | `GET …/config/active` | none | — | missing token/project or API error |
| `plan` | `GET …/config/active` | reads `--config` (non-strict) | — | errors; also `--check` when drift |
| `apply` | `GET` then `PUT …/config` | reads `--config` (strict unless `--dry-run`) | `--yes` required unless `--dry-run` | missing `--yes`, missing env, API error |
| `help` | none | none | — | — |
| `version` | none | none | — | — |

Unknown commands throw `unknown command <name>. Run \`vwaffle help\`.`

### `init`

Writes a starter desired config. Does not call Vercel and does not read `VERCEL_TOKEN`.

- Target path: `resolve(process.cwd(), options.config)` (default `firewall.config.json`).
- If `access(target)` succeeds, throws `<file> already exists; refusing to overwrite.`
- Creates parent directories (`mkdir(..., { recursive: true })`).
- Serializes the built-in `STARTER_CONFIG` with tab indent and a trailing newline.

<ResponseExample>

```text
Wrote firewall.config.json. Edit it, then run `vwaffle plan`.
```

</ResponseExample>

Starter body:

```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": []
}
```

`vwaffle init -c path/to/custom.json` writes that path instead.

### `pull`

Resolves project context, `GET`s the active firewall config, and prints tab-indented JSON.

- Without `--output`: writes the JSON to stdout (no trailing log line).
- With `-o` / `--output FILE`: writes `resolve(process.cwd(), FILE)` plus a trailing newline, then logs success.

<ResponseExample>

```text
Wrote active firewall configuration to firewall.config.json.
```

</ResponseExample>

`pull` does not read `--config`. Use `--output` to choose the destination file.

### `plan`

Loads the desired file with `loadDesiredConfig(..., { strict: false })`, fetches live config, and prints `diff(live, desired)`.

- Unset `${VAR_NAME}` values warn and drop the referencing `rules` entries; the command still runs.
- Identical JSON prints `No drift detected.`
- Drift prints a unified-style line diff (`--- live firewall configuration` / `+++ desired firewall configuration`) with 3 lines of context.
- `--check` sets `process.exitCode = 1` when the printed result is not `No drift detected.` The diff is still written to stdout.

<RequestExample>

```sh
vwaffle plan
vwaffle plan --check
vwaffle plan -c ./configs/prod.firewall.json --project prj_xxx
```

</RequestExample>

### `apply`

Puts the interpolated desired config. One of `--yes` or `--dry-run` is required.

```mermaid
flowchart TD
  apply["vwaffle apply"] --> gate{"--yes or --dry-run?"}
  gate -->|neither| err["exit 1: apply requires --yes"]
  gate -->|"--dry-run"| loadLoose["loadDesiredConfig strict: false"]
  loadLoose --> payload["print redacted JSON and return"]
  gate -->|"--yes" only| loadStrict["loadDesiredConfig strict: true"]
  loadStrict --> ctx["resolveContext"]
  ctx --> live["getActiveConfig GET /active"]
  live --> d["diff live vs desired"]
  d --> out["print redacted diff"]
  out --> drift{"result === No drift detected."}
  drift -->|yes| stop["return; no PUT"]
  drift -->|no| put["putConfig PUT /v1/security/firewall/config"]
  put --> done["Applied firewall configuration (version N)."]
```

| Mode | Interpolation | Network | Output |
| --- | --- | --- | --- |
| `apply` with neither flag | not loaded | none | `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` |
| `apply --dry-run` | `strict: false` (warn + skip rules) | none; skips `resolveContext` | tab-indented JSON with secret values replaced by `[REDACTED]` |
| `apply --yes` | `strict: true` (throws if any `${VAR}` unset) | `GET /active`, then `PUT` unless no drift | redacted diff, then apply line |
| `apply --yes --dry-run` | treated as dry-run (`strict: false`, no PUT) | none | same as `--dry-run` |

`--dry-run` does not require `VERCEL_TOKEN` or a project ID.

On a successful PUT, the CLI prints `Applied firewall configuration` and appends ` (version N)` when the API body includes `version`. If the diff is `No drift detected.`, apply returns without PUT.

### `help`

Prints the usage block from `printHelp()`. Also selected by default, `-h`, or `--help`.

### `version`

Prints `0.1.0` to stdout. Selected by `-v` or `--version`. Not listed under Commands in the help text; it is listed under Options.

## Flags

<ParamField body="-c, --config" type="string" default="firewall.config.json">
Path to the desired config, resolved from `process.cwd()`. Used by `init` (write target), `plan`, and `apply`. Not read by `pull`.
</ParamField>

<ParamField body="-o, --output" type="string">
For `pull` only. Write the active config to this path instead of stdout. Resolved from `process.cwd()`.
</ParamField>

<ParamField body="--check" type="boolean" default="false">
For `plan` only. Sets `process.exitCode = 1` when live JSON differs from desired JSON. Does not change the printed diff.
</ParamField>

<ParamField body="--dry-run" type="boolean" default="false">
For `apply`. Print the redacted interpolated payload and return. No `resolveContext`, no GET, no PUT. Interpolation is non-strict.
</ParamField>

<ParamField body="--yes" type="boolean" default="false">
For `apply`. Confirm the PUT. Without `--yes` or `--dry-run`, apply throws.
</ParamField>

<ParamField body="--project" type="string">
Vercel project ID. First match among `--project`, `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId`. Required for `pull`, `plan`, and non-dry-run `apply`.
</ParamField>

<ParamField body="--team" type="string">
Vercel team ID. First match among `--team`, `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId`. Optional; when set, appended as `teamId` on Firewall API URLs.
</ParamField>

<ParamField body="-h, --help" type="boolean">
Force the `help` command.
</ParamField>

<ParamField body="-v, --version" type="boolean">
Force the `version` command.
</ParamField>

<Warning>
`--yes` is a hard gate, not an interactive prompt. There is no confirmation TTY. CI and local apply both pass `--yes` explicitly.
</Warning>

## Environment consumed by the CLI

| Variable | Required for | Role |
| --- | --- | --- |
| `VERCEL_TOKEN` | `pull`, `plan`, `apply --yes` | Bearer token. Only source; no flag. |
| `VERCEL_PROJECT_ID` | same, if `--project` and `.vercel/project.json` are absent | Project query param |
| `VERCEL_TEAM_ID` | optional | Team query param |
| `VERCEL_API_URL` | optional | API origin; default `https://api.vercel.com` |
| `${VAR_NAME}` matches in the JSON file | `plan` / `apply` | Interpolated into string fields |

`init`, `help`, `version`, and `apply --dry-run` do not call `resolveContext`.

String placeholders must match `/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/`. Interpolated values are recorded as secrets and replaced with `[REDACTED]` in `apply` stdout (payload and diff text).

## Exit codes and errors

Unhandled throws are printed as `vwaffle: <message>` on stderr and set `process.exitCode = 1`.

| Situation | Message / result |
| --- | --- |
| Unknown flag | `unknown option <arg>` |
| Flag without value | `<flag> requires a value` |
| Unknown command | `unknown command <name>. Run \`vwaffle help\`.` |
| `init` target exists | `<file> already exists; refusing to overwrite.` |
| `apply` without `--yes` or `--dry-run` | `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` |
| Missing token | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` |
| Missing project | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.` |
| Unset `${VAR}` on `apply --yes` | `missing environment variables: NAME ($.path), …. Set them before applying the firewall configuration.` |
| Unset `${VAR}` on `plan` or `apply --dry-run` | stderr warning `vwaffle: warning: skipping rules that reference unset variables: …`; matching `rules` dropped |
| Firewall API non-OK | `Vercel API <status> <statusText>: <body>` |
| `plan --check` with drift | stdout is the diff; exit code `1` |
| `apply --yes` with no drift | stdout `No drift detected.`; exit `0`; no PUT |

## Help text

The built-in usage string is the operator-facing contract. It is what `vwaffle`, `vwaffle help`, and `vwaffle -h` print:

<ResponseExample>

```text
vwaffle 0.1.0 — config-as-code for Vercel WAF / Security / Bot Protection

Usage: vwaffle <command> [options]

Commands:
  init                  Write a starter firewall.config.json in the current directory
  pull                  Fetch the active config; print it or write --output FILE
  plan [--check]        Compare the active config with the desired config
  apply --yes           Print the diff and PUT the desired config
  help                  Show this help

Options:
  -c, --config FILE     Path to the desired config (default: firewall.config.json)
  -o, --output FILE     For pull, write the response to FILE instead of stdout
      --check           For plan, exit 1 when the live config differs
      --dry-run         For apply, print the redacted payload without an API call
      --yes             Confirm apply
      --project ID      Vercel project ID (or VERCEL_PROJECT_ID / .vercel/project.json)
      --team ID         Vercel team ID (or VERCEL_TEAM_ID / .vercel/project.json)
  -v, --version         Print the version

Environment:
  VERCEL_TOKEN          Required for pull/plan/apply (https://vercel.com/account/tokens)
  VERCEL_PROJECT_ID     Project to target when not linked via `vercel link`
  VERCEL_TEAM_ID        Team scope for the API token

Values like ${MY_SECRET} inside the config file are interpolated from the
environment and redacted in all output.
```

</ResponseExample>

## Related pages

<CardGroup>
  <Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
    How plan, --check, --dry-run, and --yes sequence GET, diff, and PUT.
  </Card>
  <Card title="Project and team context" href="/project-context">
    resolveContext order for token, --project, --team, and .vercel/project.json.
  </Card>
  <Card title="Scaffold and pull a config" href="/scaffold-and-pull">
    init versus pull --output for the first firewall.config.json.
  </Card>
  <Card title="Detect drift in CI" href="/detect-drift-in-ci">
    plan --check as a non-zero gate when the dashboard diverges from git.
  </Card>
  <Card title="Apply from CI" href="/apply-from-ci">
    apply --yes on merge with project, team, and token inputs.
  </Card>
  <Card title="Authentication and context errors" href="/authentication-errors">
    Missing VERCEL_TOKEN, unresolved project ID, and Firewall API failures.
  </Card>
</CardGroup>

---

## 15. Configuration reference

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

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/15-configuration-reference.md
- Generated: 2026-08-13T01:27:01.843Z

### 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>

---

## 16. Environment variables

> VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID, and arbitrary ${VAR_NAME} values interpolated into string fields.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/16-environment-variables.md
- Generated: 2026-08-13T01:24:31.934Z

### Source Files

- `src/api.ts`
- `src/config.ts`
- `src/index.ts`
- `README.md`

---
title: "Environment variables"
description: "VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID, and arbitrary ${VAR_NAME} values interpolated into string fields."
---

`resolveContext` reads `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID` (plus optional `VERCEL_API_URL`) to authenticate Firewall API calls. `loadDesiredConfig` then expands any `${VAR_NAME}` placeholder in string fields of the desired JSON from `process.env`, records resolved values for redaction, and either fails `apply` or drops affected `rules` on `plan` / `apply --dry-run` when a name is unset.

## Context variables

These process-environment keys select the Vercel account, project, team, and API origin. `vwaffle help` documents the first three. `init`, `help`, and `version` do not call `resolveContext`. `apply --dry-run` also skips it.

| Variable | Required | Used by | Role |
| --- | --- | --- | --- |
| `VERCEL_TOKEN` | Yes for `pull`, `plan`, and `apply --yes` | `resolveContext` → `Authorization: Bearer …` | API token from [vercel.com/account/tokens](https://vercel.com/account/tokens) |
| `VERCEL_PROJECT_ID` | When `--project` and `.vercel/project.json` `projectId` are absent | `resolveContext` → `projectId` query param | Project to target |
| `VERCEL_TEAM_ID` | No | `resolveContext` → `teamId` query param when set | Team scope for the token |
| `VERCEL_API_URL` | No | `API_URL` in `src/api.ts` | API origin; default `https://api.vercel.com` |

<ParamField body="VERCEL_TOKEN" type="string" required>
Bearer token. `resolveContext` throws if `process.env.VERCEL_TOKEN` is missing. There is no CLI flag override.
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Used only when `--project` is omitted. If this and `.vercel/project.json` `projectId` are also unset, `resolveContext` throws.
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Used only when `--team` is omitted. Falls back to `.vercel/project.json` `orgId`. Omitted from the request URL when no team ID is resolved.
</ParamField>

<ParamField body="VERCEL_API_URL" type="string">
Optional origin. The client always appends `/v1/security/firewall/config`. Not listed in `vwaffle help`.
</ParamField>

```sh
export VERCEL_TOKEN=...        # https://vercel.com/account/tokens
```

<Note>
There is no `.env` loader. Values must already be in the process environment (shell `export`, CI `env:`, or the parent process).
</Note>

## Resolution order

First match wins. Flags beat environment variables; environment variables beat `.vercel/project.json` from `vercel link`.

| Value | Sources |
| --- | --- |
| Token | `VERCEL_TOKEN` only |
| Project | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` |
| Team | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` |

A missing `.vercel/project.json` is ignored when flags or environment variables supply the IDs. Linked-project JSON is read with `readJson` from `process.cwd()`.

`requestUrl` always sets `projectId`. It sets `teamId` only when `context.teamId` is truthy.

## Config interpolation

Any `${VAR_NAME}` inside a **string** field of the desired file (`firewall.config.json` by default, or `-c` / `--config`) is replaced from `process.env`. Object keys, numbers, booleans, and `null` are left unchanged. Nested objects and arrays are walked with JSONPath-style paths starting at `$`.

The placeholder grammar is `\$\{([A-Za-z_][A-Za-z0-9_]*)\}`:

- Matches `${INTERNAL_TOKEN}`, `${MY_SECRET}`, `${A1_b}`.
- Does not match `$VAR`, `${123}`, or `${name-with-dashes}`.

On a hit:

| `process.env[name]` | Result |
| --- | --- |
| Defined (including `""`) | Placeholder replaced; `secrets.set(name, replacement)` |
| `undefined` | Placeholder left in place; `missing` records `NAME (path)` |

Example path for a header bypass value: `$.rules[1].conditionGroup[0].conditions[0].value`.

```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" } }
}
```

<Tip>
Keep the placeholder in the versioned file. Set `INTERNAL_TOKEN` (or any other name) in the environment so the secret never lands in git.
</Tip>

`pull` writes the live API body as-is and does not interpolate. `init` writes a starter file with no placeholders.

## Missing variables by command

`loadDesiredConfig(path, { strict })` interpolates first, then branches on `missing`.

```mermaid
flowchart TD
  load["loadDesiredConfig(path, strict)"] --> interp["interpolate string fields from process.env"]
  interp --> miss{"missing.size > 0?"}
  miss -->|no| ok["return config and secrets"]
  miss -->|"yes and strict"| fail["throw missing environment variables"]
  miss -->|"yes and not strict"| warn["console.warn: skipping rules that reference unset variables"]
  warn --> drop["removeRulesWithMissingEnv"]
  drop --> ok
```

| Command | `strict` | Unset `${VAR_NAME}` |
| --- | --- | --- |
| `plan` | `false` | Warn, drop matching `rules`, continue |
| `apply --dry-run` | `false` | Same as `plan` |
| `apply --yes` | `true` | Throw; no PUT |

Non-strict warning:

```text
vwaffle: warning: skipping rules that reference unset variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value)
```

Strict error (also prefixed `vwaffle: ` on stderr, exit code `1`):

```text
missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.
```

`removeRulesWithMissingEnv` extracts each missing name as the token before the first space, then drops any `config.rules` entry whose `JSON.stringify` still contains `` `${name}` ``. If `rules` is not an array, or no rule contains those placeholders, the rest of the object is unchanged — including uninterpolated `${VAR_NAME}` strings on `ips` or other non-rule fields. `apply --yes` still fails whenever any placeholder is unset, regardless of where it sits.

## Redaction

Resolved secret **values** (the `secrets` map) are replaced with `[REDACTED]` when:

- `apply --dry-run` prints `redacted(config, secrets)` (tab-indented JSON).
- `apply --yes` prints `redactText(result, secrets)` on the live-versus-desired diff.

Empty-string replacements are not redacted (`if (secret)`). `plan` prints the raw `diff` string and does not run `redactText`. `vwaffle help` states that interpolated values are redacted in all output.

## Context errors

These fire before any Firewall request when `resolveContext` runs (`pull`, `plan`, `apply --yes`):

| Condition | Message |
| --- | --- |
| `VERCEL_TOKEN` unset | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` |
| No project from `--project`, `VERCEL_PROJECT_ID`, or `.vercel/project.json` `projectId` | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.` |

Failed HTTP calls throw `Vercel API ${status} ${statusText}: …` from `request`.

## CI inputs

Drift detection and apply-on-merge both need a token and a project. Pass team when the token is team-scoped.

```yaml
# .github/workflows/firewall.yml
- run: npx vwaffle plan --check
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
    VERCEL_TEAM_ID: ${{ vars.VERCEL_TEAM_ID }}
```

Export the same names, plus every `${VAR_NAME}` referenced by `firewall.config.json`, before `apply --yes`.

## Related pages

<CardGroup>
  <Card title="Project and team context" href="/project-context">
    How `resolveContext` ranks flags, environment variables, and `.vercel/project.json`.
  </Card>
  <Card title="Secret interpolation" href="/secret-interpolation">
    Placeholder expansion, rule dropping, and `[REDACTED]` output.
  </Card>
  <Card title="Authentication and context errors" href="/authentication-errors">
    Missing `VERCEL_TOKEN`, unresolved project or team IDs, and API failures.
  </Card>
  <Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
    Unset `${VAR_NAME}` on plan versus apply, and `plan --check` exit 1.
  </Card>
  <Card title="Apply from CI" href="/apply-from-ci">
    Token, project, and team inputs for `apply --yes` on merge.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Desired-file path and `loadDesiredConfig` interpolation rules.
  </Card>
</CardGroup>

---

## 17. Firewall API client

> resolveContext, getActiveConfig, putConfig, request, and requestUrl used to read and write the Vercel Firewall config.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/17-firewall-api-client.md
- Generated: 2026-08-13T01:27:49.676Z

### Source Files

- `src/api.ts`
- `src/types.ts`
- `src/index.ts`
- `src/config.ts`

---
title: "Firewall API client"
description: "resolveContext, getActiveConfig, putConfig, request, and requestUrl used to read and write the Vercel Firewall config."
---

`src/api.ts` is vwaffle’s only HTTP client. It builds a `ResolvedContext` from `VERCEL_TOKEN`, `--project` / `--team`, environment variables, and optional `.vercel/project.json`, then reads the live firewall with `GET /v1/security/firewall/config/active` and writes the desired `FirewallConfig` with `PUT /v1/security/firewall/config`. `pull`, `plan`, and `apply --yes` are the only commands that call it. `init` and `apply --dry-run` never resolve context or touch the network.

```mermaid
flowchart LR
  subgraph cli ["CLI — src/index.ts"]
    pull["cmdPull"]
    plan["cmdPlan"]
    apply["cmdApply"]
  end
  subgraph client ["Client — src/api.ts"]
    resolve["resolveContext"]
    get["getActiveConfig"]
    put["putConfig"]
    req["request + requestUrl"]
  end
  subgraph local ["Local inputs"]
    env["VERCEL_TOKEN / VERCEL_PROJECT_ID / VERCEL_TEAM_ID"]
    link[".vercel/project.json"]
  end
  subgraph vercel ["Vercel"]
    active["GET .../config/active"]
    write["PUT .../config"]
  end
  pull --> resolve
  plan --> resolve
  apply --> resolve
  resolve --> env
  resolve --> link
  pull --> get
  plan --> get
  apply --> get
  apply --> put
  get --> req
  put --> req
  req --> active
  req --> write
```

## Client surface

Only three functions are exported. `request` and `requestUrl` stay module-private.

| Symbol | Visibility | Role |
| --- | --- | --- |
| `resolveContext` | exported | Resolve `token`, `projectId`, optional `teamId` |
| `getActiveConfig` | exported | `GET` the active config, unwrap `.active` if present |
| `putConfig` | exported | `PUT` the desired `FirewallConfig` |
| `request` | private | `fetch` wrapper: auth header, JSON body, error text |
| `requestUrl` | private | Build URL + `projectId` / `teamId` query params |
| `ActiveConfigResponse` | exported type | `{ active?: FirewallConfig }` plus extra keys |

```ts
export async function resolveContext(
  overrides: { project?: string; team?: string } = {},
): Promise<ResolvedContext>

export async function getActiveConfig(
  context: ResolvedContext,
): Promise<FirewallConfig>

export async function putConfig(
  context: ResolvedContext,
  config: FirewallConfig,
): Promise<{ version?: number; [key: string]: unknown } | null>
```

## Base URL

```ts
const API_URL = `${process.env.VERCEL_API_URL ?? 'https://api.vercel.com'}/v1/security/firewall/config`;
```

| Piece | Value |
| --- | --- |
| Default host | `https://api.vercel.com` |
| Override | `VERCEL_API_URL` (not listed in `vwaffle help`) |
| Path prefix | `/v1/security/firewall/config` |
| GET suffix | `/active` |
| PUT suffix | empty string (PUT hits the prefix) |

`requestUrl(path, context)` constructs `new URL(\`${API_URL}${path}\`)`, always sets `projectId`, and sets `teamId` only when `context.teamId` is truthy.

## resolveContext

`cmdPull`, `cmdPlan`, and `cmdApply` pass the parsed `CliOptions` object. Only `project` and `team` are read.

<ParamField body="overrides.project" type="string">
From `--project`. First match for project ID.
</ParamField>

<ParamField body="overrides.team" type="string">
From `--team`. First match for team ID.
</ParamField>

<ParamField body="VERCEL_TOKEN" type="string" required>
Sole token source. There is no `--token` flag.
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Used when `--project` is absent.
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Used when `--team` is absent.
</ParamField>

<ParamField body=".vercel/project.json" type="object">
Optional `vercel link` file under `process.cwd()`. Read via `readJson`. A missing or unreadable file is ignored.
</ParamField>

### Resolution order

| Field | 1 | 2 | 3 | Required |
| --- | --- | --- | --- | --- |
| `token` | `VERCEL_TOKEN` | — | — | yes |
| `projectId` | `--project` | `VERCEL_PROJECT_ID` | `.vercel/project.json` `projectId` | yes |
| `teamId` | `--team` | `VERCEL_TEAM_ID` | `.vercel/project.json` `orgId` | no |

The linked-project shape is `{ projectId?: string; orgId?: string }`. Team comes from `orgId`, not a `teamId` key.

```ts
export interface ResolvedContext {
  token: string;
  projectId: string;
  teamId?: string;
}
```

<Warning>
A missing team ID is not an error. The request goes out without `teamId`. A missing token or project ID throws before any `fetch`.
</Warning>

## HTTP transport

`request(method, path, context, body?)` always uses global `fetch` (Node `>=18`). There is no timeout, retry, or pagination.

| Item | Behavior |
| --- | --- |
| Method | Caller-supplied (`GET` or `PUT`) |
| Auth | `Authorization: Bearer ${context.token}` |
| Content type | `Content-Type: application/json` on every request, including GET |
| Body | `JSON.stringify(body)` only when `body` is truthy; GET omits it |
| Response | `response.text()`, then `JSON.parse`; empty body → `null`; invalid JSON kept as raw text |
| Success | `response.ok` → parsed value as `T` |
| Failure | throw `Vercel API ${status} ${statusText}: ${body}` |
| Transport errors | `fetch` rejections propagate unwrapped (CLI prefixes `vwaffle: `) |

```ts
headers: {
  Authorization: `Bearer ${context.token}`,
  'Content-Type': 'application/json',
}
```

## Endpoints

:::endpoint GET /v1/security/firewall/config/active Fetch the project's active firewall configuration
Used by `getActiveConfig`. `request` is called as `request('GET', '/active', context)`.

**Query**

| Name | Required | Source |
| --- | --- | --- |
| `projectId` | yes | `context.projectId` |
| `teamId` | no | `context.teamId` when set |

**Response unwrap**

`getActiveConfig` accepts either a wrapped envelope or a bare config:

```ts
return ((live as ActiveConfigResponse)?.active ?? live) as FirewallConfig
```

| Shape | Result |
| --- | --- |
| `{ active: FirewallConfig, ... }` | Use `.active` |
| Bare `FirewallConfig` | Use the whole body |

`ActiveConfigResponse` allows extra keys. The client does not validate fields beyond this unwrap.
:::

:::endpoint PUT /v1/security/firewall/config Replace the project's firewall configuration
Used by `putConfig`. `request` is called as `request('PUT', '', context, config)`.

**Query**

Same `projectId` / optional `teamId` as GET.

**Body**

The interpolated `FirewallConfig` after `loadDesiredConfig`. Known keys in `src/types.ts`:

| Key | Type |
| --- | --- |
| `firewallEnabled` | `boolean` |
| `managedRules` | `Record<string, ManagedRule>` |
| `crs` | `Record<string, ManagedRule>` |
| `rules` | `CustomRule[]` |
| `ips` | `IpRule[]` |

The interface is open (`[key: string]: unknown`). The client does not transform or strip unknown keys. README treats this body as the exact JSON Vercel accepts.

**Return**

`{ version?: number; [key: string]: unknown } | null`. `cmdApply` prints the version when present:

```text
Applied firewall configuration (version 12).
```

If `version` is absent or the body is `null`, it prints `Applied firewall configuration.`
:::

<RequestExample>
```http
GET /v1/security/firewall/config/active?projectId=prj_xxx&teamId=team_xxx HTTP/1.1
Host: api.vercel.com
Authorization: Bearer $VERCEL_TOKEN
Content-Type: application/json
```
</RequestExample>

<RequestExample>
```http
PUT /v1/security/firewall/config?projectId=prj_xxx&teamId=team_xxx HTTP/1.1
Host: api.vercel.com
Authorization: Bearer $VERCEL_TOKEN
Content-Type: application/json

{ "firewallEnabled": true, "managedRules": { "owasp": { "active": false } }, "rules": [], "ips": [] }
```
</RequestExample>

## Which commands call the API

| Command | `resolveContext` | `GET /active` | `PUT` |
| --- | --- | --- | --- |
| `init` | no | no | no |
| `pull` | yes | yes | no |
| `plan` / `plan --check` | yes | yes | no |
| `apply --dry-run` | no | no | no |
| `apply --yes` and `diff` is `No drift detected.` | yes | yes | no |
| `apply --yes` with drift | yes | yes | yes |

`apply --dry-run` loads and redacts the desired file, then returns. It does not require `VERCEL_TOKEN` or a project ID.

`apply --yes` always GETs first, diffs live vs desired, and skips PUT when the diff is `No drift detected.`

<Info>
`plan` loads the desired file with `strict: false` (missing `${VAR}` rules are dropped). `apply --yes` uses `strict: true` and fails before `resolveContext` if any referenced variable is unset. Interpolation lives in `src/config.ts`, not the API client.
</Info>

## Command wiring

### pull

```text
resolveContext(options) → getActiveConfig(context) → JSON to stdout or --output
```

The live object is `JSON.stringify(live, null, '\t')`. Secrets are not redacted here because pull never interpolates.

### plan

```text
loadDesiredConfig(path, { strict: false }) → resolveContext → getActiveConfig → diff(live, config)
```

`--check` sets `process.exitCode = 1` when the printed diff is not `No drift detected.` It does not write.

### apply

```text
loadDesiredConfig(path, { strict: !dryRun })
  --dry-run → print redacted(config) and return
  --yes     → resolveContext → getActiveConfig → print redacted diff
              → return if no drift
              → putConfig(context, config)
```

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

## Errors

Thrown by `resolveContext` or `request`, then printed as `vwaffle: <message>` with `process.exitCode = 1`.

| Condition | Message |
| --- | --- |
| No `VERCEL_TOKEN` | `VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.` |
| No project ID from flags, env, or link file | `A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.` |
| HTTP not OK | `Vercel API ${status} ${statusText}: ${parsed-or-raw body}` |
| `fetch` network failure | original error message, no `Vercel API` prefix |
| Unreadable `.vercel/project.json` | swallowed; treated as no linked project |

Corrupt JSON in `.vercel/project.json` is also swallowed (the `readJson` / `JSON.parse` failure is caught). Resolution then depends entirely on flags and environment variables.

<Note>
`src/config.test.ts` covers interpolation, redaction, and diff. There is no dedicated test file for `src/api.ts`.
</Note>

## Related pages

<CardGroup>
  <Card title="Project and team context" href="/project-context">
    Flag, env, and `.vercel/project.json` precedence that `resolveContext` implements.
  </Card>
  <Card title="Desired vs live config" href="/desired-vs-live-config">
    How the local JSON relates to the object `getActiveConfig` returns and `putConfig` writes.
  </Card>
  <Card title="Authentication and context errors" href="/authentication-errors">
    Missing token, unresolved project, and failed Firewall API requests.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Desired-file path, PUT body shape, and `loadDesiredConfig` interpolation.
  </Card>
  <Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
    When GET runs, when PUT is skipped, and `--yes` / `--dry-run` gates.
  </Card>
</CardGroup>

---

## 18. Diff output

> How diff, diffLines, lcsMatrix, and withContext render live-versus-desired changes, including redacted secret values.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/18-diff-output.md
- Generated: 2026-08-13T01:29:00.525Z

### Source Files

- `src/diff.ts`
- `src/index.ts`
- `src/config.ts`
- `src/api.ts`

---
title: "Diff output"
description: "How diff, diffLines, lcsMatrix, and withContext render live-versus-desired changes, including redacted secret values."
---

`diff(live, desired)` in `src/diff.ts` pretty-prints both sides as tab-indented JSON, compares those strings, and either returns the exact constant `NO_DRIFT` (`No drift detected.`) or a custom unified-style listing of live-versus-desired lines. `cmdPlan` prints that string as-is. `cmdApply` (`apply --yes`) runs the same `diff`, then `redactText` before stdout, and only then `putConfig` when the result is not `NO_DRIFT`. `apply --dry-run` never calls `diff`; it prints `redacted(config, secrets)` of the interpolated desired payload only.

## Command surfaces

| Command | Compare live? | Printer | Side effect |
| --- | --- | --- | --- |
| `vwaffle plan` | Yes — `getActiveConfig` vs `loadDesiredConfig(..., { strict: false })` | `console.log(diff(live, config))` — **not redacted** | None. `--check` sets `process.exitCode = 1` when the result is not `NO_DRIFT` |
| `vwaffle apply --yes` | Yes — same pair, but `loadDesiredConfig(..., { strict: true })` | `console.log(redactText(diff(live, config), secrets))` | `putConfig` only when the result is not `NO_DRIFT` |
| `vwaffle apply --dry-run` | No | `console.log(redacted(config, secrets))` | No GET, no PUT |

```mermaid
flowchart TB
  subgraph cli ["src/index.ts"]
    plan["cmdPlan"]
    apply["cmdApply --yes"]
    dry["cmdApply --dry-run"]
  end
  subgraph cfg ["src/config.ts"]
    load["loadDesiredConfig / interpolate"]
    redactObj["redacted(value, secrets)"]
  end
  subgraph api ["src/api.ts"]
    get["getActiveConfig → .active ?? body"]
    put["putConfig PUT /v1/security/firewall/config"]
  end
  subgraph engine ["src/diff.ts"]
    stringify["JSON.stringify(..., null, '\\t')"]
    lcs["lcsMatrix"]
    lines["diffLines"]
    ctx["withContext(context = 3)"]
    nodrift["NO_DRIFT"]
  end
  plan --> load
  apply --> load
  dry --> load
  plan --> get
  apply --> get
  load --> stringify
  get --> stringify
  stringify -->|identical| nodrift
  stringify -->|differ| lcs --> lines --> ctx
  plan -->|raw string| nodrift
  plan -->|raw string| ctx
  apply --> redactText["redactText(diff, secrets)"]
  redactText --> nodrift
  redactText --> ctx
  apply -->|if not NO_DRIFT| put
  dry --> redactObj
```

<Warning>
`plan` interpolates `${VAR_NAME}` into `config` and prints `diff(live, config)` without `redactText`. Expanded secret values appear on `+` lines (and on `  ` / `-` lines when the live value matches). Only `apply --yes` redacts the diff, and only `apply --dry-run` redacts the full desired payload.
</Warning>

Desired input is the object returned by `loadDesiredConfig`, not the on-disk JSON: placeholders are expanded, and under `strict: false` (`plan`, `apply --dry-run`) rules that still contain unset `${VAR_NAME}` are dropped by `removeRulesWithMissingEnv` before comparison or print.

Live input is `getActiveConfig`: `GET /v1/security/firewall/config/active` unwrapped as `response.active ?? response`. Extra live-only properties (rule `id`s, API-only keys, different key order) participate in the string compare.

## Output format

Identical pretty-prints return only:

```text
No drift detected.
```

Otherwise `diff` joins these lines with `\n` (no trailing newline beyond the last line):

1. `--- live firewall configuration`
2. `+++ desired firewall configuration`
3. The `withContext(diffLines(oldLines, newLines))` body

There are no `@@` hunk headers, file paths, or git index lines.

```text
--- live firewall configuration
+++ desired firewall configuration
  {
    ...
- 	"firewallEnabled": false
+ 	"firewallEnabled": true
    ...
  }
```

| Prefix | Meaning |
| --- | --- |
| `  ` (two spaces) | Line present on both sides (LCS match) |
| `- ` | Line in live, not chosen as a match — removed relative to desired |
| `+ ` | Line in desired, not chosen as a match — added relative to live |
| `  ...` | One or more omitted unchanged lines (emitted once per skipped run) |

Headers have no prefix. Indent inside JSON lines is a literal tab (`\t`), which is why the unit test asserts `- \t"a": 1` and `+ \t"a": 2`.

<RequestExample>
```bash
vwaffle plan
vwaffle plan --check
vwaffle apply --yes
```
</RequestExample>

<ResponseExample>
```text
--- live firewall configuration
+++ desired firewall configuration
  {
- 	"a": 1
+ 	"a": 2
  }
```
</ResponseExample>

For `{ a: 1 }` vs `{ a: 2 }`, both braces are LCS matches; the property lines are a delete-then-add. `withContext` keeps every line because all sit inside the default 3-line window.

## Rendering pipeline

### JSON line encoding

<ParamField body="live" type="unknown" required>
Active firewall config from `getActiveConfig`, or any value passed to `diff`. `undefined`/`null` becomes `null` via `live ?? null`.
</ParamField>

<ParamField body="desired" type="unknown" required>
Interpolated desired config from `loadDesiredConfig`. `desired ?? null` before stringify.
</ParamField>

Both sides run `JSON.stringify(value ?? null, null, '\t').split('\n')`. Equality is `oldLines.join('\n') === newLines.join('\n')` — not a semantic object compare.

Consequences:

- Key insertion order is significant.
- Omitted optional fields (`undefined` properties dropped by `JSON.stringify`) differ from explicit `null`.
- Live-only keys such as `id` on `rules[]` / `ips[]` appear as `-` lines if the desired file does not contain the same keys in the same order.

### `lcsMatrix`

```ts
function lcsMatrix(a: string[], b: string[]): number[][]
```

Not exported. Builds an `(a.length + 1) × (b.length + 1)` matrix of longest common subsequence lengths, filled from the bottom-right:

- `a[i] === b[j]` → `matrix[i + 1][j + 1] + 1`
- else → `max(matrix[i + 1][j], matrix[i][j + 1])`

The walk in `diffLines` uses these lengths; it does not reconstruct an LCS string array.

### `diffLines`

```ts
function diffLines(a: string[], b: string[]): string[]
```

Not exported. `a` is live lines, `b` is desired lines. Walks `i`, `j` from `0`:

| Condition | Action |
| --- | --- |
| `a[i] === b[j]` | Push `  ${a[i]}`; increment both |
| `matrix[i + 1][j] >= matrix[i][j + 1]` | Push `- ${a[i]}`; increment `i` (prefer delete live when lengths tie) |
| else | Push `+ ${b[j]}`; increment `j` |
| leftover `a` | All `- ${a[i++]}` |
| leftover `b` | All `+ ${b[j++]}` |

Equal-length replacements therefore render as a `-` line followed by a `+` line, matching the `{ a: 1 }` / `{ a: 2 }` test.

### `withContext`

```ts
function withContext(lines: string[], context = 3): string[]
```

Not exported. `context` is hardcoded to `3`; the CLI does not expose it.

1. A line is a change when it does **not** start with two spaces (`- ` and `+ `).
2. Every change keeps itself plus `context` neighbors on each side.
3. Unkept runs collapse to a single `  ...`. Consecutive omitted regions do not emit multiple ellipses.

Unchanged JSON lines always start with `  ` because `diffLines` prefixes matches that way. `withContext` is not called on the no-drift path.

## Function reference

<ParamField body="diff" type="(live: unknown, desired: unknown) => string" required>
Only exported renderer. Returns `NO_DRIFT` or the two headers plus context-trimmed line ops, joined by `\n`.
</ParamField>

<ParamField body="NO_DRIFT" type="string" required>
Exact value `'No drift detected.'`. `cmdPlan --check` and `cmdApply` compare with `!==`, not a boolean flag.
</ParamField>

<ParamField body="redactText" type="(text: string, secrets: Map<string, string>) => string">
Private to `src/index.ts`. For each non-empty `secrets` value, `text.split(secret).join('[REDACTED]')`. Used only on the apply-path diff string.
</ParamField>

<ParamField body="redacted" type="(value: unknown, secrets: Map<string, string>) => string">
Exported from `src/config.ts`. `JSON.stringify(value, null, '\t')` then the same substring replace. Used only by `apply --dry-run`.
</ParamField>

`secrets` is filled by `interpolate`: each successful `${NAME}` replacement records `secrets.set(name, replacement)`. Missing variables stay as `${NAME}` and are not entered in the map.

## Secret redaction

| Path | What is printed | What is replaced |
| --- | --- | --- |
| `plan` | Raw `diff` string | Nothing |
| `apply --yes` | `redactText(diff, secrets)` | Every non-empty interpolated env **value** (map values), anywhere it appears in the already-rendered text |
| `apply --dry-run` | `redacted(config, secrets)` | Same replacement on the desired JSON only |

Replacement is literal substring replace, not JSON-aware:

- A secret that appears on both sides (live already equals the interpolated value) becomes `[REDACTED]` on `-`, `+`, and `  ` lines.
- Live-only secret strings that are not values in `secrets` stay visible on apply diffs.
- Empty-string env values are skipped (`if (secret)`).
- Short interpolated values that also occur in other JSON text are over-replaced.

`interpolate` only treats successful `${[A-Za-z_][A-Za-z0-9_]*}` expansions as secrets. Hard-coded literals in the desired file are never added to the map.

<Note>
Help text says interpolated values are “redacted in all output.” That is true for `apply --yes` (diff) and `apply --dry-run` (payload). It is not true for `plan`.
</Note>

## Equality, drift, and apply

`cmdPlan`:

```ts
const result = diff(live, config);
console.log(result);
if (options.check && result !== NO_DRIFT) process.exitCode = 1;
```

`cmdApply` after a successful load and GET:

```ts
const result = diff(live, config);
console.log(redactText(result, secrets));
if (result === NO_DRIFT) return;
const applied = await putConfig(context, config);
```

- `--check` uses the printed `diff` result, not a separate semantic compare. Any pretty-print mismatch fails CI.
- `apply --yes` still prints the (redacted) `NO_DRIFT` line when there is no drift, then returns without PUT.
- `apply` without `--yes` or `--dry-run` throws before `diff`: `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.`

## Constraints and failure modes

| Situation | Behavior |
| --- | --- |
| Identical tab-indented JSON | Single line `No drift detected.` |
| Semantic match, different key order or extra live keys | Drift listing; `--check` exits 1 |
| Unset `${VAR}` on `plan` | Warning to stderr; matching rules removed; remaining interpolated values still appear unredacted in the diff |
| Unset `${VAR}` on `apply --yes` | Throws `missing environment variables: …` — no diff, no PUT |
| Unset `${VAR}` on `apply --dry-run` | Same skip-rules warning as plan; prints redacted remaining payload |
| Large unchanged regions | Collapsed to `  ...` with 3 lines of context around each change |
| `init` / `pull` | Do not call `diff` |

Tests in `src/config.test.ts` cover `NO_DRIFT` for `{ a: 1 }` vs `{ a: 1 }`, `-`/`+` lines for `{ a: 1 }` vs `{ a: 2 }`, and `redacted` replacing a recorded secret with `[REDACTED]`. There is no unit test for `withContext` ellipsis or for `redactText` on a full plan-style listing.

## Related pages

<CardGroup>
  <Card title="Desired vs live config" href="/desired-vs-live-config">
    What is compared: local desired JSON versus the active config `getActiveConfig` unwraps.
  </Card>
  <Card title="Secret interpolation" href="/secret-interpolation">
    How `${VAR_NAME}` is expanded and recorded in the `secrets` map used by `redactText` / `redacted`.
  </Card>
  <Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
    When `plan`, `--check`, `--dry-run`, and `apply --yes` run, including the no-drift early return.
  </Card>
  <Card title="Preview and apply changes" href="/preview-and-apply-changes">
    Operator path: `plan`, then `apply --dry-run`, then `apply --yes`.
  </Card>
  <Card title="Detect drift in CI" href="/detect-drift-in-ci">
    `plan --check` treats any non-`NO_DRIFT` pretty-print as exit 1.
  </Card>
  <Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
    Unset variables on plan versus apply, and `--check` exit 1 on drift.
  </Card>
</CardGroup>

---

## 19. Authentication and context errors

> Missing VERCEL_TOKEN, unresolved project or team IDs, and failed Firewall API requests from resolveContext and request.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/19-authentication-and-context-errors.md
- Generated: 2026-08-13T01:29:46.737Z

### Source Files

- `src/api.ts`
- `src/index.ts`
- `src/types.ts`
- `README.md`

---
title: "Authentication and context errors"
description: "Missing VERCEL_TOKEN, unresolved project or team IDs, and failed Firewall API requests from resolveContext and request."
---

`resolveContext` and `request` in `src/api.ts` are the only auth and Firewall HTTP surfaces. `pull`, `plan`, and `apply` (without `--dry-run`) call `resolveContext` first. A missing `VERCEL_TOKEN` or project ID throws locally. A non-OK Vercel response throws after `fetch`. Every thrown error is printed as `vwaffle: <message>` and sets `process.exitCode` to `1`.

## Commands that hit auth

| Command | Calls `resolveContext` | Calls `getActiveConfig` | Calls `putConfig` |
| --- | --- | --- | --- |
| `vwaffle pull` | Yes | Yes (`GET /active`) | No |
| `vwaffle plan` | Yes | Yes | No |
| `vwaffle apply --yes` | Yes | Yes | Yes, unless the live/desired diff is `No drift.` |
| `vwaffle apply --dry-run` | No | No | No |
| `vwaffle init`, `help`, `--version` | No | No | No |

`apply` without `--yes` or `--dry-run` throws `apply requires --yes. Use --dry-run to inspect the payload without calling Vercel.` before any token or project check.

## How errors are printed

```ts
main().catch((error: unknown) => {
	console.error(`vwaffle: ${error instanceof Error ? error.message : String(error)}`);
	process.exitCode = 1;
});
```

There is no retry, no status-specific mapping, and no stack dump. `src/config.test.ts` does not cover `resolveContext` or `request`.

<ResponseExample>

```text
vwaffle: VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.
```

</ResponseExample>

## Resolution order

`resolveContext(overrides)` builds a `ResolvedContext` of `{ token, projectId, teamId? }`. First match wins. Empty string is falsy for token and project and is treated as unset.

| Field | Sources | Required |
| --- | --- | --- |
| `token` | `VERCEL_TOKEN` only | Yes |
| `projectId` | `--project`, then `VERCEL_PROJECT_ID`, then `.vercel/project.json` `projectId` | Yes |
| `teamId` | `--team`, then `VERCEL_TEAM_ID`, then `.vercel/project.json` `orgId` | No |

`--project` and `--team` are parsed in `src/index.ts` and passed through as `overrides.project` / `overrides.team`. There is no token flag.

`.vercel/project.json` is read from `process.cwd()` via `readJson`. Missing file, unreadable file, or invalid JSON is swallowed; a linked project is optional when flags or env vars supply the IDs.

```json
{
	"projectId": "prj_...",
	"orgId": "team_..."
}
```

`orgId` is the team source. A `teamId` key in that file is ignored.

```mermaid
flowchart TD
  subgraph resolveContext["resolveContext"]
    T["VERCEL_TOKEN"] -->|missing or empty| E1["throw token required"]
    T -->|present| L["read .vercel/project.json"]
    L --> P["--project / VERCEL_PROJECT_ID / projectId"]
    P -->|missing or empty| E2["throw project required"]
    P -->|present| TM["--team / VERCEL_TEAM_ID / orgId"]
    TM --> CTX["ResolvedContext"]
  end
  subgraph requestFn["request"]
    CTX --> URL["GET /active or PUT ''"]
    URL -->|response.ok| OK["return parsed body"]
    URL -->|not ok| E3["throw Vercel API status + body"]
  end
```

## Missing `VERCEL_TOKEN`

Thrown when `process.env.VERCEL_TOKEN` is missing or empty. Checked before `.vercel/project.json` is read.

<Warning>
`VERCEL_TOKEN is required. Create a Vercel API token (https://vercel.com/account/tokens) and export it before running this command.`
</Warning>

<ParamField body="VERCEL_TOKEN" type="string" required>
Bearer token sent as `Authorization: Bearer ${context.token}`. Not read from `.vercel/project.json` or a CLI flag.
</ParamField>

<Steps>
<Step title="Create and export a token">

Create a token at `https://vercel.com/account/tokens` and export it in the same shell (or CI job) that runs `vwaffle`.

```sh
export VERCEL_TOKEN=...
```

</Step>
<Step title="Re-run a command that calls the API">

```sh
vwaffle pull
# or
vwaffle plan
```

</Step>
</Steps>

## Unresolved project ID

Thrown after the token check when `--project`, `VERCEL_PROJECT_ID`, and `linked.projectId` are all missing or empty.

<Warning>
`A project is required. Pass --project, set VERCEL_PROJECT_ID, or run \`vercel link\` so .vercel/project.json exists.`
</Warning>

<ParamField body="--project" type="string">
CLI override. Highest priority. `vwaffle --project ID` requires a value or throws `--project requires a value`.
</ParamField>

<ParamField body="VERCEL_PROJECT_ID" type="string">
Used when `--project` is omitted.
</ParamField>

<Tabs>
<Tab title="Flag">

```sh
vwaffle pull --project prj_...
```

</Tab>
<Tab title="Environment">

```sh
export VERCEL_PROJECT_ID=prj_...
vwaffle pull
```

</Tab>
<Tab title="vercel link">

```sh
vercel link
# writes .vercel/project.json with projectId (and orgId)
vwaffle pull
```

</Tab>
</Tabs>

<Note>
A present `.vercel/project.json` that lacks `projectId` does not satisfy the check. `vercel link` must be run from the same working directory `vwaffle` uses.
</Note>

## Team ID is optional in `resolveContext`

`teamId` is never required by `resolveContext`. Missing team does not throw. `requestUrl` adds `teamId` only when the resolved value is truthy.

An empty `--team` or `VERCEL_TEAM_ID=""` is stored on `ResolvedContext` but omitted from the query string (`if (context.teamId)`).

<Tip>
A team-scoped token or team project often still needs `teamId` on the request. That failure arrives later as a `Vercel API <status> ...` error, not as a local “team required” message.
</Tip>

<ParamField body="--team" type="string">
CLI override. Highest priority for team scope.
</ParamField>

<ParamField body="VERCEL_TEAM_ID" type="string">
Used when `--team` is omitted.
</ParamField>

```sh
vwaffle pull --project prj_... --team team_...
```

## Failed Firewall API requests

After context resolves, `getActiveConfig` and `putConfig` call `request`. Base URL:

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

`VERCEL_API_URL` is honored in `src/api.ts` and is not listed in `vwaffle help`.

:::endpoint GET /v1/security/firewall/config/active
Fetch the live firewall config (`getActiveConfig`).

Query: `projectId` (always), `teamId` (when truthy).

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

:::endpoint PUT /v1/security/firewall/config
Write the desired config (`putConfig`). Same query params and headers. Body is the interpolated `FirewallConfig` JSON.
:::

On every response, `request` reads `response.text()`, parses JSON when possible, and throws when `!response.ok`:

```text
Vercel API ${status} ${statusText}: ${body}
```

| Body shape | Value interpolated into the message |
| --- | --- |
| Empty | `null` (`JSON.stringify(null)`) |
| Valid JSON object/array | `JSON.stringify(data)` |
| Non-JSON text | Raw response text |

The CLI does not branch on 401, 403, 404, or 429. Treat the status and body from Vercel as the diagnostic.

<AccordionGroup>
<Accordion title="401 / 403 after context resolved">
Token is present but rejected, or the token lacks Firewall / project / team scope. Confirm `VERCEL_TOKEN` is the token you intended, then pass `--team` / `VERCEL_TEAM_ID` / `orgId` if the project is on a team.
</Accordion>
<Accordion title="404 or project-not-found body">
`projectId` resolved to a value Vercel does not accept for this token. Check `--project` / `VERCEL_PROJECT_ID` / `.vercel/project.json` `projectId` against the Vercel dashboard. A personal-account project ID with a team token (or the reverse) typically fails here, not in `resolveContext`.
</Accordion>
<Accordion title="Non-OK on apply PUT only">
`apply --yes` always `GET`s `/active` first. A GET that succeeds and a PUT that fails means context is valid and the payload or API rejected the write. Inspect `apply --dry-run` for the redacted body; that path never calls `request`.
</Accordion>
<Accordion title="Network or parse failures">
`fetch` rejections (DNS, TLS, offline) surface as the raw error message under the same `vwaffle:` prefix. Invalid JSON on an OK response is returned as-is by `request` and is not remapped.
</Accordion>
</AccordionGroup>

## Failure order on `apply --yes`

Local checks run before HTTP.

| Order | Check | Typical message |
| --- | --- | --- |
| 1 | `--yes` or `--dry-run` | `apply requires --yes. ...` |
| 2 | `loadDesiredConfig({ strict: true })` | `missing environment variables: ...` |
| 3 | `resolveContext` token | `VERCEL_TOKEN is required. ...` |
| 4 | `resolveContext` project | `A project is required. ...` |
| 5 | `GET /active` | `Vercel API <status> <statusText>: ...` |
| 6 | `PUT` (skipped when diff is `No drift.`) | `Vercel API <status> <statusText>: ...` |

`plan` loads the desired file with `strict: false` (warnings only), then follows steps 3–5. Unset `${VAR_NAME}` interpolation is a separate failure path.

## Verify a working context

<Steps>
<Step title="Export token and IDs in the same process">

```sh
export VERCEL_TOKEN=...
export VERCEL_PROJECT_ID=prj_...
export VERCEL_TEAM_ID=team_...   # if the project is on a team
```

</Step>
<Step title="Confirm pull can GET /active">

```sh
vwaffle pull
```

Success prints the live JSON (or writes `--output`). Failure is either a local `resolveContext` string or `Vercel API ...`.

</Step>
<Step title="Confirm plan can reuse the same context">

```sh
vwaffle plan
```

A printed diff or `No drift.` means auth and project resolution succeeded.

</Step>
</Steps>

<Check>
CI jobs must inject `VERCEL_TOKEN` and usually `VERCEL_PROJECT_ID` / `VERCEL_TEAM_ID`. `.vercel/project.json` is typically not present in a clean checkout unless `vercel link` ran in that workspace.
</Check>

## Related pages

<CardGroup>
<Card title="Project and team context" href="/project-context">
How `resolveContext` selects token, project, and team from flags, env, and `.vercel/project.json`.
</Card>
<Card title="Firewall API client" href="/firewall-api-client">
`getActiveConfig`, `putConfig`, `request`, and `requestUrl`.
</Card>
<Card title="Environment variables" href="/environment-variables">
`VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, and interpolated `${VAR_NAME}` values.
</Card>
<Card title="Interpolation and check failures" href="/interpolation-and-check-failures">
Unset `${VAR_NAME}` on plan versus apply, and `plan --check` exit 1.
</Card>
<Card title="Apply from CI" href="/apply-from-ci">
Token, project, and team inputs for `apply --yes` on merge.
</Card>
<Card title="CLI reference" href="/cli-reference">
`--project`, `--team`, and the commands that call the API.
</Card>
</CardGroup>

---

## 20. Interpolation and check failures

> Unset ${VAR_NAME} behavior on plan versus apply, rules skipped by removeRulesWithMissingEnv, and plan --check exit 1 on drift.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/20-interpolation-and-check-failures.md
- Generated: 2026-08-13T01:30:00.405Z

### Source Files

- `src/config.ts`
- `src/config.test.ts`
- `src/index.ts`
- `src/diff.ts`

---
title: "Interpolation and check failures"
description: "Unset ${VAR_NAME} behavior on plan versus apply, rules skipped by removeRulesWithMissingEnv, and plan --check exit 1 on drift."
---

`loadDesiredConfig` in `src/config.ts` is the gate for every `plan` and `apply` run. It interpolates `${VAR_NAME}` placeholders from `process.env`, then either throws, or warns and drops matching custom rules, before `cmdPlan` / `cmdApply` compare or PUT. `plan --check` is a separate failure: after the (possibly reduced) desired config is diffed against live, `process.exitCode` is set to `1` when the result is not `No drift detected.`

## Command outcomes

| Command | `loadDesiredConfig` `strict` | Unset `${VAR_NAME}` | Drift vs live |
| --- | --- | --- | --- |
| `vwaffle plan` | `false` | stderr warning; drop matching `rules` | print diff; exit `0` |
| `vwaffle plan --check` | `false` | same as `plan` | print diff; `process.exitCode = 1` when not `NO_DRIFT` |
| `vwaffle apply --dry-run` | `false` | same skip path as `plan` | print redacted payload; no API call |
| `vwaffle apply --yes` | `true` | throw; no PUT | never reached if any placeholder is unset |

`--check` is parsed globally but only read in `cmdPlan`. Passing it to `apply` does not change apply behavior.

```mermaid
flowchart TD
  subgraph load ["loadDesiredConfig"]
    I["interpolate string values"] --> M{"missing.size > 0?"}
    M -->|no| OK["use interpolated config"]
    M -->|"yes and strict"| ERR["throw missing environment variables"]
    M -->|"yes and not strict"| WARN["warn then removeRulesWithMissingEnv"]
  end
  subgraph callers ["CLI callers"]
    P["cmdPlan: strict false"] --> load
    D["cmdApply --dry-run: strict false"] --> load
    A["cmdApply --yes: strict true"] --> load
  end
  subgraph check ["cmdPlan after diff"]
    R{"options.check and result !== NO_DRIFT?"}
    R -->|yes| E1["process.exitCode = 1"]
    R -->|no| E0["leave exit code unchanged"]
  end
  P --> check
```

## Unset `${VAR_NAME}`

`interpolate` walks the parsed JSON (strings, arrays, objects). Object keys, numbers, booleans, and `null` are left unchanged.

<ParamField body="placeholder" type="string">
  Only `${NAME}` matches `/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g`. Hyphens, dots, and names that start with a digit are not expanded and are not recorded as missing.
</ParamField>

<ParamField body="env lookup" type="process.env">
  A name is missing only when `process.env[name] === undefined`. An empty string is a set value: it replaces the placeholder and is stored in `secrets`.
</ParamField>

<ParamField body="missing entry" type="string">
  Format is `NAME ($.json.path)` — for example `NOPE ($.a[0])` or `GONE ($.rules[1])`. The same name at two paths produces two set entries.
</ParamField>

Unset placeholders stay in the value as the original `${NAME}` text. Set placeholders are replaced and recorded in the `secrets` map used later by `redacted` / `redactText`.

### Strict apply

`cmdApply` calls `loadDesiredConfig(..., { strict: !options.dryRun })`. A real apply (`--yes`, not `--dry-run`) throws on the first missing set, before `resolveContext` or `putConfig`.

<ResponseExample>

```text
vwaffle: missing environment variables: INTERNAL_TOKEN ($.rules[1].conditionGroup[0].conditions[0].value). Set them before applying the firewall configuration.
```

</ResponseExample>

`main` prefixes every thrown error with `vwaffle: ` and sets `process.exitCode = 1`. The throw fires for a missing name anywhere in the file — `rules`, `ips`, `managedRules`, or any other string field.

### Non-strict plan and dry-run

`cmdPlan` always uses `strict: false`. `apply --dry-run` does too. Missing names do not abort the command:

<ResponseExample>

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

</ResponseExample>

The warning is written with `console.warn` (stderr). The command then continues with the config returned by `removeRulesWithMissingEnv`.

## Rules skipped by `removeRulesWithMissingEnv`

Non-strict loads call `removeRulesWithMissingEnv(config, missing)` only when `missing.size > 0`.

```
FirewallConfig after interpolate
  firewallEnabled     kept as interpolated
  managedRules        kept; leftover ${NAME} stays in strings
  ips[]               kept; leftover ${NAME} stays in strings
  rules[]             drop a rule if JSON.stringify(rule) contains ${UNSET_NAME}
  other keys          kept
```

Behavior:

- Variable names are recovered as `entry.split(' ')[0]`, so `GONE ($.rules[1])` becomes `GONE`.
- A custom rule is dropped when its serialized JSON still contains `` `${GONE}` `` (the unreplaced placeholder).
- Sibling rules that do not mention any missing name stay.
- If every matching rule is removed, `rules` becomes `[]` (an empty array, not omitted).
- If `rules` is missing or not an array, the function returns the config unchanged.
- `ips`, `managedRules`, `crs`, and top-level strings are never removed. Leftover placeholders in those fields remain in the object that `plan` diffs and that `apply --dry-run` prints.

<Warning>
`plan --check` diffs the **post-skip** desired config. A CI job that does not export the same `${VAR_NAME}` values used in `rules` drops those rules, then reports drift against live even when the committed file is correct.
</Warning>

Apply still refuses that file: `--yes` is strict and fails on any missing name, including placeholders that live only on `ips` or `managedRules`.

## `plan --check` exit 1 on drift

After interpolation (and any rule skip), `cmdPlan` fetches live via `getActiveConfig` and calls `diff(live, config)`.

| Diff result | Printed | `--check` |
| --- | --- | --- |
| identical JSON (`JSON.stringify` with tab indent) | `No drift detected.` (`NO_DRIFT`) | exit `0` |
| any line difference | unified live-vs-desired text from `src/diff.ts` | `process.exitCode = 1` |

`cmdPlan` does not call `process.exit(1)`. It assigns `process.exitCode` and returns; Node then exits with that code. Errors thrown earlier (bad JSON, missing file, missing `VERCEL_TOKEN`, unresolved project, Firewall API failure) also set `process.exitCode = 1` through `main().catch`.

<RequestExample>

```bash
npx vwaffle plan --check
npx vwaffle plan --check --config firewall.config.json
```

</RequestExample>

A matching file prints:

<ResponseExample>

```text
No drift detected.
```

</ResponseExample>

A mismatch prints a header plus context-limited `+` / `-` lines:

<ResponseExample>

```text
--- live firewall configuration
+++ desired firewall configuration
  {
- 	"firewallEnabled": false,
+ 	"firewallEnabled": true,
```

</ResponseExample>

`plan` prints the raw `diff(...)` string. It does not run `redacted`. Interpolated secret values that remain after a non-strict load can appear on the desired side of the plan output.

## Failure catalog

| Signal | Typical cause | Exit |
| --- | --- | --- |
| `vwaffle: warning: skipping rules that reference unset variables: NAME ($.path)` | `plan` or `apply --dry-run`; `process.env.NAME` is `undefined` | `0` unless `--check` then sees drift |
| `vwaffle: missing environment variables: NAME ($.path). Set them before applying...` | `apply --yes` with any unset placeholder | `1` |
| `No drift detected.` then exit `1` | does not happen; `NO_DRIFT` leaves the check branch off | `0` |
| live-vs-desired diff then exit `1` | `plan --check` and serialized live ≠ desired (including after skipped rules) | `1` |
| `vwaffle: apply requires --yes. Use --dry-run...` | `apply` with neither `--yes` nor `--dry-run` | `1` |
| `SyntaxError` / unexpected token via `vwaffle: ...` | `firewall.config.json` is not valid JSON (`readJson`) | `1` |
| `ENOENT: no such file or directory, open '...'` | `--config` path missing | `1` |

Authentication and API errors (`VERCEL_TOKEN is required`, unresolved project, `Vercel API <status> ...`) also exit `1` but are not interpolation failures.

## Recover unset interpolation

<Steps>
<Step title="Read the missing name and JSON path">
The warning or throw lists `NAME ($.path)`. Confirm the placeholder is `${NAME}` with a legal identifier, not `${name-with-dashes}`.
</Step>
<Step title="Export a defined value">
`export NAME=...` in the same process as the CLI. An unset variable is `undefined`; `NAME=` (empty) counts as set and will be interpolated.
</Step>
<Step title="Re-run the same command">
`vwaffle plan` should no longer warn. `vwaffle apply --yes` should get past `loadDesiredConfig` and print the live-vs-desired diff (then PUT if the configs differ).
</Step>
<Step title="For CI check jobs, export every rule secret">
`plan --check` must see the same environment as a successful apply. Otherwise skipped `rules` shrink desired and the job fails on drift.
</Step>
</Steps>

<Tip>
Use `vwaffle apply --dry-run` to print the post-interpolation, post-skip payload without calling Vercel. That payload is what a non-strict load would have planned.
</Tip>

## Related pages

<CardGroup>
<Card title="Secret interpolation" href="/secret-interpolation">
How `${VAR_NAME}` is expanded and how set values are redacted as `[REDACTED]`.
</Card>
<Card title="Plan and apply lifecycle" href="/plan-apply-lifecycle">
`plan` versus `apply`, `--dry-run`, and the `--yes` confirmation required to PUT.
</Card>
<Card title="Detect drift in CI" href="/detect-drift-in-ci">
Run `plan --check` so a dashboard edit that diverges from the versioned file fails the build.
</Card>
<Card title="Environment variables" href="/environment-variables">
`VERCEL_TOKEN`, project/team IDs, and arbitrary interpolation values.
</Card>
<Card title="Authentication and context errors" href="/authentication-errors">
Missing token, unresolved project or team, and failed Firewall API requests.
</Card>
<Card title="Diff output" href="/diff-output">
How `diff` / `NO_DRIFT` render live-versus-desired changes.
</Card>
</CardGroup>

---

## 21. Build and test

> Bun scripts for typecheck, bun test, bun build of src/index.ts to dist, and the local bun run src/index.ts dev path.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/21-build-and-test.md
- Generated: 2026-08-13T01:28:38.316Z

### Source Files

- `package.json`
- `src/config.test.ts`
- `src/index.ts`
- `src/config.ts`

---
title: "Build and test"
description: "Bun scripts for typecheck, bun test, bun build of src/index.ts to dist, and the local bun run src/index.ts dev path."
---

`package.json` defines five scripts for this package: `typecheck` runs `tsc --noEmit`, `test` runs `bun test`, `build` runs `bun build src/index.ts --outdir dist --target node`, `dev` runs `bun run src/index.ts`, and `prepublishOnly` chains typecheck, test, and build. The published `vwaffle` bin is `./dist/index.js`. The package is ESM (`"type": "module"`), requires Node `>=18`, and lists `typescript` `^5.6.0` and `@types/bun` `^1.2.0` as the only `devDependencies`.

## Scripts

| Script | Command | Role |
| --- | --- | --- |
| `typecheck` | `tsc --noEmit` | Type-check without emitting files |
| `test` | `bun test` | Run `bun:test` suites |
| `build` | `bun build src/index.ts --outdir dist --target node` | Bundle the CLI entry to `dist/` for Node |
| `dev` | `bun run src/index.ts` | Execute `src/index.ts` in place |
| `prepublishOnly` | `bun run typecheck && bun run test && bun run build` | Gate publish on all three succeeding |

<ParamField body="typecheck" type="npm script">
`tsc --noEmit`. Uses the `typescript` `^5.6.0` devDependency. No emit path and no `tsconfig` contents are defined in this package surface.
</ParamField>

<ParamField body="test" type="npm script">
`bun test`. The checked-in suite is `src/config.test.ts`, which imports `describe`, `expect`, and `test` from `bun:test`.
</ParamField>

<ParamField body="build" type="npm script">
`bun build src/index.ts --outdir dist --target node`. Entry is `src/index.ts`. Output directory is `dist`. Target runtime is Node, matching `engines.node` `>=18` and the `#!/usr/bin/env node` shebang on the entry.
</ParamField>

<ParamField body="dev" type="npm script">
`bun run src/index.ts`. Same TypeScript entry as the build, without writing `dist/`.
</ParamField>

<ParamField body="prepublishOnly" type="npm script">
`bun run typecheck && bun run test && bun run build`. Stops on the first failing command.
</ParamField>

```bash
bun run typecheck
bun run test
bun run build
bun run dev
```

`bun test` is the script body for `test`, so it is equivalent to `bun run test`.

## Maintainer layout

```text
src/index.ts         CLI entry (shebang #!/usr/bin/env node)
src/config.ts        interpolate, loadDesiredConfig, redacted, …
src/config.test.ts   bun:test suites for config + diff
dist/index.js        bun build output; package bin "vwaffle"
```

`src/index.ts` imports `./api.ts`, `./config.ts`, `./diff.ts`, and `./types.ts`. `src/config.test.ts` imports `interpolate`, `redacted`, and `removeRulesWithMissingEnv` from `./config.ts`, plus `diff` and `NO_DRIFT` from `./diff.ts`. `package.json` `files` publishes `dist`, `README.md`, and `LICENSE` — not `src/`.

## Typecheck

`bun run typecheck` invokes `tsc --noEmit`. There is no emit directory and no compiler-option file in this surface. A clean run is a zero-exit typecheck of the TypeScript sources the compiler is configured to see.

## Test

`bun run test` (or `bun test`) loads `bun:test`. The suite in `src/config.test.ts` covers interpolation, rule dropping, redaction, and drift text.

### interpolate

Replaces `${VAR}` from a supplied env map and records secrets.

- Input object: `{ rules: [{ name: 'x', value: 'token-${API_KEY}' }] }`
- Env: `{ API_KEY: 's3cret' }`
- Expected object: `{ rules: [{ name: 'x', value: 'token-s3cret' }] }`
- `missing.size` is `0`
- `secrets.get('API_KEY')` is `'s3cret'`

Unset placeholders stay in the value and are recorded with a JSON path:

- Input: `{ a: ['${NOPE}'] }` with empty env
- Result still contains `'${NOPE}'`
- `missing` is `['NOPE ($.a[0])']`

### removeRulesWithMissingEnv

Drops only rules whose serialized text still contains a `${NAME}` for a missing variable. A config with `keep` (no placeholder) and `drop` (`value: '${GONE}'`) plus missing set `['GONE ($.rules[1])']` yields `result.rules` names `['keep']`.

### redacted

Serializes the value and replaces recorded secret strings with `[REDACTED]`. For `{ value: 'token-s3cret' }` and secrets `[['API_KEY', 's3cret']]`, the output does not contain `s3cret` and does contain `[REDACTED]`.

### diff

`diff({ a: 1 }, { a: 1 })` equals `NO_DRIFT`. Differing objects produce line-oriented text that contains `- \t"a": 1` and `+ \t"a": 2`.

```ts title="src/config.test.ts"
import { describe, expect, test } from 'bun:test';
import { interpolate, redacted, removeRulesWithMissingEnv } from './config.ts';
import { diff, NO_DRIFT } from './diff.ts';
```

## Build

`bun run build` is:

```bash
bun build src/index.ts --outdir dist --target node
```

| Flag / input | Value |
| --- | --- |
| Entry | `src/index.ts` |
| `--outdir` | `dist` |
| `--target` | `node` |
| Package bin | `vwaffle` → `./dist/index.js` |

The entry starts with `#!/usr/bin/env node` and sets `VERSION` to `'0.1.0'` (same as `package.json` `version`). After a successful build, the installable CLI is the file at `dist/index.js`, not the TypeScript sources.

<Note>
`package.json` lists no production `dependencies`. The bundle input is the `src/index.ts` graph (`./api.ts`, `./config.ts`, `./diff.ts`, `./types.ts` plus Node built-ins such as `node:fs/promises` and `node:path`).
</Note>

## Local `src/index.ts` path

`bun run dev` is `bun run src/index.ts`. That runs `main()` in the TypeScript entry without requiring `dist/`.

`main()` parses `process.argv.slice(2)` and dispatches `help`, `version`, `init`, `pull`, `plan`, and `apply`. With no command, `parseArgs` defaults `command` to `'help'`. Unknown commands throw `unknown command ${options.command}. Run \`vwaffle help\`.` Failures print `vwaffle: ${message}` and set `process.exitCode = 1`.

<Steps>
<Step title="Run the TypeScript entry">
```bash
bun run src/index.ts
bun run dev
```
Both execute `src/index.ts`. Pass CLI arguments after the file path when invoking the file directly:

```bash
bun run src/index.ts help
bun run src/index.ts --version
```
</Step>
<Step title="Typecheck">
```bash
bun run typecheck
```
Expect `tsc --noEmit` to exit 0.
</Step>
<Step title="Run tests">
```bash
bun test
```
Expect the `interpolate`, `removeRulesWithMissingEnv`, `redacted`, and `diff` describes in `src/config.test.ts` to pass.
</Step>
<Step title="Produce the Node binary">
```bash
bun run build
```
Expect `dist/` to contain the `--target node` bundle. The published name for that file is `./dist/index.js` (`bin.vwaffle`).
</Step>
</Steps>

<Warning>
`dev` is not a substitute for `build`. npm/npx consumers resolve `vwaffle` to `./dist/index.js`. `prepublishOnly` rebuilds that file only after typecheck and tests succeed.
</Warning>

## Publish gate

`prepublishOnly` runs, in order:

1. `bun run typecheck` → `tsc --noEmit`
2. `bun run test` → `bun test`
3. `bun run build` → `bun build src/index.ts --outdir dist --target node`

A failure in any step aborts the chain (`&&`). The published tarball includes `dist`, `README.md`, and `LICENSE` only.

## Constraints

| Constraint | Value |
| --- | --- |
| Package name / version | `vwaffle` `0.1.0` |
| Module type | `module` |
| Engine | `node` `>=18` |
| Type checker | `typescript` `^5.6.0` via `tsc --noEmit` |
| Test runner | `bun test` / `bun:test` |
| Bun types | `@types/bun` `^1.2.0` |
| Build target | `node` |
| Published bin | `./dist/index.js` |

## Related pages

<CardGroup>
<Card title="Contributing" href="/contributing">
`prepublishOnly` gates, Node engine, Apache-2.0 metadata, and the files published as the vwaffle CLI.
</Card>
<Card title="Installation" href="/installation">
Node `>=18`, npm / npx / bunx invocation, and the published `dist/index.js` binary.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands and flags the local `bun run src/index.ts` path dispatches.
</Card>
</CardGroup>

---

## 22. Contributing

> prepublishOnly gates, Node engine, Apache-2.0 package metadata, and the files published as the vwaffle CLI.

- Page Markdown: https://grok-wiki.com/public/docs/jaredpalmer-vwaffle-7983cb893581/pages/22-contributing.md
- Generated: 2026-08-13T01:28:50.939Z

### Source Files

- `package.json`
- `src/index.ts`
- `src/config.test.ts`
- `README.md`

---
title: "Contributing"
description: "prepublishOnly gates, Node engine, Apache-2.0 package metadata, and the files published as the vwaffle CLI."
---

`package.json` is the publish contract for **vwaffle**. `prepublishOnly` runs `typecheck`, then `test`, then `build` before a publish; `engines.node` is `>=18`; `license` is `Apache-2.0`; and `files` ships `dist`, `README.md`, and `LICENSE`. The installed CLI is `bin.vwaffle` → `./dist/index.js`, produced from `src/index.ts` by `bun build src/index.ts --outdir dist --target node`.

## Package metadata

| Field | Value |
| --- | --- |
| `name` | `vwaffle` |
| `version` | `0.1.0` |
| `description` | Config-as-code for Vercel WAF, Security & Bot Protection. Pull, diff, and apply firewall rules from a versioned JSON file. |
| `license` | `Apache-2.0` |
| `author` | Jared Palmer |
| `type` | `module` |
| `repository` | `git+https://github.com/jaredpalmer/vwaffle.git` |
| `bin.vwaffle` | `./dist/index.js` |
| `engines.node` | `>=18` |

`vwaffle version`, `-v`, and `--version` print `VERSION` imported from `package.json`. Changing `version` is what the CLI reports.

Keywords on the package are `vercel`, `firewall`, `waf`, `security`, `bot-protection`, `config-as-code`, `infrastructure-as-code`, and `cli`.

There are no production `dependencies`. Workspace tools are `devDependencies` only: `@types/bun` `^1.2.0` and `typescript` `^5.6.0`.

## Node engine

<ParamField body="engines.node" type="string" required>
`>=18`. Applies to the published Node-targeted binary. Local scripts (`dev`, `test`, `typecheck`, `build`, `prepublishOnly`) are invoked with Bun.
</ParamField>

<Info>
Install and one-shot invocation for consumers is `npm install -g vwaffle`, `npx vwaffle help`, or `bunx vwaffle help`. The published entry is still a Node binary (`#!/usr/bin/env node` on `src/index.ts`, `--target node` on `build`).
</Info>

## Published files

`files` is the npm allowlist for the published tarball:

<ParamField body="files" type="string[]" required>
`dist`, `README.md`, `LICENSE`
</ParamField>

<ParamField body="bin.vwaffle" type="string" required>
`./dist/index.js` — the `vwaffle` executable after `npm install -g vwaffle`.
</ParamField>

:::files
published package (files)
├── dist/index.js    bun build of src/index.ts --target node
├── README.md
└── LICENSE

workspace sources used by the gates
├── package.json     metadata, engines, scripts, bin
├── src/index.ts     CLI entry (#!/usr/bin/env node)
└── src/config.test.ts
:::

`build` writes the Node bundle to `dist/` from `src/index.ts`. `README.md` and `LICENSE` ship next to that bundle. Source, tests, and `devDependencies` are not listed in `files`.

## prepublishOnly gates

```json
"prepublishOnly": "bun run typecheck && bun run test && bun run build"
```

npm runs this lifecycle script before publish. The `&&` chain is fail-fast: a typecheck or test failure skips `build` and stops the publish.

| Order | Script | Command | Pass signal |
| --- | --- | --- | --- |
| 1 | `typecheck` | `tsc --noEmit` | `tsc` exits 0 |
| 2 | `test` | `bun test` | Bun test runner exits 0 |
| 3 | `build` | `bun build src/index.ts --outdir dist --target node` | `dist/` bundle written |

`package.json` does not define lint, format, or a `publish` script. The only scripted release gates are the three commands above.

## Local development

The README development path uses Bun:

<Steps>
<Step title="Install workspace dependencies">
```sh
bun install
```
</Step>
<Step title="Run the CLI from source">
```sh
bun run dev help
```

`dev` is `bun run src/index.ts`. Pass any CLI command after `dev` (`help`, `init`, `pull`, `plan`, `apply`, `version`).
</Step>
<Step title="Run the same gates as prepublishOnly">
```sh
bun run typecheck
bun test
bun run build
```

Or run the chained lifecycle script:

```sh
bun run prepublishOnly
```
</Step>
</Steps>

<Check>
`bun test` exits 0, `tsc --noEmit` exits 0, and `dist/index.js` exists after `bun run build`. `bun run dev version` prints the `package.json` `version` (`0.1.0` in this tree).
</Check>

## Tests the publish gate runs

`test` is `bun test`. `src/config.test.ts` uses `bun:test` and covers:

| Suite | Assertion |
| --- | --- |
| `interpolate` | Replaces `${VAR}` from the env, records secrets, leaves unset placeholders, and records missing vars with a JSON path such as `NOPE ($.a[0])` |
| `removeRulesWithMissingEnv` | Drops only rules that reference unset variables |
| `redacted` | Serialized output replaces secret values with `[REDACTED]` |
| `diff` | Identical configs return `NO_DRIFT`; differing configs include `-` / `+` lines |

A failing case in this file fails `bun test` and therefore `prepublishOnly`.

## License

<ParamField body="license" type="string" required>
`Apache-2.0`, also stated in the README. The `LICENSE` file is included in `files`.
</ParamField>

## Next

<CardGroup>
<Card title="Build and test" href="/build-and-test">
Bun scripts for typecheck, bun test, bun build of src/index.ts to dist, and bun run src/index.ts.
</Card>
<Card title="Installation" href="/installation">
Node >=18, global npm install, npx/bunx, and the published dist/index.js binary.
</Card>
<Card title="CLI reference" href="/cli-reference">
Commands and flags the published vwaffle binary accepts.
</Card>
<Card title="Overview" href="/overview">
What vwaffle exposes and the shortest pull / plan / apply path.
</Card>
</CardGroup>

---
