Release notes

Follow new releases (Atom)

v0.4.0

Breaking

apifae record no longer writes credentials to disk

A captured request keeps its header names, but the values are replaced with <redacted> unless the header is one of a short list known to be harmless: accept, accept-encoding, accept-language, content-type, content-length, user-agent, referer, origin, and anything starting sec-fetch- or sec-ch-ua. Query values are replaced the same way when the parameter name contains api_key, apikey, access_token, token, secret, signature, sig, password or auth.

A recorded request now looks like this:

request:
  method: GET
  path: /orders
  headers:
    accept: application/json
    authorization: <redacted>
    user-agent: curl/8.4.0

What the target API receives is unchanged. Redaction applies on the way to disk, not on the way upstream, so a recorded call behaves exactly as it did before — the same request, header for header.

This change does not reach record --convert either. Conversion reads only the method and path of a captured request, never its headers or query, so nothing here changes what a mock is written from. Mock bodies are redacted now as well, but that is a separate change with its own switch — under Changed, "apifae record --convert no longer writes credentials into mock files".

Who this breaks: anyone who reads credentials back out of .apifae/recordings/ — replaying a captured request by hand with the token it carried, or a script that lifts an Authorization header out of a session file. Those now find <redacted> where the value used to be.

What to do: pass --no-redact to keep the old behaviour for one run:

apifae record https://api.example.com --no-redact

or set it for a workspace in apifae.yaml:

record:
  redact: false

The flag wins over the config, and record prints a warning at the start and end of any session where redaction is off. To keep one specific header without turning redaction off wholesale, name it instead:

record:
  keep_headers:
    - x-request-id

authorization, cookie and proxy-authorization are never kept by keep_headers; use --no-redact if you genuinely need one of those on disk.

record.redact is not the only switch. It governs what a capture writes — headers and query values. What a mock keeps is governed by convert.redact_bodies, described below, and setting record.redact: false leaves that on. --no-redact turns off whichever of the two applies to the run you pass it to.

Response bodies in the captured session are not redacted. A recording is meant to be a faithful record of what the API returned, and .apifae/ is gitignored. What used to be true of mocks/ as well no longer is: under Changed, "apifae record --convert no longer writes credentials into mock files".

Added

apifae init writes a .gitignore entry for .apifae/

A recording session is no longer a candidate for git add .. An existing .gitignore is appended to rather than replaced, and an entry that is already there is left alone, so running init over a repository that already ignores the directory changes nothing.

record.redact, record.keep_headers and record.redact_query

Three new apifae.yaml keys, documented in the config reference, controlling what a recorded session keeps. record.redact defaults to true; omitting the record: section entirely leaves redaction on.

convert.redact_bodies and convert.redact_body_keys

Two new apifae.yaml keys, documented in the config reference, controlling what record --convert keeps in a mock body. convert.redact_bodies defaults to true; omitting the keys entirely leaves body redaction on.

Changed

apifae record --convert no longer writes credentials into mock files

A recorded POST /login used to put its access_token straight into mocks/, which is a directory you are meant to commit. As a mock is now written, string values under credential-shaped keys are replaced with <redacted> — a key containing token, secret, password, apikey, credential, authorization or privatekey, matched at any depth, case-insensitively, and ignoring - and _.

- path: /login
  method: POST
  responses:
    - status: 200
      body:
        access_token: <redacted>
        refresh_token: <redacted>
        expires_in: 3600
        token_count: 2
        user:
          email: a@b.c

Only strings are replaced. expires_in: 3600 and token_count: 2 keep their values and their numeric types, so a mock that served a number before still serves a number. Fields under keys nothing recognises are untouched.

Bodies too large to sit inline are written beside the mock as a body_file; those are redacted as well, before the split.

The recorded session under .apifae/ still holds the real body. A capture is meant to be a faithful record of what the API returned, and that directory is gitignored.

Two things this does not cover. A body stored as raw_body or raw_body_base64 — text, XML, CSV, images, protobuf — has no keys to match on and is not scanned; the conversion summary now names those responses so you can review them yourself. And substring matching over-redacts: a field called tokenizer is replaced too.

