From 308f18be327de698fc736272c655e5bd2480df94 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 9 Sep 2026 07:00:14 +0200 Subject: [PATCH] feat(manager): finish the create flow, and repair a room's history in one call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console can now do both things it could compose and never send. Both land through `@openvtc/trust-tasks` 0.17.8 and OpenVTC/verifiable-trust-infrastructure#1332. **Creating a room now finishes.** Its second step called `rooms/create` addressed to a host, and this console cannot address a host — its bridge carries a type and a payload and the background addresses everything to the wallet's own VTA — so the registration never arrived and every create stopped halfway, leaving an owner holding a room DID and signing key for a room that existed nowhere. It now asks the agent, via `rooms/owner/register`, and the agent makes the call. **The history repair exists**, which is the state the rooms pane has been naming since it shipped: a room reading only from the epoch its holder joined at, because the epoch key chain never arrived. `rooms/keys/backfill` is one call where the honest version was three — mint a presentation, ask the host, store the rungs — of which this console could make the first and third and not the second. The screen reads all three numbers the response carries, because they come apart and only one answers the question. Nothing served means the host has no more, and is not a failure. Rungs served with the reach unmoved means they sit below a gap — early rather than wrong, and asking again is the repair, not evidence the history is gone. Reporting `stored` alone would celebrate over a room that still cannot open a word of it. It asks for the host because the agent does not know one: `rooms/keys/list` reports key custody, hosting is a different fact, and a room is portable so a remembered host would go stale the moment it moved. The create-flow tests moved with the code rather than being deleted: they now pin `rooms/owner/register`, and say why naming `rooms/create` there would be pinning a call that lands at a party which does not serve it. Signed-off-by: Glenn Gore --- package-lock.json | 10 +- package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/rooms/index.ts | 99 ++++++++++ packages/core/task-surface.json | 12 ++ packages/core/tests/task-surface.mjs | 14 +- .../src/manager/panes/rooms-backfill.tsx | 183 ++++++++++++++++++ .../src/manager/panes/rooms-create.tsx | 14 +- .../extension/src/manager/panes/rooms.tsx | 8 + .../tests/rooms-backfill.render.test.mts | 146 ++++++++++++++ .../tests/rooms-create.render.test.mts | 51 +++-- 11 files changed, 508 insertions(+), 33 deletions(-) create mode 100644 packages/extension/src/manager/panes/rooms-backfill.tsx create mode 100644 packages/extension/tests/rooms-backfill.render.test.mts diff --git a/package-lock.json b/package-lock.json index e2e7d62..2f37eef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "packages/reviewer-demo" ], "dependencies": { - "@openvtc/trust-tasks": "^0.17.1" + "@openvtc/trust-tasks": "^0.17.8" }, "engines": { "node": ">=24" @@ -2304,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.17.7", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.17.7.tgz", - "integrity": "sha512-ZUmrjTfr3y9MXv9imG2IjZGkX1xVUbtikJXmDi00D7hglyMhiy+sF8vd8OiKBu5NaJ1g1yeQw17ViTJJlALsMw==", + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.17.8.tgz", + "integrity": "sha512-fFI618HFl1m9S2VA5ZPK21YtFURJ0yczv1eTP7VdLx9xCoXI4YrBd6JVDkAYUmQwbBd0EED6df4lz2zTTqe3Mg==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -8034,7 +8034,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.17.7", + "@openvtc/trust-tasks": "^0.17.8", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/package.json b/package.json index fe95b64..1e7819d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,6 @@ "@swc/wasm": "~1.15.47" }, "dependencies": { - "@openvtc/trust-tasks": "^0.17.1" + "@openvtc/trust-tasks": "^0.17.8" } } diff --git a/packages/core/package.json b/packages/core/package.json index 4079c62..7b1221a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -130,7 +130,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.17.7", + "@openvtc/trust-tasks": "^0.17.8", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/packages/core/src/rooms/index.ts b/packages/core/src/rooms/index.ts index 3b82955..428160e 100644 --- a/packages/core/src/rooms/index.ts +++ b/packages/core/src/rooms/index.ts @@ -279,6 +279,54 @@ export async function roomsKeysPresent( * it is present too, so a set with a gap in it is reported here rather than at * the first record that will not open, which reads like corruption. */ +import { + TYPE_URI as KEYS_BACKFILL, + RESPONSE_TYPE_URI as KEYS_BACKFILL_RESPONSE, + type RoomsKeysBackfillPayload, + type RoomsKeysBackfillResponsePayload, +} from "@openvtc/trust-tasks/rooms/keys/backfill/0.1/payload"; + +/** + * Ask this agent to fetch the room's history from its host, and keep it. + * + * **The repair for a room that reads only from where its holder joined**, and + * the one call a surface can make for it. What it folds together is three hops + * — mint a presentation, ask the host for the rungs, store them — of which a + * browser can make the first and third and not the second. The agent makes all + * three, being the party with a channel to the host. + * + * `host` is named because nothing maps a room to one. A room is portable, so a + * host a surface remembered would go stale the moment the room moved; the agent + * holds key custody, which is a different fact. A member learned the host from + * whoever invited them. + * + * **Read the three numbers together.** `fetched` is what the host served, + * `stored` how many were new, and `earliestReadableEpoch` how far back the agent + * can now actually derive a key — which is the only one that answers the + * question. Rungs that arrive below a gap extend reach not at all, so a surface + * reporting `stored` alone would celebrate over a room that still cannot open a + * word of its history. + */ +export async function roomsKeysBackfill( + sender: TrustTaskSender, + params: RoomsCaller & { roomId: string; host: string; fromEpoch?: number; limit?: number }, +): Promise { + const payload: RoomsKeysBackfillPayload = { + roomId: params.roomId, + host: params.host, + ...(params.fromEpoch !== undefined ? { fromEpoch: params.fromEpoch } : {}), + ...(params.limit !== undefined ? { limit: params.limit } : {}), + }; + return call( + sender, + { holder: params.holder, service: params.service }, + KEYS_BACKFILL, + KEYS_BACKFILL_RESPONSE, + "rooms/keys/backfill/0.1", + payload, + ); +} + export async function roomsKeysChain( sender: TrustTaskSender, params: RoomsCaller & { roomId: string; links: EpochLink[] }, @@ -506,6 +554,57 @@ export async function roomsEpochMint( ); } +import { + TYPE_URI as OWNER_REGISTER, + RESPONSE_TYPE_URI as OWNER_REGISTER_RESPONSE, + type RoomsOwnerRegisterPayload, + type RoomsOwnerRegisterResponsePayload, +} from "@openvtc/trust-tasks/rooms/owner/register/0.1/payload"; + +/** + * Ask this agent to register a room with a host. + * + * `rooms/create` performed by the agent, and the reason to prefer it over + * calling `roomsCreate` directly is not convenience: a surface that reaches only + * its own agent **cannot** call `roomsCreate` at all, because the recipient it + * names never travels. This one is addressed to the agent, which can. + * + * The order it belongs in is unchanged: the room's identity is minted first — + * a separate act this does not perform — and a host is then told about a room + * that already exists. A host that named the room would be a host the room could + * not leave. + * + * `host` in the response is what the agent actually reached, which is the value + * worth recording. Normally identical to what was asked for; where it is not, a + * caller storing its own request would hold a host it never spoke to. + */ +export async function roomsOwnerRegister( + sender: TrustTaskSender, + params: RoomsCaller & { + roomId: string; + host: string; + visibility: "open" | "attributed" | "private"; + ownerDid?: string; + retentionDays?: number; + }, +): Promise { + const payload = { + roomId: params.roomId, + host: params.host, + visibility: params.visibility, + ...(params.ownerDid ? { ownerDid: params.ownerDid } : {}), + ...(params.retentionDays !== undefined ? { retentionDays: params.retentionDays } : {}), + } as RoomsOwnerRegisterPayload; + return call( + sender, + { holder: params.holder, service: params.service }, + OWNER_REGISTER, + OWNER_REGISTER_RESPONSE, + "rooms/owner/register/0.1", + payload, + ); +} + // ── Owner issuance ─────────────────────────────────────────────────────── // // These go to the owner's own VTA, which signs AS the room with a key it diff --git a/packages/core/task-surface.json b/packages/core/task-surface.json index 91bfffe..ef80237 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -703,6 +703,12 @@ { "uri": "https://trusttasks.org/spec/push/wake/0.2" }, + { + "uri": "https://trusttasks.org/spec/rooms/keys/backfill/0.1", + "consts": [ + "TASK_ROOMS_KEYS_BACKFILL_0_1" + ] + }, { "uri": "https://trusttasks.org/spec/rooms/keys/chain/0.1", "consts": [ @@ -769,6 +775,12 @@ "TASK_ROOMS_OWNER_ISSUE_MEMBERSHIP_0_1" ] }, + { + "uri": "https://trusttasks.org/spec/rooms/owner/register/0.1", + "consts": [ + "TASK_ROOMS_OWNER_REGISTER_0_1" + ] + }, { "uri": "https://trusttasks.org/spec/task-consent/decision/0.1", "consts": [ diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index b3df6bb..4a09f02 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -328,7 +328,19 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // The three remaining `rooms/keys/*` — commit, key-package, welcome — are MLS // group operations a browser does not perform. They belong to whatever holds // the group state, which is the VTA, not this library. - const expected = 196; + // 196 -> 198, and the canonical total 214 -> 216, are the two tasks that let a + // surface reach a room's host without being able to address one: + // `rooms/keys/backfill` and `rooms/owner/register` + // (trustoverip/dtgwg-trust-tasks-tf#402, implemented at + // OpenVTC/verifiable-trust-infrastructure#1332). Both terminate at the + // member's own agent, which is the whole point — the agent makes the host + // call, being the party with a channel to one. + // + // Their host-served counterparts stay in NOT_IN_SDK and stay uncallable from + // the console. That is not a gap left open: `rooms/create` and + // `rooms/epoch/chain` are what the agent sends onward, and a second copy of + // that call from here would be one that never arrives. + const expected = 198; assert.equal( implemented.size, expected, diff --git a/packages/extension/src/manager/panes/rooms-backfill.tsx b/packages/extension/src/manager/panes/rooms-backfill.tsx new file mode 100644 index 0000000..6562e34 --- /dev/null +++ b/packages/extension/src/manager/panes/rooms-backfill.tsx @@ -0,0 +1,183 @@ +// Fetching a room's history — the repair for a room that reads only from where +// its holder joined. +// +// The list pane names that state and, until the agent could reach a host, could +// not fix it. What is missing there is the **epoch key chain**: each commit +// seals the outgoing epoch's storage key under the incoming one, so a holder of +// the current key can walk backwards to every earlier one. A member who joined +// at epoch 7 was handed the key for 7 and nothing below it. +// +// ## One call, and the reason it is one +// +// Three things have to happen — mint a presentation, ask the host for the rungs, +// store what comes back — and this console can do the first and third and not +// the second. Its bridge carries a task type and a payload and addresses +// everything to the wallet's own VTA, so a document naming a host never travels. +// `rooms/keys/backfill` asks the agent to do all three, being the party that has +// a channel to the host and already holds the credentials and the group state. +// +// ## Why it asks for the host +// +// The agent does not know it. `rooms/keys/list` reports key custody — what this +// VTA can open — and hosting is a different fact it has no view of. That is not +// an oversight: a room is portable, so a remembered host would go stale the +// moment the room moved, and the member learned the host from whoever invited +// them. + +import { useCallback, useState } from "react"; +import { roomsKeysBackfill, type HeldRoom } from "@openvtc/pnm-core/rooms"; +import { Button, Note } from "../../ui.js"; +import { c, t } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { ConsentRequiredError } from "../carrier.js"; +import { ConsentCeremony, runMutation } from "../destructive.js"; +import type { Parties } from "../use-vta.js"; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +interface Outcome { + earliestReadableEpoch: number; + fetched: number; + stored: number; +} + +/** + * What happened, read from the three numbers together. + * + * They come apart, and each combination means something different — which is + * the whole reason the response carries three rather than one. Reporting + * `stored` alone would celebrate over a room that still cannot open a word of + * its history. + */ +function Result({ outcome, room }: { outcome: Outcome; room: HeldRoom }) { + // Nothing served: the host holds no rungs below what this agent already + // reads. Either the room's history begins there or it was severed before this + // member joined — indistinguishable from here, and neither is a failure. + if (outcome.fetched === 0) { + return ( + + The host served no rungs below what your agent already holds. There is + nothing further to fetch from it: either this room's history begins here, + or it was severed before your agent joined. + + ); + } + + if (outcome.earliestReadableEpoch <= 1) { + return ( + + The whole history is readable now — {outcome.stored} new{" "} + {outcome.stored === 1 ? "rung" : "rungs"} of {outcome.fetched} served. + + ); + } + + // Rungs arrived and the reach did not move: they sit below a gap. Early + // rather than wrong — they become useful the moment the gap is filled — so + // this must not read as loss. + if (outcome.earliestReadableEpoch >= room.earliestReadableEpoch) { + return ( + + {outcome.fetched} {outcome.fetched === 1 ? "rung" : "rungs"} arrived and the + readable range did not move — it still starts at epoch{" "} + {outcome.earliestReadableEpoch}. A rung only extends reach when every rung + above it is present, so this is a gap in what the host served rather than + history that is gone. Asking again is the repair. + + ); + } + + return ( + + Readable back to epoch {outcome.earliestReadableEpoch} now, from {outcome.stored}{" "} + new {outcome.stored === 1 ? "rung" : "rungs"}. Anything older was either + severed deliberately or has not been served — from here the two look the same. + + ); +} + +export function FetchHistory({ + parties, + room, + onFetched, +}: { + parties: Parties; + room: HeldRoom; + onFetched: () => void; +}) { + const [hostDid, setHostDid] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + const [outcome, setOutcome] = useState(null); + + const run = useCallback(async () => { + const host = hostDid.trim(); + if (!host) return; + setBusy(true); + setError(null); + setPending(null); + setOutcome(null); + + await runMutation( + async () => { + const res = await roomsKeysBackfill(managerSender, { + ...parties, + roomId: room.roomId, + host, + // Ask only for what is missing. The agent reads to + // `earliestReadableEpoch` already, so the rung below it is where the + // useful part of the chain starts. + fromEpoch: Math.max(1, room.earliestReadableEpoch - 1), + }); + setOutcome({ + earliestReadableEpoch: res.earliestReadableEpoch, + fetched: res.fetched ?? 0, + stored: res.stored ?? 0, + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + onFetched(); + }, [parties, room, hostDid, onFetched]); + + return ( +
+
+ + +
+ +

+ Your agent knows which rooms it can open, not who stores them — a room can + change hosts without anything it holds changing, so it does not keep one. + It presents your credentials to the host itself; they do not pass through + this console. +

+ + {outcome && } + {error && {error}} + {pending && } +
+ ); +} diff --git a/packages/extension/src/manager/panes/rooms-create.tsx b/packages/extension/src/manager/panes/rooms-create.tsx index 6339333..dadc1a7 100644 --- a/packages/extension/src/manager/panes/rooms-create.tsx +++ b/packages/extension/src/manager/panes/rooms-create.tsx @@ -31,7 +31,7 @@ // the retry registers the room that was minted rather than minting a second one. import { useCallback, useEffect, useState } from "react"; -import { roomsCreate } from "@openvtc/pnm-core/rooms"; +import { roomsOwnerRegister } from "@openvtc/pnm-core/rooms"; import { webvhDidCreate } from "@openvtc/pnm-core/webvh"; import { webvhServerList } from "@openvtc/pnm-core/webvh"; import type { WebvhServerRecord } from "@openvtc/pnm-core/webvh"; @@ -202,10 +202,16 @@ export function CreateRoom({ // ── Half two: tell a host ── const ok = await runMutation( async () => { - await roomsCreate(managerSender, { - holder: parties.holder, - service: { did: hostDid.trim() }, + // Through the agent, not straight at the host. This console addresses + // every task to the wallet's own VTA — its bridge carries a type and a + // payload and nothing else — so `rooms/create` composed here would name + // a host that never travels and land at an agent that does not serve it. + // `rooms/owner/register` is the same registration asked of the party + // that can make the call. + await roomsOwnerRegister(managerSender, { + ...parties, roomId: room!.did, + host: hostDid.trim(), ownerDid: parties.holder.did, visibility, ...(retentionDays.trim() ? { retentionDays: Number(retentionDays.trim()) } : {}), diff --git a/packages/extension/src/manager/panes/rooms.tsx b/packages/extension/src/manager/panes/rooms.tsx index 21b826b..9cf2ab6 100644 --- a/packages/extension/src/manager/panes/rooms.tsx +++ b/packages/extension/src/manager/panes/rooms.tsx @@ -26,6 +26,7 @@ import { useCallback, useEffect, useState } from "react"; import { roomsKeysList, type HeldRoom } from "@openvtc/pnm-core/rooms"; import { Note, Panel } from "../../ui.js"; import { CreateRoom } from "./rooms-create.js"; +import { FetchHistory } from "./rooms-backfill.js"; import { IssueInRoomsName } from "./rooms-owner.js"; import { c, t, font } from "../../theme.js"; import { managerSender } from "../sender.js"; @@ -137,6 +138,13 @@ export function RoomsPane({ here is decided by the host, from credentials the room issued — so a room it can read may still refuse a write.

