Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ For history before this file, see `git log` on `packages/core`.

## [Unreleased]

### Fixed

- **Every VTA rejection was decoded as a success** (`vta/protocol.ts`).
`isTrustTaskErrorType` enumerated `trust-task-error/0.1` and `/0.2`;
`trust-tasks-rs` has emitted `/0.3` since its 0.3 release (it carries the
§8.2 `inResponseTo` member, which `0.2`'s `additionalProperties: false`
payload schema cannot). An unrecognised error document is not treated as an
error — `parseTrustTaskReply` returns its payload as the operation's *result* —
so a refused task reported success to the calling page. A `webvh/dids/update`
the VTA refused rendered "your agent signed and published the update"; nothing
was published.

A `requireConsent` refusal travels as the same error document, so the
consent ceremony was affected the same way: `requestTask` resolved as
`accepted` instead of `consentRequired`, the cross-device match code never
rendered, and no approver was ever asked. This is the more serious half — the
failure is silent on both the requesting and the approving side.

The predicate now matches the framework slug at any `0.x` minor (per SPEC.md
§5.2 forward-minor compatibility), so a later minor cannot break it again.
A major bump is still excluded: `1.x` is where the payload shape may change.
Covers all four consumers — the REST channel, `parseTrustTaskReply`, the
approver's decision-outcome reader and push-gateway registration — which
already routed through this one predicate.

## [0.3.0] - 2026-08-09