--no-redact writes bodies verbatim for one run, convert.redact_bodies: false for a workspace, and convert.redact_body_keys adds keys to the list. The flag wins over the config.

diff and patch no longer check or rewrite a response through another response's conditions

On an endpoint whose responses are chosen by when:, diff could build the probe for one response from another response's values, and patch then wrote what came back into the wrong response. The example apifae init writes showed both. An untouched workspace reported drift on GET /api/users/{id} and POST /api/users, and patch turned the 409 "email already exists" response into a 201 with four new fields.

Each response is now probed with a request only that response would answer:

  • A path parameter is never taken from another response's when: path.*, and never from a template such as "{{path.id}}". If nothing else supplies one, that response is skipped with the existing "no value for {id}" reason. Pass --path-param or set diff.path_params to have it checked.

  • A request body is never rebuilt from a when: body.* matcher. It comes from the spec's requestBody example or schema, or there is none.

  • If another response that would win on the mock server could answer the same request, the response is skipped, and the skip names that response:

    [default]
      ⚠ Skipped — another response (`body.email=taken@example.com`) could answer the same request
    

    In --json this is a new skip reason, {"SiblingMayMatch": {"condition": "…"}}. A body.* condition always counts, because no request body can be guaranteed to miss one.

patch now builds the same requests as diff, one per response, including the request body diff derives from the spec. It writes each change only to the response whose probe found it. When a response is skipped, patch names it under its [label], counts it as N variants skipped in the summary, and never writes it.

On the init example, diff now exits 0 with Summary: 4 checked, 4 unchanged, 1 skipped, 3 variants skipped. After deleting a field from GET /api/health, patch restores that one line and leaves every other response byte-identical.

Skips still don't fail a run unless you pass --fail-on-skip, and the exit codes of both commands are unchanged. An endpoint with a single response and no when: is not affected: its diff output, its --json and its patch output are exactly what they were. The one difference there is that patch on a POST, PUT, PATCH or DELETE with a spec now sends the request body diff already sent.

Counts in command output agree with their number

A count of one used to print with a plural noun, or with (s). A fresh apifae init workspace validated to 5 mocks checked, 5 valid, 0 errors, 1 warnings, and a one-file patch --dry-run ended Summary: 1 files would be updated. Every count now takes the singular for 1 and the plural otherwise:

1 mock checked, 1 valid, 0 errors, 1 warning
Summary: 1 file updated, 1 field changed
Summary: 1 checked, 1 error
Error: Validation failed: 1 error

This covers:

  • the summary lines of validate, diff, patch (including --dry-run and --interactive) and record --convert;
  • validate's and up's load and failure messages;
  • up's startup and reload lines;
  • record's capture summary, --list and session picker;
  • patch's "the mock declares 1 element here" refusal;
  • init's existing-workspace prompt.

(s) no longer appears anywhere in command output. record --convert's warning now reads 1 response has non-JSON bodies, where it used to print 1 response have. The config watcher's other field(s) changed line now reads other changes have no effect on a running server.

Counts other than 1 print exactly as before: 0 errors, 2 files updated and 24 endpoints are unchanged. A script matching a summary with 1 in it, such as 1 errors or 1 files updated, needs the singular form.

Documentation

A new guide: Secrets and redaction

docs/guides/secrets-and-redaction states exactly what a recorded session keeps and what it does not, including the parts that are still not redacted, and how to change either list.

The manual's FAQ used to answer the same question twice, and both answers said recordings and mocks keep credentials in the clear. There is now one answer, "Does apifae record store my API keys?", and it matches the binary.

v0.3.0

Added

apifae report — a way to report a bug without assembling one by hand

A new command opens a prefilled GitHub issue in your browser:

apifae report

It fills in the version, OS, architecture and install channel, which is the half of a bug report that is tedious to gather and easy to get wrong.

Nothing is sent. The command makes no network call of its own — it builds a URL and opens it. You see the filled-in issue, you edit it, and it exists only if you submit it yourself. There is no account on our side and no endpoint receiving anything.

