From efb662558639e04dc1d11c114b531dd5b3586fbc Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Fri, 14 Aug 2026 15:41:22 +0200 Subject: [PATCH] feat(platform): give AuthState::LoginFailed a typed kind --- .../Sources/TrUAPIHost/truapi_platform.swift | 87 ++++++++++++++++++- playground/tests/e2e/dotli-diagnosis.ts | 32 +++++-- .../tests/golden/host-callbacks.ts | 21 ++++- rust/crates/truapi-host-cli/src/platform.rs | 2 +- rust/crates/truapi-platform/src/lib.rs | 17 ++++ rust/crates/truapi-server/src/runtime.rs | 1 + .../truapi-server/src/runtime/auth_state.rs | 18 +++- .../src/runtime/login_failure.rs | 78 +++++++++++++++++ .../truapi-server/src/runtime/sso_pairing.rs | 8 +- 9 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/login_failure.rs diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index d15683af4..aa2e9f9f9 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift @@ -1370,6 +1370,10 @@ public enum AuthState: Equatable, Hashable { * The last login attempt failed; show the reason and offer a retry. */ case loginFailed( + /** + * What kind of failure this was. Hosts branch on this and treat + * `reason` as display copy only. + */kind: LoginFailureKind, /** * Human-readable failure reason. */reason: String @@ -1409,7 +1413,7 @@ public struct FfiConverterTypeAuthState: FfiConverterRustBuffer { case 3: return .connected(try FfiConverterTypeSessionUiInfo.read(from: &buf) ) - case 4: return .loginFailed(reason: try FfiConverterString.read(from: &buf) + case 4: return .loginFailed(kind: try FfiConverterTypeLoginFailureKind.read(from: &buf), reason: try FfiConverterString.read(from: &buf) ) case 5: return .authenticating @@ -1436,8 +1440,9 @@ public struct FfiConverterTypeAuthState: FfiConverterRustBuffer { FfiConverterTypeSessionUiInfo.write(v1, into: &buf) - case let .loginFailed(reason): + case let .loginFailed(kind,reason): writeInt(&buf, Int32(4)) + FfiConverterTypeLoginFailureKind.write(kind, into: &buf) FfiConverterString.write(reason, into: &buf) @@ -1546,6 +1551,84 @@ public func FfiConverterTypeCreateTransactionReview_lower(_ value: CreateTransac +/** + * Why a login attempt failed, for hosts that need to act on the cause rather + * than only display it. + */ + +public enum LoginFailureKind: Equatable, Hashable { + + /** + * The wallet has no free statement-store allowance slot for this period, + * so it cannot register the device. Deterministic until the period rolls + * over: retrying wastes the user's remaining budget. + */ + case noFreeAllowanceSlots + /** + * Anything else. `reason` carries the detail. + */ + case other + + + + + +} + +#if compiler(>=6) +extension LoginFailureKind: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeLoginFailureKind: FfiConverterRustBuffer { + typealias SwiftType = LoginFailureKind + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> LoginFailureKind { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .noFreeAllowanceSlots + + case 2: return .other + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: LoginFailureKind, into buf: inout [UInt8]) { + switch value { + + + case .noFreeAllowanceSlots: + writeInt(&buf, Int32(1)) + + + case .other: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLoginFailureKind_lift(_ buf: RustBuffer) throws -> LoginFailureKind { + return try FfiConverterTypeLoginFailureKind.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeLoginFailureKind_lower(_ value: LoginFailureKind) -> RustBuffer { + return FfiConverterTypeLoginFailureKind.lower(value) +} + + + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts index 902bb494d..c310bc128 100644 --- a/playground/tests/e2e/dotli-diagnosis.ts +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -319,9 +319,9 @@ async function waitForSignedIn( signingHost: SigningHostCliProcess, ): Promise { try { - const existingFailure = await latestLoginFailureReason(page); + const existingFailure = await latestLoginFailure(page); if (existingFailure !== null) { - throw new Error(`Login failed: ${existingFailure}`); + throw new Error(formatLoginFailure(existingFailure)); } const outcome = await Promise.race([ page @@ -334,14 +334,18 @@ async function waitForSignedIn( const listener = (event: Event): void => { const state = ( event as CustomEvent< - { tag?: string; reason?: string } | undefined + { tag?: string; kind?: string; reason?: string } | undefined > ).detail; if (state?.tag !== "LoginFailed") { return; } window.removeEventListener("dotli:truapi-auth-state", listener); - reject(new Error(`Login failed: ${state.reason ?? "unknown"}`)); + reject( + new Error( + `Login failed (${state.kind ?? "Other"}): ${state.reason ?? "unknown"}`, + ), + ); }; window.addEventListener("dotli:truapi-auth-state", listener); }), @@ -370,15 +374,29 @@ async function waitForSignedIn( } } -async function latestLoginFailureReason(page: Page): Promise { +/** A login failure the host reported, with the core's typed cause. */ +interface LoginFailure { + kind: string; + reason: string; +} + +/** Name the cause first: `NoFreeAllowanceSlots` cannot succeed on a retry. */ +function formatLoginFailure(failure: LoginFailure): string { + return `Login failed (${failure.kind}): ${failure.reason}`; +} + +async function latestLoginFailure(page: Page): Promise { return await page.evaluate(() => { const states = window.__dotliE2eAuthStates ?? []; for (let i = states.length - 1; i >= 0; i--) { const candidate = states[i] as { - detail?: { tag?: string; reason?: string }; + detail?: { tag?: string; kind?: string; reason?: string }; }; if (candidate.detail?.tag === "LoginFailed") { - return candidate.detail.reason ?? "unknown"; + return { + kind: candidate.detail.kind ?? "Other", + reason: candidate.detail.reason ?? "unknown", + }; } } return null; diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index c52bfd9e2..4f534ce88 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -100,7 +100,7 @@ export type AuthState = /** * The last login attempt failed; show the reason and offer a retry. */ - | { tag: "LoginFailed"; value: { reason: string } } + | { tag: "LoginFailed"; value: { kind: LoginFailureKind; reason: string } } /** * The wallet accepted the pairing request and the core is resolving and * persisting the session. Hosts should replace the pairing QR with an @@ -236,6 +236,12 @@ export interface IdentityDisclosureReview { productId: string; } +/** + * Why a login attempt failed, for hosts that need to act on the cause rather + * than only display it. + */ +export type LoginFailureKind = "NoFreeAllowanceSlots" | "Other"; + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -491,7 +497,10 @@ export const AuthState: S.Codec = S.lazy( Disconnected: S._void, Pairing: S.Struct({ deeplink: S.str }) as S.Codec<{ deeplink: string }>, Connected: SessionUiInfo, - LoginFailed: S.Struct({ reason: S.str }) as S.Codec<{ reason: string }>, + LoginFailed: S.Struct({ + kind: LoginFailureKind, + reason: S.str, + }) as S.Codec<{ kind: LoginFailureKind; reason: string }>, Authenticating: S._void, }), ); @@ -586,6 +595,14 @@ export const IdentityDisclosureReview: S.Codec = S.Struct({ productId: S.str }) as S.Codec, ); +/** + * Why a login attempt failed, for hosts that need to act on the cause rather + * than only display it. + */ +export const LoginFailureKind: S.Codec = S.lazy( + (): S.Codec => S.Status("NoFreeAllowanceSlots", "Other"), +); + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 121ac8c52..402209b0f 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -648,7 +648,7 @@ impl truapi_platform::AuthPresenter for CliPlatform { AuthState::Disconnected => { ("disconnected".to_string(), SystemEvent::PairingDisconnected) } - AuthState::LoginFailed { reason } => ( + AuthState::LoginFailed { reason, .. } => ( "failed".to_string(), SystemEvent::PairingFailed { reason: reason.clone(), diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 168b74632..d084e54c2 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1052,6 +1052,9 @@ pub enum AuthState { Connected(SessionUiInfo), /// The last login attempt failed; show the reason and offer a retry. LoginFailed { + /// What kind of failure this was. Hosts branch on this and treat + /// `reason` as display copy only. + kind: LoginFailureKind, /// Human-readable failure reason. reason: String, }, @@ -1061,6 +1064,20 @@ pub enum AuthState { Authenticating, } +/// Why a login attempt failed, for hosts that need to act on the cause rather +/// than only display it. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum LoginFailureKind { + /// The wallet has no free statement-store allowance slot for this period, + /// so it cannot register the device. Deterministic until the period rolls + /// over: retrying wastes the user's remaining budget. + NoFreeAllowanceSlots, + /// Anything else. `reason` carries the detail. + #[default] + Other, +} + /// Host auth UI driven by core-owned [`AuthState`] transitions. pub trait AuthPresenter: Send + Sync { /// Observe an auth state change. Emitted only when the state actually diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 8b983d619..3635d72dc 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -15,6 +15,7 @@ mod authority; pub(crate) mod bulletin_rpc; mod chat; mod identity; +pub(crate) mod login_failure; mod pairing_host; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs index e79133160..2cbb0e4a4 100644 --- a/rust/crates/truapi-server/src/runtime/auth_state.rs +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -5,7 +5,9 @@ use std::sync::{Arc, Mutex}; use futures::channel::oneshot; -use truapi_platform::{AuthPresenter, AuthState, Platform, SessionUiInfo}; +use truapi_platform::{AuthPresenter, AuthState, LoginFailureKind, Platform, SessionUiInfo}; + +use crate::runtime::login_failure::classify_login_failure; /// Serialized auth-state machine bound to the platform's `auth_state_changed` /// sink. Each transition mutates under the lock, releases it, then emits the @@ -71,6 +73,8 @@ impl AuthStateMachine { } /// Active login -> `LoginFailed`: the in-flight login reported a failure. + /// The kind is recovered from `reason`, which is the only form the wallet + /// reports a refusal in. pub(super) fn login_failed(&self, reason: String) { self.transition(|inner| { if !matches!( @@ -80,7 +84,10 @@ impl AuthStateMachine { return None; } inner.cancel_tx = None; - inner.state = AuthState::LoginFailed { reason }; + inner.state = AuthState::LoginFailed { + kind: classify_login_failure(&reason), + reason, + }; Some(()) }); } @@ -97,7 +104,12 @@ impl AuthStateMachine { ) { return None; } - inner.state = AuthState::LoginFailed { reason }; + // Pre-pairing failures are the pairing host's own (device identity, + // bootstrap); allowance exhaustion is only ever wallet-reported. + inner.state = AuthState::LoginFailed { + kind: LoginFailureKind::Other, + reason, + }; Some(()) }); } diff --git a/rust/crates/truapi-server/src/runtime/login_failure.rs b/rust/crates/truapi-server/src/runtime/login_failure.rs new file mode 100644 index 000000000..b50408ac7 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/login_failure.rs @@ -0,0 +1,78 @@ +//! Classification of login failures into [`LoginFailureKind`]. +//! +//! A wallet reports why it refused pairing as prose, over +//! `EncryptedResponse::Failed` on the inter-host wire, so the core recovers the +//! discriminant here instead of leaving every host to pattern-match the text. +//! The wording the wallet sends originates from this workspace's own +//! `SlotError` `Display` impls, and the tests below pin the classifier to them: +//! rewording one fails here rather than silently turning a host's fast-fail +//! back into a retry loop. + +use truapi_platform::LoginFailureKind; + +/// Markers that identify an exhausted statement-store allowance period. Every +/// `SlotError` variant that means "no slot is available to take" renders one of +/// these. +const NO_FREE_SLOT_MARKERS: &[&str] = &["no free statementstore slot", "no free long-term-storage"]; + +/// Recover the failure kind from a wallet-reported reason. +pub(crate) fn classify_login_failure(reason: &str) -> LoginFailureKind { + let reason = reason.to_ascii_lowercase(); + if NO_FREE_SLOT_MARKERS + .iter() + .any(|marker| reason.contains(marker)) + { + return LoginFailureKind::NoFreeAllowanceSlots; + } + LoginFailureKind::Other +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::statement_allowance::slot::SlotError; + + #[test] + fn exhausted_allowance_periods_are_recognized_from_their_own_display_text() { + for error in [ + SlotError::NoFreeStatementStoreSlot { period: 7, max: 8 }, + SlotError::NoFreeLongTermStorageSlot { period: 7, max: 8 }, + ] { + assert_eq!( + classify_login_failure(&error.to_string()), + LoginFailureKind::NoFreeAllowanceSlots, + "`{error}` must classify as an exhausted allowance period" + ); + } + } + + #[test] + fn other_slot_failures_are_not_reported_as_exhausted_periods() { + for error in [ + SlotError::LongTermStoragePeriodDurationZero, + SlotError::ReplacementRefused { period: 7, seq: 3 }, + SlotError::FreeSlotsAwaitingSubmission { period: 7 }, + ] { + assert_eq!( + classify_login_failure(&error.to_string()), + LoginFailureKind::Other, + "`{error}` is not an exhausted allowance period" + ); + } + } + + #[test] + fn unrelated_reasons_are_other() { + for reason in [ + "", + "user rejected pairing", + "pairing statement-store subscribe failed: timeout", + ] { + assert_eq!( + classify_login_failure(reason), + LoginFailureKind::Other, + "`{reason}` is not an exhausted allowance period" + ); + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index ad08608d5..70d00f3d1 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -935,7 +935,7 @@ mod tests { assert!( auth_states .iter() - .any(|state| matches!(state, AuthState::LoginFailed { reason } if reason == expected_reason)), + .any(|state| matches!(state, AuthState::LoginFailed { reason, .. } if reason == expected_reason)), "wallet failure should be surfaced to the modal: {auth_states:?}" ); } @@ -1128,8 +1128,10 @@ mod tests { .lock() .expect("auth state list mutex poisoned"); assert_eq!(auth_states.len(), 1, "states: {auth_states:?}"); - assert!(matches!(&auth_states[0], AuthState::LoginFailed { reason } - if reason.contains("identity storage unavailable"))); + assert!( + matches!(&auth_states[0], AuthState::LoginFailed { reason, .. } + if reason.contains("identity storage unavailable")) + ); } #[test]