Release notes
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-paramor setdiff.path_paramsto have it checked.A request body is never rebuilt from a
when: body.*matcher. It comes from the spec'srequestBodyexample 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 requestIn
--jsonthis is a new skip reason,{"SiblingMayMatch": {"condition": "…"}}. Abody.*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-runand--interactive) andrecord --convert; validate's andup's load and failure messages;up's startup and reload lines;record's capture summary,--listand 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-bincovers bothinstall.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/referencenow 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-replaysays 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:
diffandpatchreach the API through the configured proxy, and apply both timeouts when they probe.--timeoutstill beats the file.recordforwards through the proxy and appliestimeouts.connect. It deliberately ignorestimeouts.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.upclamps a mock's owndelay_mstotimeouts.mock_delay_max. A response asking for more is served at the ceiling rather than refused —delay_ms: 300000is 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 runningupasks 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,loginandlogoutnow describe a capability that does not exist yet, in the conditional, instead of advertising four working features./docs/referencesays the same.- The manual corrects ten claims about behaviour the binary does not have —
among them what
up --strictactually makes fatal (errors, not warnings), thatvalidateandpatchdo not need anapifae.yaml, and thattransition_toonly fires on a response taggedscenario: machine:state. apifae up's entry no longer documents an exit code. It serves until interrupted and has none.--timeoutno longer carries its own default. Left unset it falls through totimeouts.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.requestis 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.