--print writes the URL to stdout instead of opening a browser. That also happens automatically when stdout is not a terminal, or when no browser can be opened, so the command behaves sensibly over SSH and in CI rather than failing.

Two limits are enforced deliberately, because a URL that is too long fails silently in the browser where we cannot see it: a crash file over 6 KB, or one that percent-encodes past 8000 bytes, is refused with the address to open an issue by hand.

A panic leaves a report behind instead of a wall of text

When apifae panics it now writes a report to ~/.apifae/crash/ and tells you what to do with it:

apifae crashed. A report — no message, no file contents, no flag values — is at:
  /Users/you/.apifae/crash/20260907T184233Z-41288-000.md
Send it with:  apifae report --crash /Users/you/.apifae/crash/20260907T184233Z-41288-000.md

The panic message itself still prints exactly as it did before. The hook chains to the one already installed rather than replacing it, so the crash notice is added to what you saw, not substituted for it.

The report is an allow-list, not a scrubber. It carries the version, OS, architecture, install channel, timestamp, the command path and the long flag names of the invocation, where in our source the panic happened, and the type of the panic payload. It does not carry the panic message, any flag value, any file contents, any environment variable, or any path of yours. Values are dropped by construction: the invocation is rebuilt from what the binary's own argument declaration recognises, and a flag's value is never part of that declaration.

Two consequences of that design worth stating plainly:

  • The panic message is never recorded, deliberately — .expect(&format!("bad header {value}")) puts live data in it. That is why the message stays on your screen and the file does not have it.
  • The install channel is inferred from the binary's own path, and the path is never stored. Only which arm matched — homebrew, scoop, npm, cargo, system-bin, unknown — leaves the process. system-bin covers both install.sh's targets and a hand-copied binary, which are indistinguishable, and it says so rather than guessing.

A panic in a background task says so and notes that the process is still running, rather than claiming a crash that did not happen. A second panic in the same run writes nothing and says nothing. The 20 newest reports are kept and older ones are deleted; nothing else ever cleans that directory.

Exit codes are unchanged. A panic still exits 101, and the 0/1/2 contracts diff, patch and validate reach through their own exits are untouched. The hook writes to stderr only, so a piped stdout carries exactly what it did before.

Documentation

  • The exit-code table in /docs/reference now links out to the two pages someone with a red pipeline actually needs — how to wire the gate, and the fix for the failure they hit. Both were a search away and neither was a link away.
  • /docs/guides/record-and-replay says what happens to the responses a capture discards, which the guide previously left the reader to infer.
  • The product name is spelled one way across the manual and the site, and a guard now fails when it drifts.

v0.2.0

Breaking

apifae patch --dry-run now exits 1 when it finds drift

It used to exit 0 for ordinary field drift — the drift patch exists to fix — and 1 only for drift it could not apply, such as a removed field or a grown array. That is the inverse of what the manual documented, so a CI gate written from the documentation got the opposite of what it asked for.

The contract now matches diff, which the manual has always sold it as a pair with:

Exit patch --dry-run
0 nothing needed changing
1 drift was found — nothing was written
2 the run could not complete

Who this breaks: a pipeline that runs apifae patch --dry-run and treats a non-zero exit as a failure. It will start failing on workspaces that have drift.

What to do: that is the signal working — a mock has stopped matching the API and something should change. If you want the older "tell me but do not fail" behaviour, treat exit 1 as informational explicitly:

apifae patch "$API" --dry-run || [ $? -eq 1 ]

Exit 2 still means the run could not complete, and still outranks 1, so this keeps failing on a connection error.

A real (non-dry-run) patch is unchanged: it exits 0 when it applied everything it found.

push, pull and login no longer accept flags

--force and --dry-run on push, --force and --version on pull, and --api-key on login have been removed.

None of them ever did anything. All four commands refuse immediately — APIFae cloud does not exist yet — so every one of these flags was read by nobody and silently ignored.

Who this breaks: a script passing one of them. It now fails as an unknown argument instead of being accepted and ignored.

