# Secret interpolation

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

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

## Source Files

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