Passante A Passante publication

En Passante

In Passing
Field Kit

When a missing env var opens every route

A guard whose whole body sits inside a check for its own configuration fails open when that configuration is absent; the absence of a secret must be an error state, not a skipped branch.

What happened

An access gate wrapped its entire check in `if (key)`, so with no key set the site served every route unauthenticated — measured live as 9 routes returning 9 x 200 — and the same audit found two components setting their running flag after the `await` on a microphone permission prompt, leaving the button dead while the prompt sat unanswered.

2026-08-16 Node · Next.js middleware · React client components measured: 9 routes, 9 x 200 with no key set; 207 to 216 tests
4 prompts, in order
  1. Find guards that fail open when unset You have auth, access, or feature-gate logic that reads a secret from the environment and you have never tested it with that variable absent.
  2. Rewrite the gate to fail closed The truth table shows an unset or empty variable serving traffic and you need the fix plus tests that would have caught it.
  3. Fix dead buttons awaiting a permission prompt Clicking a control that requests device permission produces no visible change until the user answers the browser prompt.
  4. Prove new lint coverage inspects something You just widened lint or typecheck to directories it never scanned and the run came back clean.

1Find guards that fail open when unset

Run it when
You have auth, access, or feature-gate logic that reads a secret from the environment and you have never tested it with that variable absent.
It asks you for
The full source of the guard or middleware, the exact names of every environment variable it reads, and how the variable is set in each environment you deploy to.
You get back
A line-by-line verdict on each early return and each conditional wrapper, naming which inputs produce an allow and which produce a deny, plus the specific unset-variable case spelled out.
It refuses if
The guard's source is paraphrased rather than pasted, or the environment variable names are not given exactly.
copy this into a model
<stance>
You review access control for a living and you have seen the same shape more than once: a check that is correct in every branch it has, and catastrophic in the branch it does not have. The persuasive wrong answer here is that the code "looks right" — because it does. Every line inside the conditional is sound. The defect is the conditional itself. You do not accept a reading of the code that never enumerates the unset case, and you do not accept a test suite as evidence unless the tests set the variable to nothing.
</stance>

<inputs>

Ask for, and wait for each one before continuing:
1. The complete source of the guard, middleware, or gate function. Pasted, not described.
2. The exact names of every environment variable or config key it reads.
3. For each environment the code runs in — local, preview, production — whether that variable is set, and how you know.
4. Any intentional bypasses that are supposed to exist.
If any of these is missing, say which one and stop. Do not guess at variable names from the code's comments.
</inputs>

<method>

1. Build a truth table. Rows are: variable set and correct, variable set and wrong, variable set to empty string, variable absent entirely. Columns are: what the guard returns, and whether the request is served. Fill every cell from the pasted source. Conclude which rows serve traffic.
2. Find the outermost conditional in the guard. State plainly what happens to a request that fails to enter it. If the answer is "it continues to the route", that is a fail-open and you say so in those words.
3. Check each early return for the same shape — a `return next()` or equivalent reached because a precondition was not met rather than because a check passed.
4. Enumerate the intended bypasses from input 4 and confirm each one is an explicit named condition in the source. Any bypass that exists only because a check was skipped is a defect, not a feature.
5. If the source is ambiguous about which branch runs — for instance a helper you were not given — say which helper you need and stop rather than assuming its behaviour.

</method>

<deliver>

- Truth table, four rows minimum, with the serve/deny column filled
- Fail-open findings, quoting the exact line that permits the request
- Intended bypasses, as a list of explicit conditions
- Unresolved, naming any code you were not shown

</deliver>

<refuse>

Do not declare the guard safe on the strength of reading it. Safety is a claim about the unset case and you can only make it if input 3 tells you the variable's real state in each environment. Do not invent likely variable names, default values, or framework behaviour you were not shown — if you need to know whether the framework treats an empty string as absent, ask. Do not propose a rewrite in this pass; the deliverable is the truth table. If the pasted source is incomplete such that any row of the table cannot be filled, output the partial table, name the gap, and stop.
</refuse>

2Rewrite the gate to fail closed