What to do: drop the flag. The command's behaviour is identical without it. Their reinstatement, if the cloud is built, is tracked separately.

diff now sees a spec that lives only in schemas/

validate, up, patch and diff looked for the OpenAPI spec under three fixed names at the workspace root and nowhere else. The schemas: globs in apifae.yaml had exactly one consumer — status, which counts files — so a workspace whose spec sat only in schemas/ had no spec at all as far as every command that reads one was concerned.

apifae init leaves exactly that workspace behind whenever the spec it imported was not already at the root.

In diff this was not a degradation, it was a silent stop. The spec is the only input to the contract check, so endpoints_violating stayed at 0 and the run exited 0 on an API that was breaking its published contract.

Resolution order is now: the three root names first, then the schemas: globs resolved against the root, then nothing. The root wins deliberately — init writes the spec to both places, and letting the copy win would mean editing the spec the manual points at had no effect. Among glob matches, only a file declaring a top-level openapi: or swagger: key is a candidate, and the first in sorted order wins, so the choice is identical on CI and on a laptop.

Who this breaks: a pipeline whose workspace keeps its spec in schemas/. diff will start reporting spec violations it could not previously see, and can exit 1 where it used to exit 0.

What to do: read the violations — they are contract breaches that were already happening. Nothing about your workspace needs to change.

diff probes every response variant, not just the first

A mock endpoint can declare several responses, each with its own when: block — a header, a query parameter, a path parameter. diff built one probe per endpoint and compared the answer against the first response, so on a versioned mock it checked one variant and reported the endpoint as covered. Every other variant was unverified and nothing said so.

It now builds a probe from each response's own when: block and reports each under its own label:

GET /orders
  [header.api-version=2026-01-01]
    ✓ No drift
  [default]
    ✗ Drift detected:
      ~ $.total: Number → String

Summary: 1 checked (2 variants), 1 drifted

The label carries the full matcher key, so query.version=2 and header.version=2 are never confused for one another.

Three kinds of response are reported as skipped rather than guessed at, because no single request satisfies them: a body.* matcher, any ~ regex matcher, and a scenario: response. A skip is reduced coverage, not a failure — it is counted and printed and the run still exits 0. --fail-on-skip now covers skipped variants as well as skipped endpoints.

An endpoint with a single unconditioned response prints exactly as it did before. If none of your mocks use when:, nothing about your output changes.

Who this breaks: a pipeline with versioned mocks. diff will start finding drift in variants it never checked, and can exit 1 where it used to exit 0. Anyone passing --fail-on-skip may also see it trip on a variant that cannot be probed.

What to do: the drift was already there. For a variant diff cannot probe, give it a matcher that can be satisfied — a header or query parameter usually says the same thing a body field does — or send the value on every probe with auth.headers.

An apifae.yaml that names a proxy turns off HTTP_PROXY

The proxy settings in apifae.yaml used to do nothing at all, so proxying happened entirely through HTTP_PROXY, HTTPS_PROXY and NO_PROXY, which the HTTP client read for itself. Those keys work now — and config replaces the environment rather than extending it.

The environment is still read when the file says nothing about proxying. The moment it names any of the three proxy.* keys, the environment stops applying entirely — so a file setting only proxy.http also turns off HTTPS_PROXY.

That rule is deliberate. Routing you can read off one file beats routing assembled from a file and whichever shell happened to launch the process, and the alternative — merging the two — produces a workspace nobody can reason about from either source alone.

Who this breaks: a workspace that sets some proxy.* key in apifae.yaml and relies on the environment for the rest. Before this release the file was inert, so the environment won by default; now the file wins completely.

What to do: put every proxy setting the workspace needs in one place. If the environment is where you want them, remove the proxy.* keys from apifae.yaml — an absent block reads the environment exactly as it always did.

Added

The six outbound config keys are no longer inert

proxy.http, proxy.https, proxy.no_proxy, timeouts.connect, timeouts.request and timeouts.mock_delay_max were validated, written and read back by apifae config, and no command consumed any of them. The manual labelled them "accepted, not yet acted on", which was honest but not much use.