### Added
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/inbound/task-consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export type TaskConsentOutcome =
*
* The executor answers a decision on the same DIDComm thread, as a Trust-Task
* envelope: a `decision/0.1#response` document on success, a
* `trust-task-error/{0.1,0.2}` on refusal. Nothing here recognised either, so
* `trust-task-error/0.x` on refusal. Nothing here recognised either, so
* both fell through the inbound handler's final "anything else is ignored"
* branch — no log, no surface, nothing.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/vta/didcomm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class DidcommVtaTransport implements VtaTransport, TrustTaskChannel {
* DIDComm and return its response payload. Authcrypts the envelope to the
* VTA, optionally forwards via the mediator, awaits the reply correlated by
* the envelope id (`thid`), validates the binding envelope, and decodes the
* body — either the success `payload` or a `trust-task-error/{0.1,0.2}`
* body — either the success `payload` or a `trust-task-error/0.x`
* (throws a normalized {@link VtaClientError}).
*/
async send<Res>(envelope: TrustTask<unknown>, opts: SendOpts = {}): Promise<Res> {
Expand Down
38 changes: 32 additions & 6 deletions packages/core/src/vta/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export const TRUST_TASK_ENVELOPE_TYPE =
"https://trusttasks.org/binding/didcomm/0.1/envelope";

/** Framework error-document `type` — a `TrustTask` whose payload is a
* {@link TrustTaskErrorPayload}. The 0.1 form; a 0.2-capable peer emits
* {@link TRUST_TASK_ERROR_TYPE_0_2}. Use {@link isTrustTaskErrorType} to
* match either on the wire. */
* {@link TrustTaskErrorPayload}. The 0.1 form; later framework versions emit
* {@link TRUST_TASK_ERROR_TYPE_0_2} or {@link TRUST_TASK_ERROR_TYPE_0_3}. Use
* {@link isTrustTaskErrorType} to match any of them on the wire. */
export const TRUST_TASK_ERROR_TYPE =
"https://trusttasks.org/spec/trust-task-error/0.1";

Expand All @@ -29,9 +29,35 @@ export const TRUST_TASK_ERROR_TYPE =
export const TRUST_TASK_ERROR_TYPE_0_2 =
"https://trusttasks.org/spec/trust-task-error/0.2";

/** True for either the 0.1 or 0.2 framework error-document `type`. */
/** 0.3 framework error-document `type` — what `trust-tasks-rs` ≥ 0.3 emits for
* every rejection. Adds the §8.2 `inResponseTo` member (0.2's payload schema
* is `additionalProperties: false`, so a document carrying it cannot claim to
* be 0.2); the members this wallet reads are unchanged. */
export const TRUST_TASK_ERROR_TYPE_0_3 =
"https://trusttasks.org/spec/trust-task-error/0.3";

/**
* True for a framework error-document `type` of any minor version.
*
* **Match the slug, not a fixed list of versions.** This used to enumerate 0.1
* and 0.2, which stopped matching the moment the VTA moved to `trust-tasks-rs`
* 0.3 and began emitting `trust-task-error/0.3` — and the failure mode is the
* worst one available: an unrecognised error document is not an error here, it
* is a *success*. `parseTrustTaskReply` returns its payload as the operation's
* result, so every VTA rejection arrived at the caller as a completed
* operation. A `dids/update` the VTA refused reported "published" in the
* relying party's UI, and — because a `requireConsent` refusal is also an error
* document — a task awaiting human approval resolved as *done*, so the consent
* ceremony never rendered and no approver was ever asked.
*
* SPEC.md §5.2's forward-minor rule says a 0.2 consumer SHOULD accept a 0.3
* document; honouring it by slug means the next minor cannot break this the
* same way. A *major* bump (`trust-task-error/1.x`) is deliberately excluded —
* that is where the payload shape may genuinely change.
*/
export function isTrustTaskErrorType(type: string | undefined): boolean {
return type === TRUST_TASK_ERROR_TYPE || type === TRUST_TASK_ERROR_TYPE_0_2;
if (typeof type !== "string") return false;
return /^https:\/\/trusttasks\.org\/spec\/trust-task-error\/0\.\d+$/.test(type);
}

const PASSKEY_VMS = "https://trusttasks.org/spec/vta/passkey-vms";
Expand Down Expand Up @@ -68,7 +94,7 @@ export interface TrustTask<P> {
payload: P;
}

/** Payload of a `trust-task-error/{0.1,0.2}` document. `code` is a framework
/** Payload of a `trust-task-error/0.x` document. `code` is a framework
* status — snake_case in 0.1 (`permission_denied`, `malformed_request`,
* `task_failed`, `unsupported_type`, `internal_error`, …) and lowerCamelCase
* in 0.2 (`permissionDenied`, …). Treat it as an opaque string; do not
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/vta/trust-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// - `buildTrustTask` — construct the request envelope.
// - `parseTrustTaskReply` — turn a reply document into a typed payload, or
// throw a normalized `VtaClientError` for a
// `trust-task-error/{0.1,0.2}` document.
// `trust-task-error/0.x` document.
//
// A `TrustTaskChannel` (see `channel.ts`) builds a request with the former
// and hands the decoded reply document to the latter; the channel itself only
Expand Down Expand Up @@ -80,7 +80,7 @@ type ReplyDocument = { type?: string; payload?: unknown };
/**
* Decode a Trust-Task reply document into its typed payload.
*
* - A `trust-task-error/{0.1,0.2}` document throws a `VtaClientError` whose
* - A `trust-task-error/0.x` document throws a `VtaClientError` whose
* `code` is the coerced typed {@link VtaErrorCode}, whose `message` is the
* framework's human message, and whose `details` is the raw error payload
* (so callers can still read the framework `code`, `retryable`, etc.).
Expand Down
78 changes: 78 additions & 0 deletions packages/core/tests/vta.error-type.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Which reply documents count as framework *errors*.
//
// This is a one-line predicate guarding a very asymmetric failure: a document
// it does not recognise is not treated as a failed operation, it is decoded as a
// successful one (`parseTrustTaskReply` returns the payload as the result). So
// under-matching here does not surface as an error with the wrong wording — it
// surfaces as a rejection the caller reports as success.

import { test } from "node:test";
import assert from "node:assert/strict";

import {
isTrustTaskErrorType,
TRUST_TASK_ERROR_TYPE,
TRUST_TASK_ERROR_TYPE_0_2,
TRUST_TASK_ERROR_TYPE_0_3,
} from "../dist/vta/protocol.js";

test("every framework error-document minor version is recognised", () => {
// 0.3 is the one that matters operationally: `trust-tasks-rs` has emitted it
// since its 0.3 release, so it is what a current VTA actually sends. It was
// absent from the enumerated list this predicate used to be, which meant the
// wallet read every real rejection as a success.
for (const type of [
TRUST_TASK_ERROR_TYPE,
TRUST_TASK_ERROR_TYPE_0_2,
TRUST_TASK_ERROR_TYPE_0_3,
]) {
assert.equal(isTrustTaskErrorType(type), true, type);
}
});

test("a future minor version is recognised without a code change", () => {
// The whole point of matching the slug. SPEC.md §5.2's forward-minor rule says
// a consumer SHOULD accept a later minor, and the cost of not doing so is not
// a missing feature — it is silent success on failure.
assert.equal(
isTrustTaskErrorType("https://trusttasks.org/spec/trust-task-error/0.9"),
true,
);
assert.equal(
isTrustTaskErrorType("https://trusttasks.org/spec/trust-task-error/0.42"),
true,
);
});

test("a major version bump is NOT assumed compatible", () => {
// 1.x is where the payload shape may genuinely change, so it must come back
// through a deliberate code change rather than be silently decoded with 0.x
// assumptions.
assert.equal(
isTrustTaskErrorType("https://trusttasks.org/spec/trust-task-error/1.0"),
false,
);
});

test("a success document is not an error", () => {
assert.equal(
isTrustTaskErrorType("https://trusttasks.org/spec/vta/webvh/dids/update/1.0#response"),
false,
);
// Nor is anything that merely mentions the slug — a task type could.
assert.equal(
isTrustTaskErrorType("https://evil.example/spec/trust-task-error/0.3"),
false,
);
assert.equal(
isTrustTaskErrorType("https://trusttasks.org/spec/trust-task-error/0.3/extra"),
false,
);
});

test("a missing or non-string type is not an error document", () => {
assert.equal(isTrustTaskErrorType(undefined), false);
assert.equal(isTrustTaskErrorType(""), false);
assert.equal(isTrustTaskErrorType(null), false);
assert.equal(isTrustTaskErrorType(42), false);
});
65 changes: 59 additions & 6 deletions packages/core/tests/vta.request-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,38 @@ function capturing(reply = { ok: true }) {
* This exercises `parseTrustTaskReply`, which THROWS on an error document. That
* throw is the whole point of the test: the refusal has to survive it.
*/
function rejecting(errorPayload) {
function rejecting(errorPayload, errorType = TT_ERROR_TYPES.emitted) {
const channel = {
kind: "test",
async send(envelope) {
// What a channel does with the reply document, everywhere in this codebase.
return parseTrustTaskReply(
{
id: "urn:uuid:reply",
type: "https://trusttasks.org/spec/trust-task-error/0.1",
payload: errorPayload,
},
{ id: "urn:uuid:reply", type: errorType, payload: errorPayload },
{ operationLabel: envelope.type },
);
},
};
return new VtaSession([channel]);
}

/**
* The framework error-document versions this wallet must handle.
*
* `emitted` is the one a current VTA actually sends — `trust-tasks-rs` has
* emitted `trust-task-error/0.3` since its 0.3 release. Every test here used to
* hard-code `0.1`, which is how the wallet came to recognise only 0.1 and 0.2
* while every real rejection arrived as 0.3: the suite exercised a version no
* VTA sends, and an unrecognised error document is decoded as a *success*, so
* nothing failed anywhere. Default to what ships; keep the older forms in the
* table so the compatibility claim stays tested.
*/
const TT_ERROR_TYPES = {
"0.1": "https://trusttasks.org/spec/trust-task-error/0.1",
"0.2": "https://trusttasks.org/spec/trust-task-error/0.2",
"0.3": "https://trusttasks.org/spec/trust-task-error/0.3",
emitted: "https://trusttasks.org/spec/trust-task-error/0.3",
};

// The REAL wire shape the VTA emits for a consent-gated task: the standard
// Trust Task error code `taskFailed` (see trust-tasks-rs `RejectReason::TaskFailed`
// -> `ErrorPayload`), with the machine-readable reason and payload in `details`.
Expand Down Expand Up @@ -198,6 +212,45 @@ test("consent is recognised by the machine-readable reason in the error details,
assert.equal(res.consentRequests.length, 1);
});

test("a consent refusal is recognised at every framework error-document version", async () => {
// The regression that let a whole class of failure read as success.
//
// The wallet enumerated 0.1 and 0.2. `trust-tasks-rs` has emitted 0.3 since
// its 0.3 release, and an error document this code does not recognise is not
// treated as an error at all — `parseTrustTaskReply` hands its payload back as
// the operation's RESULT. So a refused task reported success to the page, and
// a task the VTA was holding for human approval resolved as *done*: the match
// code never rendered, and no approver was ever asked to approve anything.
//
// Every version, not just the current one — a peer mid-upgrade may still be on
// an older minor, and the point is that no minor can break this again.
for (const version of ["0.1", "0.2", "0.3"]) {
const res = await requestTask(rejecting(CONSENT_REJECT, TT_ERROR_TYPES[version]), base);
assert.equal(res.kind, "consentRequired", `consent not recognised at ${version}`);
assert.equal(res.consentRequests.length, 1, `signed requests lost at ${version}`);
}
});

test("a plain task failure at the emitted version still THROWS — it is not a result", async () => {
// The other half of the same bug, and the one the operator saw first: a
// `taskFailed` with no consent details must reach the caller as an error. When
// the version went unrecognised this resolved instead, and the relying party
// rendered "your agent signed and published the update" for an update the VTA
// had refused.
const failure = {
code: "taskFailed",
retryable: false,
message: "did not found: SCID did:webvh:QmNope:example.com:agent not found",
};
await assert.rejects(
() => requestTask(rejecting(failure), base),
(err) => {
assert.match(err.message, /did not found/);
return true;
},
);
});

test("consent is recognised before the VTA emits an explicit reason — by the signed consentRequests", async () => {
// Rollout-order safety: a VTA build that has not yet added `details.reason`
// still delivers the executor-signed `consentRequests`. Their presence is the
Expand Down