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.
+
+ );
+}
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 ─────────────────────────────────────────────