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 ─────────────────────────────────────────────