fix: parse error bodies before throwing on status (R3.7) - #99
Merged
Conversation
Two remaining R3.7 violations from the audit, both losing the server's
machine-readable error code.
1. `register-gateway.ts` threw on `!res.ok` before reading the body — on
`/trust-tasks`, the dispatcher endpoint that emits consent refusals. The
file already handled `isTrustTaskErrorType`, but that branch was
UNREACHABLE for every rejected task, because a rejection arrives non-2xx
and the status check threw first. A consent requirement surfaced as an
opaque `failed (403): {...}` string. Now parses first and matches the
trust-task error document before considering status.
2. `rest-channel.ts` had the same class of bug INSIDE the fix for it.
`res.json()` consumes the stream, and both fallbacks then called
`errorFromResponse(res)`, whose own `res.json()` throws "Body has already
been read" into a bare `catch {}`. So `body.error.code` was always
undefined there: the code degraded to a status-only guess and `details` and
`suggestion` were always lost — R3.7 violated by the code written to
satisfy R3.7.
Adds `errorFromBody(body, status, statusText)` for callers that have already
parsed, since a Response body can only be read once. `errorFromResponse` is
unchanged for callers that have not.
Also extracts `decodeTrustTaskHttpReply` from `RestChannel.send`. The reply
decoding was only reachable through the full bearer handshake, so reverting
the fix broke NO test — the fix was unverifiable in place. It encodes a
security-relevant decision (whether a refusal keeps the `details` an approver
renders a consent prompt from), and logic that can only be exercised via a
DIDComm authcrypt round-trip is logic that never gets exercised.
The existing register-gateway tests used `{ ok, json }` literals rather than
real `Response` objects. A hand-rolled stub only implements what the code
happened to call when it was written, so it stopped representing a Response
the moment the code read the body a different way — it failed against a change
correct for every real Response. Switched to real `Response` objects.
Verified: 12 new tests. Mutation-tested both fixes — restoring the double-read
fails "keeps the server's error code"; the register-gateway status-first order
fails "a refusal at a non-2xx status still surfaces its machine-readable
code". Full suite 204/204, clean build and lint.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
added a commit
that referenced
this pull request
Jul 19, 2026
CLAUDE.md was untracked, so every contributor had their own copy or none. Checks it in, and corrects the parts that had gone stale — its named defects were the ones most likely to be trusted verbatim, and all three were fixed: - R3.7's `consentRequiredFrom` example was fixed (it now matches `details.reason`). Replaced with the rule that actually bites now: a Response body reads once, so an already-parsed body must use `errorFromBody`, not a re-read of the spent Response (#99). - R1.5's "one 2s retry from onClose" applied to the worker inbound path (fixed in #88) and then to the approver inbox (fixed in #97). Replaced with the invariant — cap the delay not the attempt count, re-arm on every failure including first-connect — and a pointer to `ReconnectScheduler`. - R1.2's `handleApiGet`/`handleApiPost` example was fixed in #88 and is now the compliant reference. Replaced with the thing that actually hides these: fetch is injected, so `grep "fetch("` finds almost nothing and the timeout belongs at the injection point (#98). Promotes the one genuinely open defect to its own section: R1.6 persist-before-ack, which is not fixable from this repo — vti-didcomm-js acks before dispatching to `onMessage`, and the wallet persists only the message id, so an offscreen teardown mid-prompt loses a task-consent request for good. Adds a repo-mechanics section for the traps that cost time this week: build `core` before typechecking dependents, lint is `tsc -b` (never `-b --noEmit`, TS6310), cross-workspace imports need a `references` entry, what CI asserts, stub with real `Response` objects, and Node unreffing the `AbortSignal.timeout` timer (passes locally, fails in CI). Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last two R3.7 violations from the audit. Both silently discard the server's machine-readable error code.
1.
register-gateway.ts— the error branch was unreachableIt threw on
!res.okbefore reading the body, on/trust-tasks— the dispatcher endpoint that emits consent refusals. The file already handledisTrustTaskErrorType, but that branch could never run for a rejected task: rejections arrive non-2xx, and the status check threw first. A consent requirement surfaced as an opaquepush/register: ... failed (403): {...}string.This is the same bug
rest-channel.tswas previously fixed for, still live on a second path.2.
rest-channel.ts— R3.7 violated inside the fix for R3.7res.json()consumes the stream. Both fallbacks then callederrorFromResponse(res), whose ownres.json()throwsBody has already been readinto a barecatch {}:So
body?.error?.codewas alwaysundefinedon that path. The code degraded to a status-only guess, anddetails/suggestionwere always lost — precisely what R3.7 forbids, in the code written to satisfy it.Adds
errorFromBody(body, status, statusText)for callers that have already parsed.errorFromResponseis unchanged for callers that have not.Why
decodeTrustTaskHttpReplywas extractedWhen I mutation-tested fix 2 by restoring the double-read, no test failed. The reply decoding was only reachable through the full bearer handshake, so it was unverifiable in place.
It encodes a security-relevant decision — whether a refusal keeps the
detailsan approver renders a consent prompt from — and logic reachable only via a DIDComm authcrypt round-trip is logic that in practice never gets exercised. Extracting it made the bug detectable.A note on the existing tests
The
register-gatewaytests used{ ok, json }object literals rather than realResponseobjects, so they broke against a change that is correct for every real Response. A hand-rolled stub only implements whatever the code happened to call when it was written. Switched to realResponseobjects — I hit the same trap in my own wiring test from #98 and fixed that too.Verification
12 new tests across
vta.errors,vta.rest-replyanddevice.register-gateway, covering: details surviving a 403 refusal, a server code that disagrees with its status, non-JSON bodies on 2xx vs non-2xx, and an explicit "body is read exactly once" assertion.Mutation-tested both fixes:
Full suite 204/204 (was 198), clean build and lint.
Not verified: no run against a real VTA emitting a real refusal. The wire shapes come from the existing tests and the trust-task error type, not from observed VTA traffic.
Remaining audit finding
Persist-before-ack (R1.6/R4.1) —
@openvtc/vti-didcomm-jsacks before dispatching toonMessage, and the wallet persists only the message id, never the body. An offscreen teardown mid-prompt loses a task-consent request permanently. Not fixable in this repo; needs a library change or disabling auto-ack. Worth an issue in the shared repo rather than a patch here.Pre-merge checklist