Skip to content

fix(frontend): stop one undecryptable secret blanking the environment page - #1004

Open
omlahore wants to merge 4 commits into
phasehq:mainfrom
omlahore:fix/undecryptable-secret-blanks-page
Open

fix(frontend): stop one undecryptable secret blanking the environment page#1004
omlahore wants to merge 4 commits into
phasehq:mainfrom
omlahore:fix/undecryptable-secret-blanks-page

Conversation

@omlahore

@omlahore omlahore commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #948.

Stacked on #970. This branch is cut from fix/environment-switch-key-race, so the diff here includes that PR's three commits. Only the last commit belongs to this one. Merge #970 first and I will rebase, or say the word and I will re-cut this against main instead.

The problem

decryptSecrets runs every secret through a single Promise.all. One row whose ciphertext cannot be decoded rejects the whole batch, so setServerSecrets / setSecretsLoaded never run and every member of that app sits on skeleton loaders indefinitely.

The reporter's instance hit this with a value truncated to 16384 characters at write time, months before anyone noticed. The truncation left the base64 segment at length % 4 === 1, which sodium.from_base64 rejects with invalid input.

#970 added a .catch on this chain, which stopped the unhandled rejection and released decrypting. It does not fix this issue: setSecretsLoaded(true) is still never reached, so the skeletons stay. The two changes are complementary, which is the other reason this is stacked rather than standalone.

The change

Promise.allSettled for both the static and dynamic passes, then:

  • render every row that decrypted, so the page loads with N-1 secrets instead of nothing
  • console.error per failed row, including the secret id, so the bad row is identifiable
  • one non-dismissing toast with the count

The count matters because of the failure mode described in the issue: the CLI already skips undecodable rows silently, phase secrets list showed N-1 with no warning, and that silence is what hid the problem for months. Dropping the rows without saying so would reproduce that in the console.

What I did not decide

How a failed row should look is a design call, so I left it out. A greyed row with an "undecryptable" marker in place of dropping it entirely would be better than a toast, and I will add it if you say what it should look like.

Verification

New test at frontend/tests/utils/crypto/undecryptableSecret.test.ts reproduces the reported corruption (truncating a real ciphertext to length % 4 === 1) and asserts both halves: Promise.all over the same three rows rejects, Promise.allSettled returns 2 fulfilled and 1 rejected with the readable value intact.

tsc --noEmit clean. Full suite 19 files / 387 tests passing.

Switching environments keeps this page mounted; only `data` changes. The
environment's decryption keys are derived asynchronously from the new
data's wrapped seed, but the secrets-decrypting effect fires on the same
render that `data` changes, before that derivation has any chance to
resolve. It ran with the new environment's ciphertext and the previous
environment's still-current envKeys, which decryptAsymmetric rejects.

That rejection went to `decryptSecrets().then(...)` with no `.catch()`,
an unhandled rejection, so `setDecrypting(false)` was never reached and
the page was stuck on "Decrypting..." until reloaded. Switching to a
third environment before the second's key derivation resolved could also
let it land after the third's, pairing envKeys with the wrong data even
once the promise settled.

The GetSecrets query above also polls every 5s, so keying the key
derivation off `data` directly would re-derive and clear envKeys on every
idle poll tick, not just on real environment switches. Track which
environment's wrapped seed the current envKeys were derived from instead,
so an unrelated poll refresh of the same environment is a no-op.

The decrypting effect independently checks envKeys against that same
tracked seed before running, rather than assuming the deriving effect's
state update is visible to it in the same pass, since the two effects'
execution order relative to a state update from one of them is not
something to build correctness on. It also gets the missing `.catch()`.

Fixes the page getting stuck on "Decrypting..." after switching
environments, and the narrower case of a secret from one environment
being decrypted with another environment's keys during a fast multi-hop
switch.
…hrow

Hoisting the wrappedSeed read out of the async function to compare it
against the ref changed what an empty environmentKeys array does: it was
a rejected promise from inside the async fn, and became a synchronous
throw from the effect body, which unmounts the page via the error
boundary. Read it with optional chaining and bail instead, so this fix
does not alter that failure mode as a side effect.
Switching A -> B and back to A before B's derivation resolved left the ref
holding A's seed while envKeys was null, so the effect early-returned on the
seed match and never re-derived. Also trims the comments in this file and the
test suite.
… page

The decrypt pass ran every secret through a single Promise.all, so one row
with unreadable ciphertext rejected the whole batch and no user of that
environment ever got past the skeleton loaders. Use Promise.allSettled, render
the rows that decrypted, log each failure and surface a count.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One undecryptable secret blanks the entire environment page for all users (permanent skeleton loaders)

1 participant