+ {/* Offered only where there is something to repair. A room + that already reads to its first epoch has no chain left to + fetch, and a button that did nothing would teach an + operator to distrust the one that does. */} + {r.earliestReadableEpoch > 1 && ( + void load()} /> + )} ); }} diff --git a/packages/extension/tests/rooms-backfill.render.test.mts b/packages/extension/tests/rooms-backfill.render.test.mts new file mode 100644 index 0000000..4b2361b --- /dev/null +++ b/packages/extension/tests/rooms-backfill.render.test.mts @@ -0,0 +1,146 @@ +// Fetching a room's history, rendered. +// +// One call now, so the interesting tests are no longer about ordering hops — +// the agent does those. What is left is the thing a screen can still get wrong: +// **reporting the wrong number.** +// +// The response carries three, and they come apart. Counting what was delivered +// would say "12 rungs stored" over a room that still cannot read a word of its +// history, because rungs below a gap extend reach not at all. Only +// `earliestReadableEpoch` answers the question, and these pin that it is the one +// the screen reads. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { agent, h, render, PARTIES } from "./harness/dom.mjs"; +import { FetchHistory } from "../src/manager/panes/rooms-backfill.js"; + +const BACKFILL = "rooms/keys/backfill/0.1"; +const HOST = "did:webvh:QmHost:host.example"; +const ROOM = "did:webvh:QmRoom:rooms.example"; + +const room = (epoch: number, earliestReadableEpoch: number) => ({ + roomId: ROOM, + epoch, + earliestReadableEpoch, +}); + +const mount = async (answer: unknown, r = room(7, 7)) => { + const a = agent({ [BACKFILL]: answer }); + const screen = await render( + h(FetchHistory, { parties: PARTIES, room: r, onFetched: () => {} } as never), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await screen.type(screen.all("input")[0]!, HOST); + return { a, screen }; +}; + +const run = async (screen: Awaited>["screen"]) => + screen.click(screen.button("Fetch the history")); + +// ── One call, to the agent ────────────────────────────────────────────────── + +// The console cannot address a host at all — its bridge drops the recipient — +// so a repair that tried would land at an agent that does not serve it. This is +// the whole reason `rooms/keys/backfill` exists. +test("the repair is one call, and the host travels as a payload member", async () => { + const { a, screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 1, fetched: 2, stored: 2 }); + await run(screen); + + assert.equal(a.calls.length, 1); + assert.match(a.calls[0]!.type, /rooms\/keys\/backfill\/0\.1$/); + assert.equal((a.calls[0]!.payload as { host: string }).host, HOST); +}); + +// Asking for the whole chain again when the agent already reads most of it +// makes a host serve rungs that will all be discarded as already-held. +test("it asks only for what is missing", async () => { + const { a, screen } = await mount( + { roomId: ROOM, earliestReadableEpoch: 1, fetched: 3, stored: 3 }, + room(9, 4), + ); + await run(screen); + assert.equal((a.calls[0]!.payload as { fromEpoch: number }).fromEpoch, 3); +}); + +// A room already reading to its first epoch has nothing to ask for, and +// `fromEpoch: 0` is below the schema's minimum. +test("a room reading from epoch 1 never asks for epoch 0", async () => { + const { a, screen } = await mount( + { roomId: ROOM, earliestReadableEpoch: 1, fetched: 0, stored: 0 }, + room(5, 1), + ); + await run(screen); + assert.equal((a.calls[0]!.payload as { fromEpoch: number }).fromEpoch, 1); +}); + +// ── The three numbers, read together ──────────────────────────────────────── + +test("a walk to the first epoch says the whole history is readable", async () => { + const { screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 1, fetched: 5, stored: 5 }); + await run(screen); + assert.match(screen.text(), /whole history is readable now/); +}); + +// The case that must not read as success. +test("rungs that did not extend the reach are reported as a gap, not a win", async () => { + const { screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 7, fetched: 12, stored: 12 }); + await run(screen); + const text = screen.text(); + assert.match(text, /did not move/); + assert.match(text, /gap in what the host served rather than history that is gone/); + assert.doesNotMatch(text, /whole history is readable now/); +}); + +test("a partial walk names the epoch it reached", async () => { + const { screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 4, fetched: 3, stored: 3 }); + await run(screen); + assert.match(screen.text(), /back to epoch 4/); +}); + +// Nothing served is an answer, not a failure — and it is a different answer +// from "rungs arrived and did not help", which is why `fetched` is read first. +test("a host with nothing to serve says so, without calling it a gap", async () => { + const { screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 7, fetched: 0, stored: 0 }); + await run(screen); + const text = screen.text(); + assert.match(text, /served no rungs below what your agent already holds/); + assert.doesNotMatch(text, /gap in what the host served/); +}); + +// `stored: 0` with rungs served is a retry that found everything already held — +// a success, and it must not be drawn as nothing having happened. +test("an already-delivered chain still reports the reach it achieved", async () => { + const { screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 1, fetched: 4, stored: 0 }); + await run(screen); + assert.match(screen.text(), /whole history is readable now/); +}); + +// ── Failures and refusals ─────────────────────────────────────────────────── + +// The agent surfaces the host's own refusal — a room policy declining is not a +// broken network, and the words have to be the host's. +test("a host's refusal is shown as the host's words", async () => { + const { screen } = await mount(() => { + throw new Error("room host `did:webvh:QmHost:host.example` refused: notAMember: not a member"); + }); + await run(screen); + assert.match(screen.text(), /refused: notAMember/); +}); + +test("nothing is asked until a host is named", async () => { + const a = agent({}); + const screen = await render( + h(FetchHistory, { parties: PARTIES, room: room(7, 7), onFetched: () => {} } as never), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + await screen.click(screen.button("Fetch the history")); + assert.deepEqual(a.calls, []); +}); + +// The console never sees the credentials — the agent presents them — and the +// screen says so, because "where did my membership go" is the obvious question. +test("the screen says the credentials do not pass through it", async () => { + const { screen } = await mount({ roomId: ROOM, earliestReadableEpoch: 1, fetched: 1, stored: 1 }); + assert.match(screen.text(), /do not pass through this console/); +}); diff --git a/packages/extension/tests/rooms-create.render.test.mts b/packages/extension/tests/rooms-create.render.test.mts index a7329f1..378c142 100644 --- a/packages/extension/tests/rooms-create.render.test.mts +++ b/packages/extension/tests/rooms-create.render.test.mts @@ -1,11 +1,11 @@ // Making a room, rendered. // -// The form does two writes against two different parties, and the interesting -// tests are all about the seam between them: the DID is minted at the agent, -// the room is registered at a host, and **the first is not undoable**. So what -// happens when the second fails is the property worth pinning, not the happy -// path — an operator who loses the `signingKeyId` has a room nothing can ever -// issue in the name of, and no way to get it back. +// Two writes, both to the agent — the second asks it to reach a host, because +// this console cannot. The interesting tests are all about the seam between +// them: the DID is minted first and **that is not undoable**. So what happens +// when the registration fails is the property worth pinning, not the happy path +// — an operator who loses the `signingKeyId` has a room nothing can ever issue +// in the name of, and no way to get it back. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -14,7 +14,14 @@ import { CreateRoom } from "../src/manager/panes/rooms-create.js"; const SERVERS = "vta/webvh/servers/list/1.0"; const DIDS_CREATE = "vta/webvh/dids/create/1.0"; -const ROOMS_CREATE = "rooms/create/0.1"; +// `rooms/owner/register`, not `rooms/create`. The console cannot address a host +// at all — its bridge carries a type and a payload and addresses everything to +// the wallet's own VTA — so the registration is asked of the agent, which can +// make the call. A test naming `rooms/create` here would be pinning a call that +// lands at a party that does not serve it. +const REGISTER = "rooms/owner/register/0.1"; + +const HOST_DID = "did:webvh:QmHost:host.example"; const CONTEXTS = [ { id: "openvtc", name: "OpenVTC", basePath: "/openvtc", createdAt: "2026-09-07T09:00:00Z" }, @@ -50,26 +57,26 @@ const fillAll = async (screen: Awaited>["screen"]) => { await screen.select(selects[0]!, "openvtc"); // context await screen.select(selects[1]!, "webvh-1"); // hosting server await screen.type(inputs[0]!, "did:web:mediator.example"); // mediator - await screen.type(inputs[1]!, "did:webvh:QmHost:host.example"); // host + await screen.type(inputs[1]!, HOST_DID); // host }; // ── The seam ──────────────────────────────────────────────────────────────── test("both halves are written, identity first", async () => { - const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, [ROOMS_CREATE]: { roomId: MINTED.did, epoch: 1 } }); + const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, [REGISTER]: { roomId: MINTED.did, host: HOST_DID, epoch: 1 } }); await fillAll(screen); await screen.click(screen.button("Create room")); const writes = a.calls.map((c) => c.type).filter((t) => !t.includes("servers/list")); assert.deepEqual( writes.map((t) => t.replace("https://trusttasks.org/spec/", "")), - [`${DIDS_CREATE}`, `${ROOMS_CREATE}`], + [`${DIDS_CREATE}`, `${REGISTER}`], "the DID must exist before a host is told about the room", ); }); test("the room is minted from the room template, addressable and hosted", async () => { - const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, [ROOMS_CREATE]: { roomId: MINTED.did, epoch: 1 } }); + const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, [REGISTER]: { roomId: MINTED.did, host: HOST_DID, epoch: 1 } }); await fillAll(screen); await screen.click(screen.button("Create room")); @@ -85,15 +92,17 @@ test("the room is minted from the room template, addressable and hosted", async // The room's identifier is the DID that was just minted, and the owner is the // caller. A form that sent anything else here would register a room somebody // else controls, or one nobody does. -test("the host is told the minted DID and who owns it", async () => { - const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, [ROOMS_CREATE]: { roomId: MINTED.did, epoch: 1 } }); +test("the agent is told the minted DID, the host, and who owns it", async () => { + const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, [REGISTER]: { roomId: MINTED.did, host: HOST_DID, epoch: 1 } }); await fillAll(screen); await screen.click(screen.button("Create room")); - const create = a.calls.find((c) => c.type.includes("rooms/create"))!; - assert.equal(create.payload.roomId, MINTED.did); - assert.equal(create.payload.ownerDid, PARTIES.holder.did); - assert.equal(create.payload.visibility, "private", "private is the default a room should start at"); + const register = a.calls.find((c) => c.type.includes("owner/register"))!; + assert.equal(register.payload.roomId, MINTED.did); + assert.equal(register.payload.ownerDid, PARTIES.holder.did); + assert.equal(register.payload.visibility, "private", "private is the default a room should start at"); + // The host rides in the payload, because it cannot ride in the recipient. + assert.equal(register.payload.host, HOST_DID); }); // ── The failure that costs something ──────────────────────────────────────── @@ -101,7 +110,7 @@ test("the host is told the minted DID and who owns it", async () => { test("a minted identity survives a failed registration, both halves on screen", async () => { const { screen } = await mount({ [DIDS_CREATE]: MINTED, - [ROOMS_CREATE]: () => { + [REGISTER]: () => { throw new Error("host unreachable"); }, }); @@ -120,9 +129,9 @@ test("retrying after a failed registration registers rather than minting again", let hostFails = true; const { a, screen } = await mount({ [DIDS_CREATE]: MINTED, - [ROOMS_CREATE]: () => { + [REGISTER]: () => { if (hostFails) throw new Error("host unreachable"); - return { roomId: MINTED.did, epoch: 1 }; + return { roomId: MINTED.did, host: HOST_DID, epoch: 1 }; }, }); await fillAll(screen); @@ -133,7 +142,7 @@ test("retrying after a failed registration registers rather than minting again", const mints = a.calls.filter((c) => c.type.includes("dids/create")); assert.equal(mints.length, 1, "a second press minted a second room and orphaned the first"); - assert.equal(a.calls.filter((c) => c.type.includes("rooms/create")).length, 2); + assert.equal(a.calls.filter((c) => c.type.includes("owner/register")).length, 2); }); // ── What the form refuses to do ─────────────────────────────────────────────