They are live:

  • diff and patch reach the API through the configured proxy, and apply both timeouts when they probe. --timeout still beats the file.
  • record forwards through the proxy and applies timeouts.connect. It deliberately ignores timeouts.request: it is relaying live traffic whose duration is the upstream's business, and cutting off a slow response mid-capture would write the fragment into the session as if it were the whole thing.
  • up clamps a mock's own delay_ms to timeouts.mock_delay_max. A response asking for more is served at the ceiling rather than refused — delay_ms: 300000 is almost always a misplaced zero, and turning a typo into a failed request helps nobody. Offending responses are named once when the mocks load and again on every reload. The ceiling is fixed at startup, so changing it under a running up asks for a restart.

A run that times out exits 2 — could not complete — never 1, so a slow API never reads as drift.

Two new manual pages

/docs/mock-files documents the mock file format itself — every field, its type and its default — and /docs/config-file does the same for apifae.yaml. Both were previously things you learned by reading apifae init's output. Their tables are checked against the binary, so a field that exists only in the documentation now fails the build.

/docs/scenarios has become /docs/guides/work-with-scenario-files, a guide rather than a reference, and the old URL redirects.

Changed

  • apifae push --help, pull, login and logout now describe a capability that does not exist yet, in the conditional, instead of advertising four working features. /docs/reference says the same.
  • The manual corrects ten claims about behaviour the binary does not have — among them what up --strict actually makes fatal (errors, not warnings), that validate and patch do not need an apifae.yaml, and that transition_to only fires on a response tagged scenario: machine:state.
  • apifae up's entry no longer documents an exit code. It serves until interrupted and has none.
  • --timeout no longer carries its own default. Left unset it falls through to timeouts.request, and only then to 5000 ms — which is what it defaulted to before, so a run that sets neither is unchanged. Previously the flag's default was applied first and the config key could never have been reached.
  • timeouts.request is documented as defaulting to 5000 ms, not 30000. The 30000 was the intended default of a key nothing read; 5000 is what the code has always actually used.

Documentation and site

The manual's transcripts are checked harder than they were: every command and flag in it now resolves against the running binary, every console block must declare whether it is executed or exempted-with-a-reason, and the scenario-file reference — previously outside every guard — is inside them.

The site's footer no longer claims every example is executed, because it was not. The landing page's three diff transcripts now describe one run rather than three contradictory ones, and every claim about price has been removed.

The landing page's two lead terminal transcripts — the hero's diff and the capabilities section's lead transcript — now play as recordings when they scroll into view. Each recording is generated from the same data array the static block renders, so the animation and the static text describe the same run. The static block stays as the fallback: it's what a reader with no JavaScript, prefers-reduced-motion: reduce, or a screen under 768px sees, and it's what the prerendered HTML carries. The CSP's script-src gained 'wasm-unsafe-eval', which the player needs to compile its WebAssembly terminal parser; 'unsafe-eval' remains disallowed.

/docs/reference is structurally sound for the first time: its 87 heading ids were 32 distinct ones, so every link in its own command overview resolved to an ambiguous target. Ids are unique now, heading levels nest by command depth rather than jumping from h2 to h6, and every code block is reachable from the keyboard.

/docs/scenarios is rewritten. It was the one page still written to a generic documentation template — Title Case headings, sections that restated their own heading, three sections written twice — while the rest of the manual is in one voice. Every example, transcript and YAML block on it is unchanged; only the prose around them moved. It also now says what DELETE /_apifae/scenarios/active resets, which the control-API section never mentioned.

Install

Downloads from apifae.com/dl are now counted in aggregate — what was downloaded and on what day, with no address, cookie or identifier kept. The CLI itself sends nothing; it gained no telemetry. An install.sh run can label its channel with APIFAE_INSTALL_SOURCE. /privacy describes exactly what is recorded.

See https://apifae.com/dl/ or the install instructions at https://apifae.com.

v0.1.0

No release notes were written for this version. It shipped before this page, and this practice, existed.