Phase log 30: 2026-09-13
The 2FA panel was unreachable for everyone, and Phase 23 steps 1–3.
Goal
Two things, in the order they arrived.
- "The 2fa show screen doesnt work, fix that." - the Authenticator panel Phase 22.2 shipped two days earlier.
- "Implement the next phase." - Phase 23: credential shape, copy profiles and env naming. Paused after step 3 at the user's request (credit budget), with what was dropped written down rather than dropped quietly.
Part 1 - the Authenticator panel was invisible, for everyone
It was never a bug in the panel. src/ts/auth-panel.ts, its ticker, its
markup, its CSS and its 11 unit tests were all fine. The activity-bar button that
opens it was display: none.
applyPanelOrder() (src/ts/settings-panel.ts) hides every activity-bar button
whose data-panel is missing from the persisted panelOrder setting, and Phase
22.2 added the fifth panel without adding auth to that list - or to the
settings editor's ALL_PANELS, or to the fallback literal, or to a migration.
panelOrder has been persisted since Phase 12, so every install already had a
stored list, and a corrected default alone would have fixed nothing.
The button was in the markup, wired, arrow-key reachable, aria-controls correct
- and painted out. 1090 unit tests passed while the feature could not be opened, because jsdom has no cascade and nothing in the suite clicks that tab.
Found in ~40 seconds by clicking it in a real browser:
locator.click: Timeout 30000ms exceeded.
- locator resolved to <button … data-panel="auth" …>
- element is not visible
Three more dead controls behind it
Once the panel opened, three of its controls did nothing:
| Control | State |
|---|---|
#auth-import-btn | markup, a title, no handler |
#auth-export-btn | markup, a title, no handler |
.auth-side-item (data-auth-focus) | rendered, nothing listened |
Only the sidebar section's #totp-* pair had ever been wired. Both id pairs
now run off one loop in vault.ts, so a third copy cannot be forgotten, and the
side list scrolls its card into view and flashes it - addressed by entry id,
never by position (invariant 1).
And two layout defects a real browser found
.auth-next-row { display: flex }outranked[hidden]. The user-agent rule[hidden] { display: none }has specificity 0,0,1 and loses to any class, so the renderer'shiddenattribute did nothing and every card painted its "NEXT" label whether or not the next code had been asked for - which is the entire opt-in. Fixed with.auth-next-row[hidden].- Export was clipped off the sidebar edge. "Authenticator" + Import + Export
do not fit 205px and
.users-panel-headerneither wraps nor shrinks, so the only way into the exporter from the screen built to manage seeds was off-screen.
Verification
Playwright, seeded with totp/hotp/steam entries, at 1400×900 and 900×700:
panel opens, 3 cards, 0 page errors, Export not clipped at either width,
next-row display: none until the toggle then flex, side-list click flashes
exactly one card.
Two regression tests in tests/settings-panel.test.ts, each verified by
reverting its own half:
shows the Authenticator tab under the default order- fails whenauthis removed fromDEFAULT_SETTINGS.panelOrder.offers the Authenticator panel in the order editor- fails when theALL_PANELSrow is removed.
DEFAULT_SETTINGS is now exported from state.ts so the first test asserts
against the real default rather than a literal that would drift from it - which
is the bug itself, in test form.
Part 2 - Phase 23, steps 1–3
8 steps, numbered 0–7. Step 0 shipped as Phase 21. Steps 1, 2 and 3 landed here. Steps 4–7 are untouched.
Step 1 - the name template, and quoting
There were three name builders and they disagreed. dotenvKey (provider +
key_id) fed the .env and YAML exports; envKey in import-export.ts (provider
only) fed the k8s and tfvars exports; data::env_key in the CLI (provider only)
fed all four of its. One entry exported under two different names depending on
which button you pressed, and CLAUDE.md had recorded that as a known live
inconsistency since Phase 23 was designed.
All three are gone. One template, built in one place per side:
[PREFIX_] PROVIDER [_KEYID] [_VERSION] [_LABEL] [_ROLE]
envName()/primaryEnvName()/secretEnvName()-src/ts/state.tsenv_name()/primary_name()/secret_name()-envv-cli/src/envfile.rs
KEYID is a documented deviation from the design, which lists six segments
and not this one on the grounds that key_id is identity rather than a value.
That is true of what it means and false of what it already does: dotenvKey
has put it in the name since Phase 3, chunks/env-link.ts scores
PROVIDER_KEYID as a tier-1 match, and find_entry parses a bare ${NAME} by
splitting into exactly that pair. Dropping it silently renames every variable a
keyed entry generates - the failure the design's own "the one thing this must not
break" section is about - in exchange for nothing. The reasoning is in the
state.ts header and the fixture, because an undocumented deviation is
indistinguishable from having missed the design.
E1 - nothing quoted a value into a .env. Exporter.dotenv emitted
${b}=${k.api_key} raw, and the parser stripped exactly one layer of quotes with
no unescaping. A cookie string, a User-Agent, a password containing # and any
value with a trailing space each produced a file that parsed back as something
else. quoteEnvValue / quote_env_value and their inverses now wrap every
.env writer on both sides. An unresolved ${ref} is deliberately written
through raw: quoting turns a recognisable broken placeholder into an escaped
literal, and the caller is told about it either way.
E3 (legal identifier, __Host-/__Secure- stripped) is inside envName.
E4 landed before a single field was added, which is what the design demanded:
formToEntry(base?) spreads the entry being edited first, so a field with no
form input survives by construction instead of because the save path
remembered to re-attach it - it re-attached five, and every field a later phase
adds would have been erased on the first edit.
Step 2 - roles, labels, and the live preview
New entry fields: primary_role, secret_role, label. Form gets a Value role
(open vocabulary, <datalist> presets), a Name label, a Secret role, and a
live generated-variable preview under the value field - the template has five
inputs and nobody should have to run a copy to find out what they produced.
key_id's visible label was "Label", which now collides; it reads "Key ID".
CLI parity: --role, --secret-role, --label on entry add|set, with label
refused rather than transliterated if it is not a usable segment.
E9 - the reference grammar and the template occupy the same syntactic
position. find_entry / findEntryByRef now try exact provider → generated-name
→ legacy Provider_keyid split, and refuse when two of them disagree:
${TWITCH_V3} against a vault holding both a key_id: V3 entry and a
version: 3 entry has two honest answers, and returning either writes a silent
wrong value into a rendered config - the Phase 21 defect class.
E10 - the .env link scorer learns the template, scored 95, the same as
the PROVIDER_KEYID arm on purpose: they make the same claim about the same
syntax, so when they fire on two different entries they tie, and a tie is now
reported as ambiguous instead of resolved by array position. The tier-1
comment had been arguing for that since Phase 18.
E12 - envv doctor gains an entry-ids check and envv doctor --fix
backfills them. The fix is not to add label to entry_ck's legacy tuple:
that would change every existing ck and silently re-target RBAC scoping on every
pre-id vault.
Also: a declared role beats the alias table. primary_role: "id" says the
primary value is a client id, so ${Spotify/ID} answers with api_key rather
than the key_id beside it - while an entry declaring no role still resolves
exactly as Phase 21 left it. This is what makes the no-primary shapes addressable
at all: ${Twilio/ACCOUNT_SID} and ${Twilio/AUTH_TOKEN} name the two halves by
the issuer's own words.
Step 3 - copy profiles
basic / extended / full, in a new twin pair - src/ts/copy-profile.ts and
envv-cli/src/profile.rs.
- Metadata is
#comments by default. An.envis loaded into a process, and injecting six non-functional variables per credential into every container is a cost nobody asked for by pressing Copy.metadataStyle: 'var'is the opt-in. - Comment mode writes one
# name: valueline per field rather than the design's single joined line:fulladds nine of them and a.envcomment does not wrap. - The builder emits real values and has no masker. Redaction is the caller's job, decided by the Phase 14 rule that already governs every other artefact. A second redaction policy inside the builder is the shape that produced the Phase 22 seed leak.
- The caret beside the copy button keeps the setting a default rather than a wall - the other two profiles and "Value only" are one click away on every card, and the one-off choice is deliberately not persisted.
fullis refused vault-wide on both sides (Copy All downgrades with a toast saying so;envv export --profile fullwithout--projectexitsinvalid). One entry's metadata is a convenience; every entry's is a map of the vault.- CLI:
envv get <entry> --profile,envv export --profile, both with--metadataand--out. - Settings → Data gains a Copy section with a worked example that re-renders as the options change - four interacting switches with no preview is how a settings panel becomes unreadable. The example uses an invented entry, so the panel shows the same thing on an empty vault and no real credential name lands in a settings screenshot.
E5 - extra_vars were masked only when secret: true, and that flag defaults
to unset. So an entry whose real payload lives in named variables - an AWS key
pair, a Twilio credential, a split cookie jar - printed every one of them in
clear from envv get --json, and the first unflagged password was the one that
leaked. This is the env_file-chunk trap wearing a different hat and the same
shape as the Phase 22 seed leak: a value printed because nobody marked it, rather
than because somebody vouched for it.
Inverted: extra_vars are masked by default and public: true is the opt-out.
The two fixed slots take the same flag (primary_public, secret_public),
because a client id lives in api_key, is printed in the issuer's own
documentation, and masking it protects nothing while stopping an agent from
building an auth URL. Per value, never per type. Without it the basic
profile is either useless (everything masked) or unsafe (nothing masked).
Surfaces: a "public" checkbox beside "secret" on each extra-var row, --public /
--no-public / --secret-public / --no-secret-public on the CLI, and the card
stops masking a public value.
What was deliberately dropped, and where it went
E16 - auth_scheme / auth_param and envv curl - was assigned to step 3 and
is not implemented. It is the one item in this session that lost its step.
Recording it here rather than letting it disappear: the design's own rule is that
an edge case losing its step is a decision, and an unwritten exemption is
indistinguishable from an oversight (invariant 10).
It moves to step 4, which is the next thing anyone picks up, and nothing in
steps 1–3 depends on it. Its actual dependency is the other direction: envv curl wants the cookie work in step 5 anyway, since the header-building code is
shared.
Verification
npm run check version + prettier + eslint (0 errors) + tsc + 1098 tests, 48 files
cargo test --workspace all green, 196 in vault-core, 23 in the parity suite
npm run lint:rust clippy --workspace --all-targets -D warnings, clean
Three new parity fixtures, each asserted from both implementations:
| Fixture | Pins |
|---|---|
parity/env-names.json | 25 name cases, 15 quoting round trips, 3 unquoting cases, 6 role cases, 6 reference-lookup cases |
parity/copy-profiles.json | 4 profile/style combinations byte for byte, plus an entry with no primary value |
parity/field-aliases.json (extended) | role-aware resolution, 6 cases |
Each verified by breaking the mechanism and watching the right test fail:
- Break the template arm in
find_entry→reference_lookup_matches_the_appfails on "the template resolves a version nobody could address before". - Disable the role check in
getEntryFieldValue→ the TS parity suite fails on "the declared primary role resolves to the primary value". - Remove
authfromDEFAULT_SETTINGS.panelOrder→ the tab test fails. - Remove the
ALL_PANELSrow → the editor test fails.
Real-browser checks (Playwright, since jsdom has no layout and no cascade):
the Authenticator panel at two widths, the form's live name preview
(SPOTIFY_V2_GAME_ID=•••••• and the label's invalid state), the copy caret menu
(Basic ·, Extended, Full, Value only), and the Settings copy preview.
The bugs that mattered, ranked by consequence
- A stored
extra_varprinted in clear fromenvv get --jsonunless somebody had ticked a box that defaults to unticked. Anything whose payload is named variables - AWS, Twilio, a split cookie jar - was fully readable in a transcript. Same class as the Phase 22 seed leak; fixed the same way, by inverting to fail-closed. - The 2FA panel could not be opened by anyone. A whole feature, shipped and invisible, with nothing anywhere saying why.
- Nothing quoted a value into a
.env. Every cookie, User-Agent, or password with a#produced a file that loaded as something else - silently, at deploy time. - Three name builders disagreed, so one entry exported under two names.
formToEntrydropped every field with no form input. Editing a description erased the entry's pool, and would have erased every field added from here on.- The
.envlink scorer broke ties by array position - invariant 1, and it silently repoints a working.envat a different secret.
What worked
- Clicking the thing in a real browser. Two minutes of Playwright found what
1090 unit tests structurally cannot see. The
ui-labhas no Authenticator screen and no TOTP seed in its fixture - that gap is why this shipped. - Reverting each fix to check the test. Four of them; all four failed correctly. One (the order-editor test) would have passed against a half-fix.
- Writing the deviation down at the moment of deviating. The
KEYIDsegment and the dropped E16 are both in the source and in this file, so neither reads as an oversight later.
What didn't
- Guessing fixture bytes. The first
copy-profiles.jsonhad100/minutequoted andplaylist-read,user-top-readbare; both wrong, because/is in the bare set and,is not. Write the expectation, run it, read the diff. - A backtick inside an HTML comment inside a template literal. It terminates
the string. The error was
TS1005: ';' expectedforty characters later.
Next steps
- Commit. The pwsh one-liner was handed over in the session. Nothing is
staged;
CLAUDE.mdis tracked and was edited,the session records/is gitignored. - Phase 23 step 4 -
env_varwithout a mandatory primary, carrying E8 (version_historyper secret-carrying field, or an honest statement in the UI that these are not versioned) and the E16 item dropped above. The audit list inCLAUDE.md's step-4 section must be walked before the form validation is relaxed, not after. - Then steps 5 (cookies), 6 (collisions,
last_copied_name), 7 (file-shaped credentials). ui-labhas no Authenticator screen and no seeded TOTP entry. Adding both is cheap and is what would have caught Part 1 before a user did.- Still outstanding from phase log 29: re-enrol the two exposed seeds, and delete
or rebuild the stale 0.20.0
envvbinary intarget/debug.
Notes for whoever picks this up
The order in the design is load-bearing and it was right. Step 1 is the
foundation: steps 4 and 5 are the visible half and both sit on the name builder.
Building them first means writing the template logic twice and then reconciling
it, which is exactly how the two disagreeing envKey functions came to exist.
And the thing Part 1 should teach: a control that is wired, tested and painted out is indistinguishable from a missing feature. Every new panel, section or row that any persisted list can hide needs that list updated and migrated - the default alone reaches only installs that do not exist yet.