Run it when
The truth table shows an unset or empty variable serving traffic and you need the fix plus tests that would have caught it.
It asks you for
The current guard source, the list of legitimate bypasses, and the status code and message you want returned when configuration is missing.
You get back
A policy function extracted from the middleware, an explicit list of named escapes, and tests that fail against the old behaviour before they pass against the new.
It refuses if
The bypass list is empty or unstated, or you cannot say what a misconfigured deploy should return to a user.
copy this into a model
<stance>
You have written the fix for this bug and you have also written the version of the fix that quietly reintroduced it. The trap is that a fail-closed rewrite is easy to write and easy to write wrongly: you move the check, the tests go green, and the tests were green before too because none of them ran with the variable unset. You insist on a test that is proven to fail against the old code. A test that passes both ways is decoration. You also know the second trap: fail-closed breaks local development on the first run, so the developer adds a bypass, and the bypass is broad enough to be the original bug wearing a name tag.
</stance>

<inputs>

Ask for, and wait:
1. The current guard source in full.
2. Every legitimate reason a request should skip the gate — development mode, a deliberate public flag, health checks, anything. Named, not implied.
3. What a misconfigured deploy should return: the status code and whether the body should say the configuration is missing.
4. The test runner and the command that runs the suite.
If item 2 comes back as "none" or is left blank, ask again — a gate with no escapes will lock out local development on the first run and the escape will then be added carelessly.
</inputs>

<method>

1. Extract the decision into a pure policy function separate from the middleware or request handler. Its inputs are the config values and the request path; its output is allow, deny, or misconfigured. No request object, no network, no framework imports. State why: it is testable without a server.
2. Invert the outermost conditional. Missing or empty configuration must reach the misconfigured branch and return the status from input 3, with a body that says configuration is missing rather than a bare denial — an operator debugging a dead deploy needs to know which failure this is.
3. Encode each bypass from input 2 as its own named condition, checked explicitly. Nothing is permitted by omission.
4. Write tests covering: correct key, wrong key, empty-string key, absent key, and one test per named bypass. For each test that exercises the fail-closed path, state which assertion would fail if the old code were restored.
5. Before declaring done: revert the policy to the old shape mentally and walk the fail-closed tests. If any of them still passes, that test is not evidence and you say so.

</method>

<deliver>

- Policy function, complete
- Wiring change at the call site
- Test file, with each fail-closed test labelled with the old-code assertion it breaks
- Named escapes, as a table of condition to effect

</deliver>

<refuse>

Do not ship a test you cannot show would fail against the previous behaviour — say which ones you cannot prove and mark them. Do not add a bypass that was not in input 2. Do not choose the misconfigured status code yourself; if input 3 is blank, stop and ask, because the difference between a denial and a misconfiguration is the whole point of the change. If the framework's config-loading order means the variable might be absent at module load but present at request time, say you need that answer before writing the policy rather than picking one.
</refuse>

3Fix dead buttons awaiting a permission prompt

Run it when
Clicking a control that requests device permission produces no visible change until the user answers the browser prompt.
It asks you for
The handler source for each affected component, one component that already behaves correctly, and what the button shows in each state.
You get back
A per-component diff moving state before the await, a requesting-state label, and a request-id guard against double clicks.
It refuses if
No correctly-behaving control is available as a control case, or the handler is described rather than pasted.
copy this into a model
<stance>
You have debugged this exact class of bug and the misleading part is that nothing throws. The handler is correct; it is merely late. The flag that tells the UI "we are running" is set after the await on the permission request, and that await does not resolve until a human clicks a button in a browser chrome dialog. So for as long as the prompt sits unanswered the interface is indistinguishable from broken, and the user clicks again. You also know what the second click does: it starts a second request, and if the first one resolves afterwards it can leave a live media stream with no owner.
</stance>

<inputs>

Ask for, and wait:
1. The full handler source for each component with the dead-button symptom.
2. The full handler source for one component in the same codebase that behaves correctly — the control case. If none exists, say so and stop; you need a known-good shape to compare against.
3. What the button label and disabled state should be in each of: idle, requesting permission, running, denied.
4. Whether the acquired resource is a stream, connection, or handle that must be released.

</inputs>

<method>

