Skip to main content
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

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

Emit the catalog JSON

alfiz-verify reads your catalog from a JSON file produced by catalog.toDocument(). Generate it with:
--experimental-strip-types needs Node 22.6 or newer. On an older runtime, compile first and run the emitter against the built output.
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.

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.

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.
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: a gate call site for a key declared requiresCondition: true that visibly carries no condition in its options is an error (missing-condition).
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:

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:
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.
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.
string
required
Path to the catalog JSON file produced by catalog.toDocument(). Resolved relative to the config file’s directory.
string[]
default:"[\"src\"]"
Directories or files to scan. alfiz-verify walks each entry recursively, collecting .ts, .tsx, .mts, and .cts files (excluding .d.ts).
string[]
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.
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.
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.)
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.
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.
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.

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.
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:
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.
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.
Skipped files appear in the output as ○ [ignored] lines, with their reason, so reviewers can audit the exemption list:

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:
-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.
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.
The verifier reports three ways a suppression goes wrong, all as stale-suppression warnings, so an exemption cannot quietly rot in place:

Output format and exit codes

Every issue is printed to stderr in the format:
  • for an error-severity issue
  • for a warning-severity issue
  • for a skipped file
Exit codes: A sample run with errors:

CI integration

Add alfiz-verify as a step after your TypeScript build. Regenerate the catalog first so the run reflects the current catalog definition:
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.
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.