fix(web): make API response decoding skew-tolerant #507

Merged
Claude-Run merged 1 commit from fix/BUNYIP-506-skew-tolerant-decoding into main 2026-08-10 16:48:49 +02:00
Member

An unrelated field was load-bearing on the auth path. bunyip-web v0.12.0 declared subscription_tier as a required field, bunyip-api v0.13.0 renamed it to membership_tier, and every decode of the user object failed with "missing field subscription_tier". Login step 1 passed (parse_login returns TwoFactorRequired before it decodes AuthResponse), so the failure surfaced on the Two-Factor Authentication screen while the user was typing a TOTP code. A membership tier has nothing to do with verifying a TOTP code.

bunyip-web/src/api/types.rs::User now requires only id, email, role, email_verified and two_factor_enabled; every other field carries #[serde(default)] with a least-privileged default (no membership, no tier, no price lock), so an AuthResponse cannot fail to decode on billing or presentation data. The same rule now covers every Deserialize struct in the file: a field either defaults or is listed in the ESSENTIAL_FIELDS table of the new scripts/check-serde-compat.nu with the reason a default there would be a lie. The guard runs in check.yml with a --self-test first, so it cannot pass vacuously. The rule is asymmetric by direction: request structs under bunyip-api/src/handlers/ keep their required inputs required, and a new test asserts a login body with no password is still rejected.

UserRole, MembershipStatus, MembershipTier and FeedbackStatus decode through String via a wire_enum! macro and gain an Unknown variant, so a value a newer API adds renders neutrally instead of failing the response with "unknown variant" (#[serde(other)] is not available: serde allows it only on internally or adjacently tagged enums). UserRole::Unknown never compares equal to Admin, and a render-level test asserts the dashboard shell hands it no admin link. User.membership_tier and AdminUser.membership_tier carry #[serde(alias = "subscription_tier")] for the reverse skew of a new web against a not-yet-restarted API; the alias is the expand half of an expand/contract rename and drops in v0.15.0.

All four decode sites now log tracing::error! with the endpoint, the target type and the serde message (a 2xx the client cannot read is a deployment defect, not a routine event), and ApiError carries one fixed user-facing line for DECODE_ERROR, so serde internals stop rendering in the login and 2FA forms.

#BUNYIP-506

An unrelated field was load-bearing on the auth path. bunyip-web v0.12.0 declared `subscription_tier` as a required field, bunyip-api v0.13.0 renamed it to `membership_tier`, and every decode of the user object failed with "missing field `subscription_tier`". Login step 1 passed (parse_login returns TwoFactorRequired before it decodes AuthResponse), so the failure surfaced on the Two-Factor Authentication screen while the user was typing a TOTP code. A membership tier has nothing to do with verifying a TOTP code. `bunyip-web/src/api/types.rs::User` now requires only id, email, role, email_verified and two_factor_enabled; every other field carries `#[serde(default)]` with a least-privileged default (no membership, no tier, no price lock), so an AuthResponse cannot fail to decode on billing or presentation data. The same rule now covers every Deserialize struct in the file: a field either defaults or is listed in the ESSENTIAL_FIELDS table of the new `scripts/check-serde-compat.nu` with the reason a default there would be a lie. The guard runs in check.yml with a --self-test first, so it cannot pass vacuously. The rule is asymmetric by direction: request structs under bunyip-api/src/handlers/ keep their required inputs required, and a new test asserts a login body with no password is still rejected. UserRole, MembershipStatus, MembershipTier and FeedbackStatus decode through String via a `wire_enum!` macro and gain an Unknown variant, so a value a newer API adds renders neutrally instead of failing the response with "unknown variant" (`#[serde(other)]` is not available: serde allows it only on internally or adjacently tagged enums). UserRole::Unknown never compares equal to Admin, and a render-level test asserts the dashboard shell hands it no admin link. User.membership_tier and AdminUser.membership_tier carry `#[serde(alias = "subscription_tier")]` for the reverse skew of a new web against a not-yet-restarted API; the alias is the expand half of an expand/contract rename and drops in v0.15.0. All four decode sites now log `tracing::error!` with the endpoint, the target type and the serde message (a 2xx the client cannot read is a deployment defect, not a routine event), and ApiError carries one fixed user-facing line for DECODE_ERROR, so serde internals stop rendering in the login and 2FA forms. #BUNYIP-506
fix(web): make API response decoding skew-tolerant
All checks were successful
E2E / PR gate (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m33s
Create release / Create release from merged PR (pull_request) Has been skipped
5ecf063847
An unrelated field was load-bearing on the auth path. bunyip-web v0.12.0 declared `subscription_tier` as a required field, bunyip-api v0.13.0 renamed it to `membership_tier`, and every decode of the user object failed with "missing field `subscription_tier`". Login step 1 passed (parse_login returns TwoFactorRequired before it decodes AuthResponse), so the failure surfaced on the Two-Factor Authentication screen while the user was typing a TOTP code. A membership tier has nothing to do with verifying a TOTP code.

`bunyip-web/src/api/types.rs::User` now requires only id, email, role, email_verified and two_factor_enabled; every other field carries `#[serde(default)]` with a least-privileged default (no membership, no tier, no price lock), so an AuthResponse cannot fail to decode on billing or presentation data. The same rule now covers every Deserialize struct in the file: a field either defaults or is listed in the ESSENTIAL_FIELDS table of the new `scripts/check-serde-compat.nu` with the reason a default there would be a lie. The guard runs in check.yml with a --self-test first, so it cannot pass vacuously. The rule is asymmetric by direction: request structs under bunyip-api/src/handlers/ keep their required inputs required, and a new test asserts a login body with no password is still rejected.

UserRole, MembershipStatus, MembershipTier and FeedbackStatus decode through String via a `wire_enum!` macro and gain an Unknown variant, so a value a newer API adds renders neutrally instead of failing the response with "unknown variant" (`#[serde(other)]` is not available: serde allows it only on internally or adjacently tagged enums). UserRole::Unknown never compares equal to Admin, and a render-level test asserts the dashboard shell hands it no admin link. User.membership_tier and AdminUser.membership_tier carry `#[serde(alias = "subscription_tier")]` for the reverse skew of a new web against a not-yet-restarted API; the alias is the expand half of an expand/contract rename and drops in v0.15.0.

All four decode sites now log `tracing::error!` with the endpoint, the target type and the serde message (a 2xx the client cannot read is a deployment defect, not a routine event), and ApiError carries one fixed user-facing line for DECODE_ERROR, so serde internals stop rendering in the login and 2FA forms.

#BUNYIP-506
Claude-Run deleted branch fix/BUNYIP-506-skew-tolerant-decoding 2026-08-10 16:48:49 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!507
No description provided.