Skip to content

Refactor MultifactorAuthenticationContextProvider to XState state machine - #98137

Draft
jakubstec wants to merge 249 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-81197-mfa-state-machine
Draft

jakubstec wants to merge 249 commits into
Expensify:mainfrom
software-mansion-labs:dariusz-81197-mfa-state-machine

Conversation

@jakubstec

@jakubstec jakubstec commented Aug 10, 2026

Copy link
Copy Markdown
Member

Explanation of Change

This PR tracks the MFA flow to XState migration (#81197) and merges the finished dariusz-81197-mfa-state-machine integration branch into main. Right now, it is a progress tracker, not a reviewable diff: the branch lands as a stack of vertical slices, each reviewed and merged into the integration branch on its own, and nothing here reaches main until the whole stack is done.

# PR Slice Status
1 #93055 Remove the legacy flow engine, scaffold executeScenario to the success screen Merged
2 #93180 XState foundation; machine owns the modal lifecycle Merged
3 #93807 XState inspector dev tooling, shared sensitive-key denylist Merged
4 #345 Test foundation: reachability + real-UI-walk harness Merged
5 #346 Device check + failure outcome Merged
6 #352 Soft prompt step Merged
7 #355 Validate code + registration decision Merged
8 #356 Credential creation + backend registration Merged
9 #360 Authorization + legacy-state cleanup In review

All merged slices target dariusz-81197-mfa-state-machine on the software-mansion-labs/expensify-app-fork remote, not main — that's why most of them aren't visible as commits on this PR's branch comparison view directly against Expensify/App history until the final merge.

Why this migration

MultifactorAuthenticationContextProvider used to drive the whole MFA flow (device check, registration, soft prompt, authorization) from a single useEffect that watched state and ran an async process() step function, with react-hooks/exhaustive-deps disabled to keep it from re-entering mid-step. That pattern works only as long as two invariants hold — the effect's dependencies never change outside step boundaries, and every step dispatches its state update only after all its async work finishes — and both are easy to violate silently. We hit exactly that: an optimistic update on key registration re-entered process() early and raced the authorization challenge request against a backend write that hadn't landed yet. Full background, the two approaches considered, and the acceptance criteria are in #81197.

This migration replaces that implicit step-machine with an explicit XState machine: every state, transition and side effect is declared, so a race is a missing/incorrect transition instead of a dependency-array footgun.

Current state of the flow

With every merged slice plus the slice in review, the machine drives the full happy path:

device check
    ├─ unsupported device / disallowed verification type → failure outcome
    └─ supported
           → registration decision
                 ├─ known credential + accepted soft prompt
                 │      → authorize
                 ├─ known credential + soft prompt required
                 │      → soft prompt → authorize
                 └─ registration required
                        → validate code
                        → request registration challenge
                        → soft prompt
                        → create platform credential
                        → register credential with backend
                        → authorize

authorize
    → request authorization challenge
    → run platform ceremony (passkey on web, HSM-backed biometrics/device credential on native)
    → invoke scenario-specific action with the signed challenge
    → outcome

Every step above dispatches through the machine's own transitions; the legacy reducer, its split contexts, and the composed provider that used to own this state no longer exist (removed in slice #9). Failures at any step (challenge request, platform refusal, malformed WebAuthn response, signing failure, backend rejection, scenario-action rejection, unexpected exception) resolve to the failure outcome with the specific MFA error, and an authorization failure caused by a stale/unusable local credential clears it so the next flow attempt re-registers instead of repeating the same failure.

What's left

  • Recovery: automatically re-registering after a recoverable authorization failure instead of ending at the generic failure outcome (currently the exact failure reason is preserved but not acted on — see slice Adds ESlint to webpack so files are linted as they are saved #9's "Authorization routing" table).
  • Complete cancel / back-press confirmation handling.
  • Scenario callbacks and custom outcome screens (today every flow ends on the shared success/failure outcome).
  • Dedicated offline retry behavior (today the flow is blocked by the existing full-page offline view, with no automatic retry on reconnect).
  • Final Sentry outcome logging.
  • Merging this PR once the stack above is fully in.

Fixed Issues

$ #81197

Tests

No independent test steps here — this PR is a tracker, not a diff to review. See each slice PR's own Tests / QA Steps sections for how that piece of the flow was verified; the manual test steps in #360 currently exercise the full happy path (fresh registration, returning-user authorization, refusal, cancellation, re-registration after an unusable credential) end to end.

QA Steps

[No QA] - tracking PR for an internal migration. Nothing here reaches staging/production until the stack is fully merged into main, at which point this description will be replaced with real QA steps for the complete flow.

dariusz-biela and others added 30 commits June 9, 2026 13:39
…s screen

Remove the process()/handleCallback()/driver-useEffect orchestration engine plus its exhaustive-deps eslint-disable, ahead of the XState migration (Expensify#81197).

With the engine gone the flow is inert, so executeScenario now opens the modal and navigates straight to the success outcome screen (SET_FLOW_COMPLETE so back-press/backdrop close directly) to keep the modal and outcome UI testable until the state machine lands.
Add xstate@5.31.1 + @xstate/react@5.0.5 and a machine whose top-level
states map 1:1 to the visible screen (idle -> preparing -> outcome ->
closing); INIT short-circuits to outcome.success in this slice.

Teardown owns its own state: CLOSE_MODAL (guarded on isModalOpen)
enters closing, the navigator reports MODAL_CLOSED, and entering idle
wipes the context plus the mfaNavigation buffer. A machine-owned
closeFallback delay re-enters idle even if the navigator unmounts
mid-close and cancels its transition callback, so flow data (validate
code, challenges, scenario response) cannot outlive the modal.

The Provider becomes a thin typed facade over send() with no flow
logic; OutcomeScreenBase's close button and the modal navigator wire
into it, and the RESET event/reset() API is gone (the navigator was
its only consumer).

Refs Expensify#81197
isModalOpen was context data duplicating what the chart already knows.
A parent `open` compound state now hosts the screen states; CLOSE_MODAL
is declared on it, so the isModalOpen guard and the closeModal action
are gone and duplicate events while idle or closing are ignored by the
chart structure itself. Shared flow events of later slices (SET_ERROR,
...) get a natural home on `open`.

The machine context drops the flag (Omit) and a new snapshotToState
mapper restores the legacy shape for consumers, deriving isModalOpen as
matches('open') - false in `closing`, which is what starts the
navigator's close animation. The cancel-confirm dialog is still hidden
on CLOSE_MODAL so it cannot stay up during the slide-out.

Refs Expensify#81197
The navigator's local Phase useState + render-time sync duplicated the
machine's top level (open/closing/idle). snapshotToState now derives
modalPhase from the chart and the navigator mounts, animates teardown,
and unmounts off that single value; the close callback only reports
MODAL_CLOSED. A cancelled close report can no longer strand the
navigator in 'closing': the machine's closeFallback re-enters idle,
which now also unmounts the view.

Refs Expensify#81197
The backdrop faded out over ANIMATED_TRANSITION (300ms) while the
screen's slide-out - the close spec of SLIDE_FROM_RIGHT - takes 100ms
on web. The navigator unmounts on transitionEnd, so the fade was ripped
out at a third of its run. Fade with the slide's own duration and
easing instead, the way the RHP overlay rides the card progress.

Refs Expensify#81197
Comment-only review pass over the machine slices:

- the Provider API doc claimed every method is a thin send() wrapper
  and that state is the bare legacy shape; executeScenario's telemetry
  and the modalPhase field made both claims false
- MfaMachineContext doc still said snapshotToState returns the "full
  legacy shape" (stale since modalPhase landed)
- the machine header repeated the teardown contract and the modalPhase
  derivation that live next to the closing state and in snapshotToState;
  it also referenced the removed engine's process()+useEffect race,
  meaningless to a post-merge reader
- dropped history notes ("Relocated here (PR-3)", "the old
  handleCallback had"), a caller list on closeModal, and the RHP-overlay
  analogy in the backdrop fade comment

Refs Expensify#81197
Follow-up review pass on mfaMachine.ts:

- the header shrank to the structure convention (top level = modal
  lifecycle, open children = visible screens) and the no-final-state
  rationale; per-state bullets restated the chart below
- the wipe rationale moved onto idle's entry, the dropped-INIT note
  onto idle's INIT transition, and the deferred-push note inside
  navigateToSuccessOutcome next to runAfterTransition
- closeFallback now just says what the delay is for; the closing
  comment spells out that the navigator unmounting mid-close means
  MODAL_CLOSED never comes
- dropped the hideCancelConfirm comment, which described nothing the
  assign showed

Refs Expensify#81197
React Compiler now compiles this file, so manual useCallback/useMemo
wrappers are redundant. Addresses review feedback from roryabraham
on Expensify#93055.
…e' into dariusz-biela/refactor/3ds/mfa-state-machine-setup

# Conflicts:
#	src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx
…-biela/refactor/3ds/mfa-cleanup-for-state-machine

[NO QA] refactor(mfa): remove flow engine, scaffold executeScenario to success screen
The machine Provider exposed a single context bundling executeScenario
with flow-internal controls, and the hook lived in the Provider module.
Any config-reachable screen importing it closed an import cycle
(config/scenarios -> screens -> Context -> config): OutcomeScreenBase did
exactly that through the Context barrel, crashing every jest entry that
loads a scenario module first (sortTransactionsPending3DSReview.test.ts).

Split the API into two runtime-leaf contexts; react is their only runtime
import, so they are safe to consume from anywhere:
- external (useMultifactorAuthentication, barrel-exported): executeScenario
  only. App code starting flows keeps its current import and stops
  re-rendering on machine transitions.
- internal (useMultifactorAuthenticationInternal, imported from the leaf
  directly, deliberately not in the barrel): machine state + modal
  lifecycle for the screens and navigator hosting a flow.

Also route ChangePIN's state hook through its existing leaf instead of the
barrel, which closed the same cycle via the barrel's usePromptContent edge.
`{} as T` inside setup({types}) is XState v5's documented typing idiom; the
values are erased at runtime and only carry types, so there is no
assertion-free way to express it.
…essor

The per-scenario action signatures make the config record's value union
non-narrowable, so every lookup needed its own unsafe assertion. Move it
into a single getScenarioConfig() accessor in the config module and use
it from both the provider and the reducer; this also removes the
grandfathered seatbelt entry for stateReducer.ts. Rename the local
Configs record to match its public SCREAMING_SNAKE export name.
Knip compares exports against main, so symbols exported for future
slices fail CI now. Trim to what is consumed today (mfaMachine,
snapshotToState, MfaState); re-add the rest in the slices that use
them. mfaMachine becomes a default export to satisfy
import/prefer-default-export once DEFAULT_CONTEXT is module-local.
PR/slice numbers (PR-5, PR-11, Slice 1) reference the implementation
plan, which reviewers have no access to, and they rot as the stack
evolves. Rephrase as "a later slice adds X".
Drop the stored isModalOpen flag: the machine already models the modal
lifecycle, so useSyncMfaModalNavigatorWithHistory now takes modalPhase
and derives the boolean locally. Also inline the now-identity
MfaMachineContext alias.
Dev-only Stately Browser Inspector for XState machines, opened on demand
from Troubleshoot > XState inspector in the test tool menu.

- sanitizeContext/sanitizeEvent mask every subtree under a sensitive key
  (payload, pin, password, token, otp, secret, validateCode) at any depth
  before events leave the app to stately.ai
- useInspectedMachine hook pre-wires the inspector so new machines adopt
  it with one line; MFA main context switched to it
- guarded require keeps @statelyai/inspect out of production bundles;
  native/Jest resolve a stub via index.native.ts
- unit tests cover the masking contract
Future MFA machine slices store the scenario response in context and run
registration as an invoked actor, so inspection events will carry the
REVEAL_CARD_DETAILS body (pan/expiration/cvv) and the HSM/passkey keyInfo
(whose clientDataJSON embeds the challenge). Mask the body wholesale -
keeping httpStatusCode/reason/message visible for debugging - and add
pan/cvv as standalone keys in case the body is ever destructured into an
event.
useMachine creates its actor during render and XState announces actors
to inspectors in the Actor constructor, so a render pass React discards
and redoes (concurrent restart, Strict Mode, machine hot reload) leaves
a ghost actor in the Stately UI - a duplicate machine forever stuck in
its initial state with no events.

Hold each @xstate.actor registration until that session emits its first
other inspection event (the actor actually started) and forward it then,
still ahead of the triggering event. Registrations that never activate
are never forwarded, so ghosts no longer reach the inspector.
Make getScenarioConfig generic so each scenario's action signature
survives the lookup, dropping the no-unsafe-type-assertion suppression
and the as cast it guarded.

- Add scenarios/types.ts holding the per-scenario ScenarioPayloadMap,
  guarded against drift from the scenario union by AssertTypesEqual
  (same idiom as the Onyx keys check).
- Name the lookup result MultifactorAuthenticationScenarioConfigFor<T>
  and the dynamic union MultifactorAuthenticationResolvedScenarioConfig,
  both defined next to the config const to keep deps one-way.
- Move the INIT machine event to machine/types.ts and correlate its
  scenarioName, scenario, and payload through T.
- Require executeScenario params only for scenarios that need them via
  a conditional args tuple.
Add MFA_STATE (machine node IDs) and MODAL_PHASE (view-layer phase) under
CONST.MULTIFACTOR_AUTHENTICATION and reference them from the machine,
snapshotToState, and the MfaModalPhase type instead of bare string literals.
De-duplicates the literals and guards reference typos.

Note: this does not make a wrong machine 'initial'/'target' a compile error -
XState v5 does not type-check those against the real node IDs. 'matches()' is
already type-checked, so snapshotToState stays guarded.
The masker keys off field names, so a state node named like a sensitive
key (validateCode, pin, ...) had its state value masked - which hides the
machine's current state and breaks the inspector's active-state
highlight.

Exempt snapshot.value from key-based masking via a maskByKey flag while
keeping its structural safety (cycle, depth, postMessage). The exemption
is scoped to that one path; a value key in context, events or child
input/output is still masked.
Replace the 5-parameter `maskDeep` recursion - called with cryptic
literals like `maskDeep(event, false, 0, new WeakSet(), true)` - with a
`serialize(value, maskSensitiveKeys)` core that closure-captures the
visited set and runs an inner `walk()`. Two named wrappers, `maskEvent`
and `serializeStateValue`, name the two modes so the bare boolean no
longer appears at any call site.

Trim the explanatory comments from ~30 to ~17 lines now that the names
carry the intent, and drop the serialize-vs-sanitize-hooks prose already
documented in index.ts. Behavior is unchanged; the unit suite passes
untouched.
open/closing already matched the machine's top-level state names; only
the resting state diverged. Rename the machine state idle -> closed so
all three top-level states share the view's vocabulary, delete the
now-redundant MODAL_PHASE const, and source MfaModalState from MFA_STATE.

Renames MfaModalPhase -> MfaModalState and modalPhase -> modalState; the
navigator and history hook compare against
CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE.
Address review feedback to contextualize each machine action, preferring
self-documenting names over comments:

- initFromEvent -> initFlow
- hideCancelConfirm -> hideCancelConfirmModal (also avoids the clash with
  the same-named context method)
- resetNavigationBuffer -> clearModalOpenNavigationState

Add comments only where the name cannot carry a non-obvious detail:
initFlow (why the non-INIT type guard exists) and hideCancelConfirmModal
(why it runs on CLOSE_MODAL).
import Button from './Button';
import TestToolRow from './TestToolRow';

type XStateInspectorTestToolRowProps = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-13 (docs)

The new XStateInspectorTestToolRowProps type declares a prop (ready) with no /** ... */ block comment above it, and none of its props are documented. Per STYLE.md, every component prop must be documented with a JSDoc block comment at its definition site so its purpose is clear.

Add a block comment above the prop:

type XStateInspectorTestToolRowProps = {
    /** Resolves to the loaded Stately inspector handle, or null if the inspector chunk failed to load */
    ready: Promise<LoadedInspector | null>;
};

Reviewed at: 50b8e1e | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50b8e1ef51

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

initial: MFA_STATE.RESOLVING_OUTCOME,
states: {
[MFA_STATE.RESOLVING_OUTCOME]: {
always: [{guard: 'hasError', target: MFA_STATE.FAILURE}, {target: MFA_STATE.SUCCESS}],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run MFA authorization before showing success

Reaching OUTCOME with no error now immediately falls through to the success screen, but the new machine has no happy-path state that requests an authorization challenge, invokes the biometric/passkey authorization, calls scenario.action, or runs the scenario callback. Both the already-accepted-soft-prompt path and the prompt-approved path target OUTCOME directly, so a user with existing credentials (or one who just enters the validate code) can see success for flows like 3DS authorization or PIN changes without the backend operation ever happening.

Useful? React with 👍 / 👎.

const notifyValidateCodeChanged = () => send({type: 'VALIDATE_CODE_CHANGED'});

// There is no cancel-confirmation dialog yet, so every cancel path closes the modal directly.
const requestCancel = () => send({type: 'CLOSE_MODAL'});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route cancels through scenario onCancel

For an active online flow this now closes the MFA modal directly, and confirmCancel does the same while isCancelConfirmVisible is never set. Scenarios such as AUTHORIZE_TRANSACTION provide a cancel-confirm modal and an onCancel handler that calls denyTransaction; pressing Back/Escape/the backdrop now bypasses that handler, so the transaction is not denied and no cancel outcome is shown.

Useful? React with 👍 / 👎.

@JmillsExpensify JmillsExpensify left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Product review not required.

@mountiny

mountiny commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@jakubstec seems like this branch has a huge diff, can you please think of ways to split this up? also can you link the relevant issue please?

@jakubstec

Copy link
Copy Markdown
Member Author

hey @mountiny, this PR is currently just a draft to give a high-level overview of the refactor's current state. we are actually already splitting the work into smaller slices using our local branch as a base. There is:

here is link to the issue #81197 😄

@mountiny

Copy link
Copy Markdown
Contributor

Oh ok got it, thanks

@amyevans
amyevans removed their request for review August 17, 2026 11:04
jakubstec and others added 11 commits August 19, 2026 13:35
Seven sites built the same canceled failure result inline. The factory's return
type is declared explicitly: an inferred `success: false` widens to `boolean`,
which stops fitting MFAResult's failure branch.
…hots

The exemption fired on any `{snapshot: {value}}` shape, so actor logic keeping
runtime data under its own `value` had that subtree serialized unmasked at every
depth. xstate's `isMachineSnapshot` narrows it to snapshots whose `value` really
holds state-node names.
Addressing the findings from self-review.

Distinguish a missing HSM credential from an unreadable keystore

`getLocalCredentialID()` collapsed three outcomes into `string | undefined`: a
credential that was found, one the keystore confirmed does not exist, and a read
the keystore failed to answer at all. During authorization the last two both
became `NO_MATCHING_LOCAL_CREDENTIAL`, which is treated as recoverable and
deletes the account's key. A transient keystore failure therefore took the same
destructive path as a genuinely absent credential, forcing a re-registration the
device did not need.

The error was already being computed - `decodeLibraryError()` ran for the
breadcrumb and its result was then discarded - so the fix carries it instead of
dropping it. `getLocalCredential()` now returns a discriminated
`LocalCredentialLookup` of `found` / `absent` / `readFailed`, and `authorize()`
maps them separately:

  - `readFailed`  -> the decoded error, credential left untouched
  - `absent`      -> `HSM.KEY_NOT_FOUND`, credential deletable
  - mismatch      -> `HSM.NO_MATCHING_LOCAL_CREDENTIAL`, credential deletable

The library rejects with `KEY_NOT_FOUND` rather than returning an empty key list
when the alias does not exist, so that specific rejection is mapped to `absent`.
Treating every rejection as a read failure would have stopped routing a
first-time device into registration.

`KEY_ACCESS_FAILED` is removed from `CREDENTIAL_FAILURES_REQUIRING_LOCAL_DELETION`
for the same reason, and consequently from `RECOVERABLE_CREDENTIAL_FAILURES`,
which is derived from it: a keystore that could not be read has not proven the
credential unusable, so it must not trigger deletion or re-registration.

`areLocalCredentialsKnownToServer()` still answers `false` on a read failure and
still routes to registration. It returns a plain boolean and has no way to fail
the flow instead; that belongs with the recovery slice and is called out in a
comment at the call site.

Stop the web ceremony from mutating local credentials

`authorize()` on web called `reconcileLocalPasskeysWithBackend()`, which writes
the pruned credential list back to Onyx whenever it differs from what it read.
On a full miss it persisted an empty list, so the ceremony cleared the
credentials before the actor could decide - contradicting its own doc comment -
and did so without consulting the abort signal, leaving a hole in the
cancellation-aware cleanup the actor is careful about.

The ceremony now filters against the challenge's `allowCredentials` in place and
persists nothing. `createCredential()` keeps calling `reconcileLocalPasskeysWithBackend()`,
so stale local entries are still pruned on the registration path. The trade-off
is that a partial mismatch is no longer pruned during authorization; those
entries are inert, since every ceremony filters them out anyway.

Convert an aborted Onyx read into a canceled result

The local-passkey read in `authorize()` rejects with the signal's reason when the
flow is cancelled mid-read. Nothing caught it, so that one path left the actor as
a rejected promise and reached the machine as `UNHANDLED_EXCEPTION`, an anomalous
failure, while every other cancellation point returns `createCanceledMFAResult()`.
It is now caught and converted, and genuine read errors are rethrown unchanged.

Make the web credential deletion actually await its write

`deleteLocalPasskeyCredentials()` returned `void` while performing an `Onyx.set()`,
so `deleteLocalCredentials()` on web resolved before the write landed even though
its native twin serializes properly. It now returns the write's promise.

Pair the scenario action with its own payload

`processScenarioAction()` took a widened action plus the union of every scenario's
parameters and cast between them, so a mismatched (action, payload) pair
type-checked and would only fail at the API. The pairing is now captured at INIT,
where the scenario generic is still known: `createScenarioActionRunner()` binds
the action to its payload and the machine stores the resulting `runScenarioAction`
in context. The actor supplies only the values the ceremony owns - the signed
challenge and the authentication method - so caller-provided values still cannot
replace them, and the unsafe cast is gone.

Drop the unreachable signed-challenge guard

`processScenarioAction()` opened with a `!signedChallenge` check returning
`SIGNATURE_MISSING`. The field was already required on both sides of the call -
`MultifactorAuthenticationActionParams` picks it from
`AllMultifactorAuthenticationBaseParameters`, where it is declared non-optional -
so the branch was already unreachable before this slice. Its only test proved it:
reaching the branch required `'' as unknown as Parameters<...>[1]`.

Binding the scenario runner moved the challenge out of the params bag and into an
explicit parameter, which made that deadness visible: `signedChallenge` was then
the function's only unused input, present solely to feed the guard. Guard,
parameter, and the double-cast test are removed, along with the imports they were
the last users of. The backend validates the signature regardless, so nothing
enforced here is lost.

`SIGNATURE_MISSING` is intentionally left in `VALUES` and in `ANOMALOUS_FAILURES`.
It predates this work and reason codes are a telemetry vocabulary shared beyond
this repository, so retiring one is a wider decision than this cleanup.

Keep the prompt content stable through the close animation

`CLOSE_MODAL` ran `resetPresentationPhases`, clearing the presentation phase while
the `closing` state still renders the outgoing screen. Cancelling during
authorization therefore swapped the spinner back for the Confirm button for the
duration of the close animation - the exact flicker the phase exists to prevent -
and contradicted the invariant documented on `closing`. The action is dropped;
`closed` already runs `resetContext`, so no phase survives into the next flow.

Record two non-obvious invariants that were previously undocumented

  - The credential cleanup in the authorization actor is awaited on purpose. The
    deletion checks the abort signal inside its serialized queue, so returning
    first would let the actor stop and abort that signal before the check runs,
    silently skipping the cleanup.
  - The HSM mutation queue deliberately covers only `createKeys`/`deleteKeys`. The
    authorization ceremony neither creates nor destroys the key, and queueing it
    would hold the biometric prompt behind a keystore write without changing its
    outcome.

Tests

  - authorization reports the decoded read error, and that reason is not in the
    deletion set, when the keystore cannot be read
  - authorization reports `KEY_NOT_FOUND`, which is in the deletion set, when the
    keystore confirms the account has no key
  - a `KEY_NOT_FOUND` rejection still keeps a first-time device on the registration
    path
  - the actor leaves the credential in place after a read failure
  - the actor stays active until the cleanup resolves, pinning the awaited deletion
  - web authorization persists nothing on a reconciliation miss
  - a cancelled local-credential read produces a canceled result rather than an
    unhandled exception
  - the scenario runner is bound at INIT and receives the ceremony-owned values
…auth

[No QA] feat(mfa): add auth to the state machine
@melvin-bot

melvin-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx bun ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx bun ./scripts/generateTranslations.ts --compare-ref main

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.

7 participants