Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. "The 2fa show screen doesnt work, fix that." - the Authenticator panel Phase 22.2 shipped two days earlier.
  2. "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:

ControlState
#auth-import-btnmarkup, a title, no handler
#auth-export-btnmarkup, 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's hidden attribute 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-header neither 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 when auth is removed from DEFAULT_SETTINGS.panelOrder.
  • offers the Authenticator panel in the order editor - fails when the ALL_PANELS row 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.ts
  • env_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 .env is 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: value line per field rather than the design's single joined line: full adds nine of them and a .env comment 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.
  • full is refused vault-wide on both sides (Copy All downgrades with a toast saying so; envv export --profile full without --project exits invalid). 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 --metadata and --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:

FixturePins
parity/env-names.json25 name cases, 15 quoting round trips, 3 unquoting cases, 6 role cases, 6 reference-lookup cases
parity/copy-profiles.json4 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_app fails 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 auth from DEFAULT_SETTINGS.panelOrder → the tab test fails.
  • Remove the ALL_PANELS row → 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

  1. A stored extra_var printed in clear from envv get --json unless 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.
  2. The 2FA panel could not be opened by anyone. A whole feature, shipped and invisible, with nothing anywhere saying why.
  3. 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.
  4. Three name builders disagreed, so one entry exported under two names.
  5. formToEntry dropped every field with no form input. Editing a description erased the entry's pool, and would have erased every field added from here on.
  6. The .env link scorer broke ties by array position - invariant 1, and it silently repoints a working .env at 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-lab has 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 KEYID segment 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.json had 100/minute quoted and playlist-read,user-top-read bare; 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: ';' expected forty characters later.

Next steps

  1. Commit. The pwsh one-liner was handed over in the session. Nothing is staged; CLAUDE.md is tracked and was edited, the session records/ is gitignored.
  2. Phase 23 step 4 - env_var without a mandatory primary, carrying E8 (version_history per secret-carrying field, or an honest statement in the UI that these are not versioned) and the E16 item dropped above. The audit list in CLAUDE.md's step-4 section must be walked before the form validation is relaxed, not after.
  3. Then steps 5 (cookies), 6 (collisions, last_copied_name), 7 (file-shaped credentials).
  4. ui-lab has no Authenticator screen and no seeded TOTP entry. Adding both is cheap and is what would have caught Part 1 before a user did.
  5. Still outstanding from phase log 29: re-enrol the two exposed seeds, and delete or rebuild the stale 0.20.0 envv binary in target/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.