# Firewall API client

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

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