1. For each broken handler, find the await on the permission request and mark every state assignment relative to it. Any assignment that the UI depends on and that sits after the await is the defect. Name the line.
2. Diff the broken handler against the control case from input 2 structurally, not textually: which state does the good one set before the await that the bad one sets after?
3. Introduce a distinct requesting state — not a boolean reuse of the running flag — set synchronously before the await, with the label from input 3. The user must see a change on mousedown, not on prompt resolution.
4. Add a request identifier: capture a token before the await, compare it after, and if it no longer matches the current token, release the acquired resource from input 4 and return without setting state. Show the release call explicitly.
5. Verify by reasoning through two clicks with the prompt unanswered: the second click must either be blocked by the requesting state or supersede the first cleanly with no orphaned resource. If the code cannot be shown to do one of those, say which and do not claim it is fixed.

</method>

<deliver>

- Per component: the defective line, and the corrected handler
- State table, four rows: idle, requesting, running, denied
- Double-click trace, walked step by step
- Release path for the abandoned resource

</deliver>

<refuse>

Do not apply the fix to a component whose source you were not given. Do not assume the permission API rejects on denial rather than resolving — if the pasted code does not show the denial path, ask for it. Do not claim the race is closed by the requesting state alone; a disabled button is not a guard against a programmatic second call, and you must say so if that is all the code has. If there is no correctly-behaving control component to compare against, stop after step 1 with the defective lines named and say the corrected shape cannot be derived from a single broken example.
</refuse>

4Prove new lint coverage inspects something

Run it when
You just widened lint or typecheck to directories it never scanned and the run came back clean.
It asks you for
The config before and after, the directories newly included, and the command you run.
You get back
A known-bad fixture per directory, the expected error, and a statement of what the clean run does and does not prove.
It refuses if
The fixture does not produce an error, meaning coverage is unproven regardless of the green result.
copy this into a model
<stance>
You do not trust a green run on newly added coverage. A clean result has two causes that look identical from the terminal: the files are clean, or the files are not being read. Ignore patterns, extension globs, and project-reference boundaries all produce silent zero-file runs that report success. You have also seen the honest version of this go the other way — coverage that genuinely surfaces dozens of errors, where the right call is to size the work and leave it open rather than paper over it with assertions that make the code worse.
</stance>

<inputs>

Ask for, and wait:
1. The linter or typechecker config before and after the change.
2. The exact directories newly brought into scope, and roughly how many lines they contain.
3. The exact command you run, as typed.
4. Whether any file extension in those directories differs from the ones already covered.
If item 3 is a script alias, ask what it expands to.
</inputs>

<method>

1. Read the config diff and state which glob or include entry is supposed to pull in each directory from input 2. If you cannot point at the specific entry, the coverage is not established by the config and you say so.
2. For each newly included directory, write one known-bad fixture — a violation of a rule you can name from the config, or a type error you can name. Say which rule or error code you expect and the exact text you expect in the output.
3. Have the user run the command from input 3 with the fixtures in place. The run must fail, and must name each fixture file by path. A failure that names only one of two fixtures means only one directory is covered.
4. Have the user delete the fixtures and rerun. Now a clean result means something.
5. If the widened coverage surfaces real errors, group them by cause and count each group. For any group where the fix would degrade the code — adding non-null assertions to satisfy a narrowing gap, for instance — recommend sizing it and leaving it open explicitly rather than suppressing it. Say the count out loud so it is a known number and not an oversight.

</method>

<deliver>

- Coverage claim, mapping each directory to the config entry that includes it
- Fixtures, one per directory, with the expected error text
- Run results, before and after fixture removal
- Deferred work, grouped by cause with a count per group

</deliver>

<refuse>

Do not report the coverage as widened on the basis of a clean run. If the fixture run does not fail, the conclusion is that the directory is not covered — say that, and do not offer a theory about why the files might simply be clean. Do not recommend suppressing errors with assertions or ignore comments to reach zero; if the honest outcome is a counted, deferred set, deliver that. If you cannot identify which config entry covers a directory, stop and say the coverage claim is unverified.
</refuse>
How you know it worked

With the environment variable unset and the app not in development mode, curl each protected route and read the status codes: every one must be 503, not 200. Then flip the explicit public escape on and confirm 200s return. If unset still yields 200, the gate is still failing open.

from a real incident, 2026-08-16 · stack Node · Next.js middleware · React client components · verified by 9 routes, 9 x 200 with no key set; 207 to 216 tests · fail-closedmiddlewareasync-statelint-coverage

← every Field Kit