fix(desktop): pin resolve idempotency seam and gate 4xx reset on a full body

Two follow-ups to the idempotency fix from the kalvin-agent security review.

The requestId-preservation tests exercised only admin_reopen_report, so the
resolve call-site — the enforcement path — could regress to the stale-intent
bug with no test going red (verified: unconditionally resetting the resolve
requestId left the 35-test admin jsdom suite green). Add resolve lost-response
and definitive-4xx tests, mutation-verified against the resolve catch block.

preserveRequestIdOnError decided from HTTP status alone, so a truncated 4xx
(status read, body lost mid-stream) was treated as a definitive pre-commit
rejection and cleared the key even though no authoritative body arrived. Add a
bodyComplete discriminator to AdminMutationError, set true only when the full
response body is read (authoritative) and false for a redirect, over-cap, or
mid-stream read failure (partial). The UI resets a non-409 4xx only when the
body was complete; a truncated 4xx now preserves the key. Message strings are
unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-14 17:52:14 -04:00
co-authored by Will Pfleger
parent 4eedc33a8f
commit 71b932708a
4 changed files with 348 additions and 33 deletions
+26 -6
View File
@@ -10,12 +10,16 @@
/// pre-send failure (auth build, body serialisation): the relay never
/// committed anything, so a retry must reuse the same idempotency key.
///
/// A body-read failure mid-stream keeps the status it was reading (the relay
/// answered with headers/status but the body was lost) — the outcome is
/// unknown, so the caller preserves idempotency and lets the retry dedupe.
/// `bodyComplete` is `true` only when the relay's full response body was read —
/// an authoritative verdict. A non-409 4xx with `bodyComplete: true` is a
/// definitive pre-commit rejection, so the UI may mint a fresh idempotency key.
/// A status that arrives but whose body is lost mid-stream (or rejected over
/// the size cap) carries `bodyComplete: false`: the outcome is unknown, so the
/// caller preserves idempotency and lets the retry dedupe even on a 4xx.
///
/// Serialises `rename_all = "camelCase"`; the JS bridge surfaces it as the
/// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus`.
/// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus`
/// and `bodyComplete`.
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminMutationError {
@@ -25,14 +29,29 @@ pub struct AdminMutationError {
/// The relay's HTTP status when a response was received; `None` for a
/// transport/pre-send failure where no relay answer exists.
pub relay_status: Option<u16>,
/// Whether the relay's full response body was read. `true` only for an
/// authoritative verdict; `false` when the body was lost or truncated.
pub body_complete: bool,
}
impl AdminMutationError {
/// The relay answered with an HTTP status.
pub(super) fn relay(status: reqwest::StatusCode, message: String) -> Self {
/// The relay answered with an HTTP status and its full body was read — an
/// authoritative verdict.
pub(super) fn authoritative(status: reqwest::StatusCode, message: String) -> Self {
Self {
message,
relay_status: Some(status.as_u16()),
body_complete: true,
}
}
/// The relay answered with an HTTP status but the body was not fully read
/// (redirect, over-cap, or a mid-stream read failure) — outcome unknown.
pub(super) fn partial(status: reqwest::StatusCode, message: String) -> Self {
Self {
message,
relay_status: Some(status.as_u16()),
body_complete: false,
}
}
}
@@ -44,6 +63,7 @@ impl From<String> for AdminMutationError {
Self {
message,
relay_status: None,
body_complete: false,
}
}
}
@@ -281,10 +281,12 @@ pub(super) async fn read_admin_response(
///
/// Mirrors [`read_admin_response`]'s size discipline and message wording so the
/// UI's message parsing is unchanged, but on a non-2xx it returns an
/// [`AdminMutationError`] tagged with the received status. A redirect or a
/// body-read failure also carries the status: the relay answered (headers/
/// status arrived), the body outcome is unknown, so the caller preserves the
/// idempotency key and lets the retry dedupe against any commit that landed.
/// [`AdminMutationError`] tagged with the received status and whether the full
/// body was read. Only a status with a complete body (`authoritative`) is a
/// verdict the UI treats as definitive; a redirect, an over-cap body, or a
/// mid-stream read failure carries the status as `partial` — the relay answered
/// but the outcome is unknown, so the caller preserves the idempotency key and
/// lets the retry dedupe against any commit that landed.
async fn read_admin_mutation_response(
resp: reqwest::Response,
success_cap: u64,
@@ -295,7 +297,7 @@ async fn read_admin_mutation_response(
let status = resp.status();
if status.is_redirection() {
return Err(AdminMutationError::relay(
return Err(AdminMutationError::partial(
status,
format!("admin API returned a {status} redirect (not followed)"),
));
@@ -309,7 +311,7 @@ async fn read_admin_mutation_response(
if let Some(cl) = resp.content_length() {
if cl > cap {
return Err(AdminMutationError::relay(
return Err(AdminMutationError::partial(
status,
format!("admin response too large ({cl} bytes, cap {cap} bytes)"),
));
@@ -320,10 +322,10 @@ async fn read_admin_mutation_response(
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
AdminMutationError::relay(status, format!("admin response stream error: {e}"))
AdminMutationError::partial(status, format!("admin response stream error: {e}"))
})?;
if bytes.len() as u64 + chunk.len() as u64 > cap {
return Err(AdminMutationError::relay(
return Err(AdminMutationError::partial(
status,
format!("admin response too large (cap {cap} bytes)"),
));
@@ -333,7 +335,7 @@ async fn read_admin_mutation_response(
if !is_success {
let body = String::from_utf8_lossy(&bytes);
return Err(AdminMutationError::relay(
return Err(AdminMutationError::authoritative(
status,
format!("admin API error: {body}"),
));
@@ -92,7 +92,7 @@ export function adminErrorMessage(e: unknown): string {
* Extract the relay's HTTP status from a rejected admin mutation, or `null`.
*
* Native mutation commands reject with a typed `AdminMutationError`
* (`{message, relayStatus}`); the Tauri bridge surfaces it as
* (`{message, relayStatus, bodyComplete}`); the Tauri bridge surfaces it as
* `TauriInvokeError` whose `payload` is that object. `relayStatus` is a number
* only when the relay actually answered — `null`/absent for a transport or
* pre-send failure where no relay verdict exists.
@@ -108,31 +108,56 @@ export function adminMutationRelayStatus(e: unknown): number | null {
return null;
}
/**
* Whether the relay's full response body was read — an authoritative verdict.
*
* `AdminMutationError.bodyComplete` is `true` only when the relay answered AND
* its whole body was received. A status that arrives but whose body is lost
* mid-stream (or rejected over the size cap) is `false`: the outcome is
* unknown. Absent/non-boolean payloads (bare-string errors, non-typed
* rejections) read `false`, which is fail-safe — an unknown outcome preserves
* the idempotency key.
*/
export function adminMutationBodyComplete(e: unknown): boolean {
if (e && typeof e === "object" && "payload" in e) {
const payload = (e as { payload: unknown }).payload;
if (payload && typeof payload === "object" && "bodyComplete" in payload) {
const complete = (payload as { bodyComplete: unknown }).bodyComplete;
if (typeof complete === "boolean") return complete;
}
}
return false;
}
/**
* Whether a failed mutation must reuse its idempotency `requestId` on retry.
*
* The id is preserved UNLESS the relay definitively rejected the request before
* committing — a 4xx other than 409. Those (bad action, unauthorized, not
* found) refuse the input pre-commit, so a corrected resubmission is a
* genuinely new command and a fresh id is safe.
* committing — a non-409 4xx whose full body was read. Those (bad action,
* unauthorized, not found) refuse the input pre-commit, so a corrected
* resubmission is a genuinely new command and a fresh id is safe.
*
* Everything else preserves the id so the relay can dedupe against a commit
* that may have landed:
* - 409 — an idempotency claim or in-progress action already exists;
* - 5xx — the relay may have committed before failing;
* - a lost response body (status arrived, outcome unknown);
* - a lost or truncated response body (status arrived, `bodyComplete` false —
* outcome unknown), including a truncated 4xx;
* - a transport or pre-send failure with no relay answer (`relayStatus` null).
*
* This replaces string-matching `"409"`/`"processing"` on the message — which
* missed the native layer's transport errors (`relay unreachable: …`, `admin
* response stream error`) and cleared the id on exactly the ambiguous
* lost-response failures where reuse is required.
* Status alone is insufficient: a truncated 4xx carries a definitive-looking
* status without an authoritative body, so the `bodyComplete` bit gates the
* reset. This replaces string-matching `"409"`/`"processing"` on the message,
* which missed the native layer's transport errors and cleared the id on
* exactly the ambiguous lost-response failures where reuse is required.
*/
export function preserveRequestIdOnError(e: unknown): boolean {
const status = adminMutationRelayStatus(e);
if (status === null) return true;
if (status === 409) return true;
return status < 400 || status >= 500;
if (status < 400 || status >= 500) return true;
// A non-409 4xx resets only when the relay's full body confirmed the verdict.
return !adminMutationBodyComplete(e);
}
// ── Shared UI helpers ─────────────────────────────────────────────────────
@@ -103,16 +103,24 @@ toast.error = (msg) => {
// ── Typed native mutation error ──────────────────────────────────────────────
//
// Admin mutation commands reject with a serialized Rust `AdminMutationError`
// (`{message, relayStatus}`, camelCase). The real tauri bridge rejects with
// that plain object and `toTauriError` wraps it into a `TauriInvokeError` whose
// `.message` is the message and `.payload` is the whole object — from which the
// UI reads `relayStatus` to decide idempotency-retry policy. Rejecting with a
// plain object here (NOT an Error) reproduces that wire shape exactly.
// (`{message, relayStatus, bodyComplete}`, camelCase). The real tauri bridge
// rejects with that plain object and `toTauriError` wraps it into a
// `TauriInvokeError` whose `.message` is the message and `.payload` is the
// whole object — from which the UI reads `relayStatus`/`bodyComplete` to decide
// idempotency-retry policy. Rejecting with a plain object here (NOT an Error)
// reproduces that wire shape exactly.
//
// `relayStatus` is a number when the relay authoritatively answered, and `null`
// for a transport/pre-send failure where no relay verdict exists.
function mutationReject(message, relayStatus) {
return Promise.reject({ message, relayStatus });
// for a transport/pre-send failure where no relay verdict exists. `bodyComplete`
// is true only when the relay's full body was read; it defaults to `relayStatus
// !== null` (a status with a fully-read body — the common authoritative case),
// and callers pass `false` explicitly to model a truncated/lost-body response.
function mutationReject(
message,
relayStatus,
bodyComplete = relayStatus !== null,
) {
return Promise.reject({ message, relayStatus, bodyComplete });
}
// ── Deferred promise helper ──────────────────────────────────────────────────
@@ -2558,6 +2566,266 @@ test("reopen-4xx-resets-requestId: a definitive pre-commit rejection uses a fres
await unmount();
});
test("resolve-lost-response-preserves-requestId: an ambiguous transport failure reuses the requestId on retry", async () => {
// The resolve path is the enforcement seam and carries the same stale-intent
// risk as reopen: a lost-response failure (`relayStatus: null`, no relay
// verdict) must reuse the idempotency requestId so a retry dedupes against a
// commit that may have landed — otherwise a retry with a fresh id re-applies
// an enforcement action over another operator's intervening state.
//
// Mutation evidence: replace the resolve catch's preservation branch with an
// unconditional `requestIdRef.current = null` → the two attempts carry
// different ids and this goes red (the helper and reopen path stay intact).
const origin = "https://admin.example.com";
const pubkey = "c7".repeat(32);
const openItem = {
id: "00000000-0000-0000-0000-0000000000c7",
communityId: "comm-1",
communityHost: "alpha.example.com",
reportEventId: "aa",
reporterPubkey: "bb",
targetKind: "event",
target: "cc",
reportType: "spam",
status: "open",
createdAt: "2024-06-01T12:00:00Z",
};
const openDetail = {
...openItem,
channelId: null,
note: null,
resolvedBy: null,
resolvedAt: null,
actionId: null,
message: null,
};
setIpcHandler("admin_list_reports", () => Promise.resolve([openItem]));
setIpcHandler("admin_get_report", () => Promise.resolve(openDetail));
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
const requestIds = [];
setIpcHandler("admin_resolve_report", (args) => {
requestIds.push(args?.body?.requestId);
// Transport failure: no relay answer, so no HTTP status.
return mutationReject("relay unreachable: network error", null);
});
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
await doRender();
await settle(30);
await openFirstReportDetail(container);
await settle(20);
// Select the dismiss action so the resolve submit button appears.
const dismissBtn = container.querySelector(
"[data-testid='action-btn-dismiss']",
);
assert.ok(dismissBtn, "dismiss action must be present");
await act(async () => {
fireEvent.click(dismissBtn);
await new Promise((r) => setTimeout(r, 10));
});
const submit = container.querySelector("[data-testid='resolve-submit-btn']");
assert.ok(
submit,
"resolve submit button must appear after selecting dismiss",
);
// First attempt → lost response.
await act(async () => {
fireEvent.click(submit);
await new Promise((r) => setTimeout(r, 20));
});
// Second attempt → same ambiguous failure; requestId must be identical.
await act(async () => {
fireEvent.click(submit);
await new Promise((r) => setTimeout(r, 20));
});
assert.equal(
requestIds.length,
2,
"two resolve attempts must have been made",
);
assert.equal(
requestIds[0],
requestIds[1],
`requestId must be preserved across a resolve lost-response retry; got: ${JSON.stringify(requestIds)}`,
);
await unmount();
});
test("resolve-4xx-resets-requestId: a definitive pre-commit rejection uses a fresh requestId", async () => {
// The resolve counterpart to reopen-4xx-resets: a non-409 4xx whose full body
// was read is a definitive pre-commit rejection, so a corrected resubmission
// is a genuinely new command and a fresh requestId is correct. Pins the
// resolve call-site's reset branch specifically.
//
// Mutation evidence: make the resolve catch preserve unconditionally → the
// two attempts share an id and this goes red.
const origin = "https://admin.example.com";
const pubkey = "c8".repeat(32);
const openItem = {
id: "00000000-0000-0000-0000-0000000000c8",
communityId: "comm-1",
communityHost: "alpha.example.com",
reportEventId: "aa",
reporterPubkey: "bb",
targetKind: "event",
target: "cc",
reportType: "spam",
status: "open",
createdAt: "2024-06-01T12:00:00Z",
};
const openDetail = {
...openItem,
channelId: null,
note: null,
resolvedBy: null,
resolvedAt: null,
actionId: null,
message: null,
};
setIpcHandler("admin_list_reports", () => Promise.resolve([openItem]));
setIpcHandler("admin_get_report", () => Promise.resolve(openDetail));
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
const requestIds = [];
setIpcHandler("admin_resolve_report", (args) => {
requestIds.push(args?.body?.requestId);
// Full body read → authoritative pre-commit rejection.
return mutationReject("admin API error: bad request", 400);
});
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
await doRender();
await settle(30);
await openFirstReportDetail(container);
await settle(20);
const dismissBtn = container.querySelector(
"[data-testid='action-btn-dismiss']",
);
assert.ok(dismissBtn, "dismiss action must be present");
await act(async () => {
fireEvent.click(dismissBtn);
await new Promise((r) => setTimeout(r, 10));
});
const submit = container.querySelector("[data-testid='resolve-submit-btn']");
assert.ok(
submit,
"resolve submit button must appear after selecting dismiss",
);
await act(async () => {
fireEvent.click(submit);
await new Promise((r) => setTimeout(r, 20));
});
await act(async () => {
fireEvent.click(submit);
await new Promise((r) => setTimeout(r, 20));
});
assert.equal(
requestIds.length,
2,
"two resolve attempts must have been made",
);
assert.notEqual(
requestIds[0],
requestIds[1],
`a definitive non-409 4xx must reset the resolve requestId; got: ${JSON.stringify(requestIds)}`,
);
await unmount();
});
test("reopen-truncated-4xx-preserves-requestId: a 4xx with a lost body reuses the requestId on retry", async () => {
// Status alone is not a verdict: a 4xx whose body was lost mid-stream
// (`bodyComplete: false`) is NOT a definitive pre-commit rejection — the
// relay answered with a status but the outcome is unknown, so the requestId
// must be preserved and the retry left to dedupe. Only a 4xx with a fully
// read body resets. This pins the `bodyComplete` discriminator: reset-on-
// status-alone would clear the key here and re-issue a fresh command.
//
// Mutation evidence: drop the `bodyComplete` gate (reset every non-409 4xx) →
// the two attempts carry different ids and this goes red.
const origin = "https://admin.example.com";
const pubkey = "c9".repeat(32);
const resolvedItem = {
id: "00000000-0000-0000-0000-0000000000c9",
communityId: "comm-1",
communityHost: "alpha.example.com",
reportEventId: "aa",
reporterPubkey: "bb",
targetKind: "event",
target: "cc",
reportType: "spam",
status: "resolved",
createdAt: "2024-06-01T12:00:00Z",
};
const resolvedDetail = {
...resolvedItem,
channelId: null,
note: null,
resolvedBy: "mod_pubkey",
resolvedAt: "2024-06-02T08:00:00Z",
actionId: null,
message: null,
};
setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem]));
setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail));
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
const requestIds = [];
setIpcHandler("admin_reopen_report", (args) => {
requestIds.push(args?.body?.requestId);
// Status arrived but the body was lost mid-stream: outcome unknown.
return mutationReject(
"admin response stream error: connection reset",
400,
false,
);
});
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
await doRender();
await settle(30);
await openFirstReportDetail(container);
await settle(20);
const submit = container.querySelector("[data-testid='reopen-submit-btn']");
assert.ok(submit, "reopen submit button must be present");
await act(async () => {
fireEvent.click(submit);
await new Promise((r) => setTimeout(r, 20));
});
await act(async () => {
fireEvent.click(submit);
await new Promise((r) => setTimeout(r, 20));
});
assert.equal(requestIds.length, 2, "two reopen attempts must have been made");
assert.equal(
requestIds[0],
requestIds[1],
`a truncated 4xx (bodyComplete false) must preserve the requestId; got: ${JSON.stringify(requestIds)}`,
);
await unmount();
});
test("cancel-on-failed: a failed action offers Cancel, POSTs {actionId} to admin_cancel_report, and reloads to open", async () => {
// Cancel-then-resolve is the only recovery from a failed enforcement. The
// block offers Cancel on `status: "failed"`, fences it on the action id, and