> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alfiz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Static Verification: Catch Ungated Actions with alfiz-verify

> Run alfiz-verify in CI to catch ungated server actions, misused canAny() calls, and catalog violations before they reach production.

`alfiz-verify` is a build-time linter that reads your source files and catalog to enforce the four-point wiring convention. It checks that every exported server action is gated, that `canAny` never appears in server-side enforcement points, that every key in the catalog is referenced by at least one gate, and that the catalog itself is internally consistent. The checks require no TypeScript language-server. They run on parsed syntax, which means they are fast enough for CI and cheap enough to run on every commit.

The verifier exists because the convention document is only trustworthy if tooling backs it up. Coding agents wire authorization steps in the order they encounter them, so they will skip step three of four unless a failing build stops them. `alfiz-verify` is that stop.

***

## Installation

```bash theme={null}
npm install --save-dev @alfiz/verify
```

Add the `alfiz-verify` CLI to your `package.json` scripts:

```json theme={null}
{
  "scripts": {
    "verify:permissions": "alfiz-verify"
  }
}
```

***

## Emit the catalog JSON

`alfiz-verify` reads your catalog from a JSON file produced by `catalog.toDocument()`. Generate it with:

```bash theme={null}
node --experimental-strip-types -e \
  'import("./src/alfiz.ts").then(m => console.log(JSON.stringify(m.catalog.toDocument())))' \
  > alfiz-catalog.json
```

<Note>
  `--experimental-strip-types` needs **Node 22.6 or newer**. On an older runtime, compile first and run the emitter against the built output.
</Note>

Commit `alfiz-catalog.json` to source control and regenerate it whenever the catalog changes. In CI, regenerate it before running `alfiz-verify` to catch a stale catalog file as a config-parse failure (exit code 2) rather than a false clean pass.

***

## The four check categories

### 1. Typed keys

Permission keys in Alfiz are template-literal types derived from the catalog definition, so `tsc` already rejects a misspelled key at a literal call site. `alfiz-verify` checks the same literals **again**, against the catalog document, and `unknown-pattern` is its most-fired error rule. That is deliberate: the compiler only sees call sites whose key is a literal in a file it type-checks, while the verifier reads the published catalog and catches the wrapped, re-exported, and generated call sites that never present a literal to the compiler.

```ts theme={null}
await alfiz.can({ userId }, "docs.files.reed"); // TS error: not assignable to KeyOf<typeof catalog>
await alfiz.can({ userId }, "docs.files.read"); // OK
```

### 2. Coverage linting

`alfiz-verify` scans every gate call site in your included files and navigation items in your catalog, then cross-references the full list of catalog leaves:

* **Warning (`unreferenced-leaf`).** A catalog leaf that no gate or navigation item references. This may be a dead permission or a missing enforcement point.
* **Error (`ungated-action`).** An exported `async` function in a `"use server"` file that contains no gate at all.

```
⚠ [unreferenced-leaf] <catalog> — "docs.files.read_history" is declared but referenced by no gate or nav item — dead permission, or missing enforcement?
✖ [ungated-action] app/actions/export.ts:14 — exported server action "exportDocument" contains no gate — every action gates on a concrete permission before doing work
```

### 3. Gate-shape linting

`alfiz-verify` errors whenever a visibility affordance (`canAny`, `requireAny`, or `holds`) appears inside a server action or route handler. Visibility affordances answer "should this section exist?"; they are never authorization gates. The rule fires on **any** call to one of these names inside a server file, not only on calls that appear to guard something.

```
✖ [visibility-as-gate] app/api/drive/route.ts:12 — canAny() is a visibility affordance, never a gate — server actions and route handlers gate on a concrete permission (can/require*)
```

Server files are identified by a `"use server"` directive or by path patterns matching the configured `serverFilePatterns` (default: `app/.*route\.(t|j)sx?$` and `pages/api/`).

Gate-shape linting also covers the [condition seam](/enforcement/conditions): a gate call site for a key declared `requiresCondition: true` that visibly carries no `condition` in its options is an error (`missing-condition`).

```
✖ [missing-condition] app/actions/claims.ts:12 — "exp.claims.approve_claim" is declared `requiresCondition: true` — the gate must pass `{ condition: () => … }` evaluating the resource predicate; without it the check throws MissingConditionError at runtime
```

The rule is syntax-level and honest about its reach: an options object built elsewhere (an identifier, a call, a spread) is accepted here and caught by the runtime check instead. What it exists to catch is the common literal call site with no predicate at all.

### 4. Catalog linting

`lintCatalog` runs over the loaded catalog document alongside the code scan, so one command covers the full checklist. It reports six things, and each carries its own severity, so a warning does not fail the build:

| Finding                                                                                                                             | Severity                                    |
| ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| A key whose depth is not **exactly** `conventions.depth` (default 3)                                                                | Error. [Set or disable `depth`](#key-depth) |
| An empty group, declared but with no permissions under it                                                                           | Error                                       |
| A group below the naming floor: no `read` or `read_<thing>` leaf                                                                    | Error                                       |
| A nav `permission` that is not a valid key or pattern, or references nothing in the catalog                                         | Error                                       |
| A `requestable` declaration with no resolvable policy (zero approval stages)                                                        | Error                                       |
| A read leaf not named `read` / `read_<thing>`, an action not named `<verb>_<noun>`, or a nav pattern that currently matches no keys | Warning                                     |

```
✖ [catalog] docs.files — below the naming floor: every tab defines at least one read permission (`read` or `read_<thing>`)
✖ [catalog] docs.files.sharing.read — is 4 levels deep; this catalog's convention is 3
⚠ [catalog] docs.files.Publish — actions are named `<verb>_<noun>` in snake_case (destructive actions may stand alone, e.g. `delete`)
```

#### Key depth

Depth is the one lint rule that is purely a naming preference, and the one most likely to fire on a codebase that was not written with Alfiz in mind. It is checked for **exact equality**, not as a minimum: a four-level key fails against the default `depth: 3` exactly as a two-level one does.

Set it to whatever your keys already are, or turn it off:

```ts theme={null}
conventions: { depth: 2 }      // an integration catalog: zoom.host
conventions: { depth: 4 }      // a deeper feature tree
conventions: { depth: "any" }  // no depth check at all
```

<Tip>
  If depth findings are the only thing standing between you and a green build, set `{ depth: "any" }` and keep the rules that catch real problems: an ungated action, a visibility affordance used as a gate, a key no gate references. Nothing about evaluation, grants, or wildcards depends on how many segments your keys have.
</Tip>

Lint findings are reported over **owned** entries only. Another application's imported keys answer to its conventions, not yours, and a finding your codebase cannot act on is the wrong kind of finding.

***

## Configuration file

`alfiz-verify` looks for `alfiz-verify.config.json` in the current working directory. Pass `--config <path>` to override.

```json theme={null}
{
  "catalog": "alfiz-catalog.json",
  "include": ["src", "app"],
  "exclude": ["node_modules", "dist"],
  "gateNames": ["assertCanEditFile", "gateDestructiveAction"],
  "visibilityNames": ["showIfAny"],
  "serverFilePatterns": ["app/actions/"],
  "forbidClientIdentifiers": ["ALFIZ_SERVICE_KEYS"]
}
```

<ParamField body="catalog" type="string" required>
  Path to the catalog JSON file produced by `catalog.toDocument()`. Resolved relative to the config file's directory.
</ParamField>

<ParamField body="include" type="string[]" default="[&#x22;src&#x22;]">
  Directories or files to scan. `alfiz-verify` walks each entry recursively, collecting `.ts`, `.tsx`, `.mts`, and `.cts` files (excluding `.d.ts`).
</ParamField>

<ParamField body="exclude" type="string[]" default="[&#x22;node_modules&#x22;, &#x22;dist&#x22;, &#x22;.next&#x22;, &#x22;.git&#x22;]">
  Path substrings to skip. Entries in this list are matched against the full resolved path, so any file whose path contains any entry is excluded.
</ParamField>

<ParamField body="gateNames" type="string[]">
  Additional function and method names to treat as authorization gates. **Added to** the built-in defaults (`can`, `fresh`, `require`, `requirePermission`, `gateAction`, `apiRequirePermission`), so you do not need to restate the defaults. Pass your own wrapper names here so the coverage linter recognizes actions gated through them.
</ParamField>

<ParamField body="visibilityNames" type="string[]">
  Additional function names to treat as visibility affordances. **Added to** the built-in defaults (`canAny`, `requireAny`, `holds`). Functions in this list trigger `visibility-as-gate` errors when found in server files. (`heldKeys` is a property access rather than a call, so it cannot read as a gate and is not in the list.)
</ParamField>

<ParamField body="serverFilePatterns" type="string[]">
  RegExp source strings for additional server-file path patterns. **Added to** the built-in patterns for Next.js route handlers (`app/.*route\.(t|j)sx?$`, `pages/api/`). Use this to identify route handler conventions in other frameworks.
</ParamField>

<ParamField body="forbidClientIdentifiers" type="string[]">
  Identifier names that must never appear in a `"use client"` module. Use this to guard service-key constants: if the identifier appears in a client file, `alfiz-verify` emits a `client-reachable-secret` error and fails the build.
</ParamField>

<Note>
  `gateNames`, `visibilityNames`, and `serverFilePatterns` are **additive** in the config file. The CLI merges your entries with the built-in defaults so that forgetting to list `can` does not silently un-gate your codebase. The programmatic `verifyProject()` API uses replacement semantics, so pass `[...DEFAULT_GATE_NAMES, "yourWrapper"]` explicitly when calling it directly.
</Note>

***

## Custom gate and visibility names

When you wrap `can` or `canAny` in a project-specific helper, register the wrapper names in `gateNames` or `visibilityNames` so the verifier can see through them.

```ts theme={null}
// lib/auth.ts — project wrapper
export async function gateDestructiveAction(userId: string, key: KeyOf<typeof catalog>, scope: ScopeId) {
  return alfiz.can.fresh({ userId }, key, scope);
}

export async function assertCanEditFile(userId: string, folderId: string) {
  await alfiz.require({ userId }, "docs.files.update_file", `docs.folder:${folderId}`);
}
```

```json theme={null}
{
  "gateNames": ["gateDestructiveAction", "assertCanEditFile"]
}
```

The verifier matches on the **final name** of any call expression, so `client.can`, `session.require`, `gateDestructiveAction`, and `can.fresh` all match on `can`, `require`, `gateDestructiveAction`, and `fresh` respectively.

***

## File-level opt-out

Files that authenticate outside the catalog by design, such as system trust domains, deploy-key surfaces and health-check endpoints, can opt out of scanning with a file-header comment:

```ts theme={null}
// alfiz-verify-ignore-file system trust domain, authenticates by deploy key
"use server";

// This file verifies inbound webhook signatures — there is no catalog-keyed
// permission to gate on, and adding a fake one would be misleading.
export async function handleWebhook(req: Request) { ... }
```

Place the pragma in the **file header**: above the first non-directive statement, inside a line or block comment. It is also valid between `"use server"` / `"use client"` directives. A reason is required, because an unexplained exemption in a security tool is how exemptions rot.

<Warning>
  A pragma placed after the first non-directive statement is inert. `alfiz-verify` reports it as a warning rather than silently skipping it, so you will see a `⚠ [ignored-file]` entry and the file will still be scanned.
</Warning>

Skipped files appear in the output as `○ [ignored]` lines, with their reason, so reviewers can audit the exemption list:

```
○ [ignored] app/webhooks/stripe.ts — system trust domain, authenticates by deploy key
```

***

## Line-level opt-out

Skipping a whole file is far too blunt for the common case: one runtime-string call site in a file whose other checks you very much want to keep. Two per-line pragmas cover it:

```ts theme={null}
// alfiz-verify-ignore-next-line implicit-import legacy zoom bridge, removed in Q3
await client.can(user, legacyKey);

await client.can(user, k); // alfiz-verify-ignore-line implicit-import same bridge
```

`-next-line` applies to the line below it. The trailing `-line` form applies to its own line, and only when it trails code. A comment alone on a line is not a trailing pragma.

<Warning>
  The **rule name is required**, not optional. An unqualified per-line ignore is how a `client-reachable-secret` error gets silenced by someone reaching for the nearest way to quiet an unrelated warning. Name the rule it suppresses.
</Warning>

The verifier reports three ways a suppression goes wrong, all as `stale-suppression` warnings, so an exemption cannot quietly rot in place:

| Situation                | What you see                                                                                                    |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| No rule name             | `alfiz-verify-ignore-next-line without a rule name — name the rule it suppresses`                               |
| No reason                | `alfiz-verify-ignore for "implicit-import" without a reason — say why this call site is exempt`                 |
| Nothing left to suppress | `alfiz-verify-ignore for "implicit-import" suppresses nothing here — the finding is gone, so remove the pragma` |

***

## Output format and exit codes

Every issue is printed to `stderr` in the format:

```
<symbol> [<rule>] <file>:<line> — <message>
```

* `✖` for an error-severity issue
* `⚠` for a warning-severity issue
* `○` for a skipped file

**Exit codes:**

| Code | Meaning                                                                                                |
| ---- | ------------------------------------------------------------------------------------------------------ |
| `0`  | Clean, with no errors found (warnings may still be present)                                            |
| `1`  | One or more error-severity issues found                                                                |
| `2`  | Config or parse failure: the config file was missing, malformed, or the catalog JSON could not be read |

A sample run with errors:

```
⚠ [unreferenced-leaf] <catalog> — "docs.files.read_history" is declared but referenced by no gate or nav item — dead permission, or missing enforcement?
✖ [ungated-action] app/actions/export.ts:14 — exported server action "exportDocument" contains no gate — every action gates on a concrete permission before doing work
✖ [visibility-as-gate] app/api/drive/route.ts:12 — canAny() is a visibility affordance, never a gate — server actions and route handlers gate on a concrete permission (can/require*)
alfiz-verify: 47 file(s), 1 ignored, 2 error(s), 1 warning(s)
```

***

## CI integration

Add `alfiz-verify` as a step after your TypeScript build. Regenerate the catalog first so the run reflects the current catalog definition:

```yaml theme={null}
# .github/workflows/ci.yml
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Build
        run: npx tsc -b

      - name: Emit catalog
        run: |
          node --experimental-strip-types -e \
            'import("./src/alfiz.ts").then(m => console.log(JSON.stringify(m.catalog.toDocument())))' \
            > alfiz-catalog.json

      - name: Verify permissions
        run: npx alfiz-verify
```

The step fails (exit code 1) if any ungated server action, visibility-as-gate misuse, or catalog error is found, blocking the PR from merging. Warnings do not fail the build unless you promote them with a wrapper script that treats non-zero warning counts as errors.

<Tip>
  Store `alfiz-catalog.json` as a CI artifact alongside your type-check output. It gives you a timestamped record of the exact permission set that was verified for each build, useful for audits.
</Tip>
