diff --git a/packages/extension/src/manager/panes/rooms-owner.tsx b/packages/extension/src/manager/panes/rooms-owner.tsx
new file mode 100644
index 0000000..5fcbeb9
--- /dev/null
+++ b/packages/extension/src/manager/panes/rooms-owner.tsx
@@ -0,0 +1,313 @@
+// Issuing in a room's name — the owner's side of admitting someone.
+//
+// Three verbs that all mean "the room says so", and the reason they are three
+// rather than one is the whole membership model:
+//
+// - An **invitation** is consent. Without it an owner seals a room key to
+// somebody's agent and they are simply *in* — holding keys to material they
+// may not want, having agreed to nothing, and on a `private` room with
+// nobody outside able to tell them they are there. Single-use, consumed on
+// entry.
+// - A **membership credential** is what a member presents afterwards. It does
+// not collapse into the invitation: a VIC names its subject, so presenting
+// one per access would disclose the member to the host on every read, which
+// is exactly what the sealed tiers exist to prevent.
+// - An **authority credential** is what they may *do*. Membership is not
+// permission — a room admits a party and then says separately whether they
+// may write, curate, or admin.
+//
+// ## Nothing here is stored, by anyone
+//
+// The agent signs and returns; it keeps no record of having issued, because a
+// room's membership and authority live in the credentials themselves and a list
+// kept here would be the roster the design keeps away from any single party.
+//
+// The consequence is real and belongs on screen rather than in a comment: **the
+// owner is the only party who knows what they have issued.** That is invariant
+// I1 working as intended — a room has an accountable party and this is one of
+// the things they are accountable for — but an owner who closes this screen
+// without keeping the credential has lost it, and nothing can reissue the same
+// one.
+//
+// ## Why the signing key is typed in rather than looked up
+//
+// Nothing maps a room's DID to the key it was minted with. Inventing that
+// mapping would add a lifecycle to get wrong: a binding that goes stale, or
+// disagrees with the DID document after a rotation. A wrong key here produces a
+// credential that fails to verify against the room's DID document — loud, and at
+// first use rather than silently.
+
+import { useCallback, useState } from "react";
+import {
+ roomsOwnerInvite,
+ roomsOwnerIssueAuthority,
+ roomsOwnerIssueMembership,
+ type IssuedCredential,
+ type RoomAction,
+} from "@openvtc/pnm-core/rooms";
+import { Button, Note, Panel } from "../../ui.js";
+import { c, t, font } 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,
+};
+
+type Verb = "invite" | "membership" | "authority";
+
+const VERBS: { id: Verb; label: string; what: string }[] = [
+ {
+ id: "invite",
+ label: "Invitation",
+ what:
+ "Single-use, and consumed on entry. This is what makes joining an act the " +
+ "invitee agrees to rather than something done to them.",
+ },
+ {
+ id: "membership",
+ label: "Membership",
+ what:
+ "What the member presents afterwards. Separate from the invitation because a " +
+ "credential naming its subject, presented on every access, would disclose the " +
+ "member to the host each time.",
+ },
+ {
+ id: "authority",
+ label: "Authority",
+ what:
+ "What they may do. Membership admits a party; this says whether they may write, " +
+ "curate, or admin — a room can admit someone who may only read.",
+ },
+];
+
+const ACTIONS: { id: RoomAction; hint: string }[] = [
+ { id: "read", hint: "open records" },
+ { id: "write", hint: "add and change them" },
+ { id: "curate", hint: "retract them — its own authority, not implied by write" },
+ { id: "admin", hint: "mint epochs, which is how a member is removed" },
+];
+
+/**
+ * The issued credential, kept on screen until the owner dismisses it.
+ *
+ * Not a toast. Nothing else holds a copy — not this console, not the agent —
+ * so a notification that fades is a credential destroyed.
+ */
+function Issued({ issued, onDone }: { issued: IssuedCredential; onDone: () => void }) {
+ const [copied, setCopied] = useState(false);
+ return (
+
+
+ Signed. Keep this — nothing else has a copy. The agent signed
+ and returned it without recording that it did, because a room's membership lives
+ in its credentials rather than in a list any one party holds.
+
+
+ {issued.credential}
+
+
+ {issued.credentialId}
+
+
+
+
+
+
+ );
+}
+
+export function IssueInRoomsName({ parties }: { parties: Parties }) {
+ const [verb, setVerb] = useState("invite");
+ const [roomId, setRoomId] = useState("");
+ const [signingKeyId, setSigningKeyId] = useState("");
+ const [subject, setSubject] = useState("");
+ const [validUntil, setValidUntil] = useState("");
+ const [actions, setActions] = useState(["read"]);
+
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [pending, setPending] = useState(null);
+ const [issued, setIssued] = useState(null);
+
+ const toggle = (a: RoomAction) =>
+ setActions((prev) => (prev.includes(a) ? prev.filter((x) => x !== a) : [...prev, a]));
+
+ const missing = !roomId.trim()
+ ? "Name the room this is issued in the name of."
+ : !signingKeyId.trim()
+ ? "Name the held key that signs as the room — it is not looked up from the room's DID."
+ : !subject.trim()
+ ? "Name the party this is for."
+ : verb === "authority" && actions.length === 0
+ ? "An authority credential conferring nothing is a credential with no purpose. Choose at least one action."
+ : null;
+
+ const submit = useCallback(async () => {
+ setBusy(true);
+ setError(null);
+ setPending(null);
+ setIssued(null);
+
+ const common = {
+ ...parties,
+ roomId: roomId.trim(),
+ signingKeyId: signingKeyId.trim(),
+ subject: subject.trim(),
+ ...(validUntil.trim() ? { validUntil: new Date(validUntil).toISOString() } : {}),
+ };
+
+ await runMutation(
+ async () => {
+ const res =
+ verb === "invite"
+ ? await roomsOwnerInvite(managerSender, common)
+ : verb === "membership"
+ ? await roomsOwnerIssueMembership(managerSender, common)
+ : await roomsOwnerIssueAuthority(managerSender, { ...common, actions });
+ setIssued(res);
+ },
+ { onConsent: setPending, onError: setError },
+ );
+ setBusy(false);
+ }, [parties, verb, roomId, signingKeyId, subject, validUntil, actions]);
+
+ const chosen = VERBS.find((v) => v.id === verb)!;
+
+ return (
+
+
+ {VERBS.map((v) => (
+
+ ))}
+
+
{chosen.what}
+
+
+
+
+
+
+
+
+
+
+
+ {verb === "invite" && !validUntil.trim() && (
+
+ An invitation with no expiry is a standing right to enter, held by whoever ends up
+ with the bytes. It is single-use, so it cannot admit two people — but it has no
+ deadline by which it stops admitting one.
+
+ )}
+
+ {verb === "authority" && (
+
+ ACTIONS
+
+ {ACTIONS.map((a) => (
+
+ ))}
+
+ {actions.includes("admin") && (
+
+ admin mints epochs, and minting an epoch is how a member is
+ removed. A party holding it can remove any other — including the owner's own
+ agents — by declining to seal the new key to them.
+
+ )}
+
+
+
+ Delivery is yours to arrange, and on a `private` room it matters: routing an
+ invitation through the host would tell it who was asked, which is the one fact that
+ tier is built to withhold.
+
+
+ );
+}
diff --git a/packages/extension/src/manager/panes/rooms.tsx b/packages/extension/src/manager/panes/rooms.tsx
index 3340cfb..21b826b 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 { IssueInRoomsName } from "./rooms-owner.js";
import { c, t, font } from "../../theme.js";
import { managerSender } from "../sender.js";
import { Loading, LoadError, Table, type Column } from "../table.js";
@@ -156,6 +157,11 @@ export function RoomsPane({
<>
{list}
void load()} />
+ {/* Below the list rather than beside it: issuing is about a room the owner
+ already has, and the list is key custody — a room can appear in one and
+ not the other in both directions, so pairing them per-row would suggest
+ a correspondence that does not hold. */}
+
>
);
}
diff --git a/packages/extension/tests/rooms-owner.render.test.mts b/packages/extension/tests/rooms-owner.render.test.mts
new file mode 100644
index 0000000..5bf4f22
--- /dev/null
+++ b/packages/extension/tests/rooms-owner.render.test.mts
@@ -0,0 +1,159 @@
+// Issuing in a room's name, rendered.
+//
+// Two things here are worth more than the happy path.
+//
+// **The credential is the only copy.** The agent signs and keeps no record —
+// a room's membership lives in its credentials rather than a roster — so a
+// screen that showed the result transiently would destroy it. These pin that it
+// stays until dismissed, and that the words say why.
+//
+// **The three verbs are not interchangeable.** Sending an invitation where a
+// membership was asked for admits somebody once and leaves them unable to
+// present afterwards; sending an authority credential where a membership was
+// asked for confers permissions on a party the room never admitted. The task
+// URI is the whole of that distinction, so it is asserted directly.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { agent, h, render, PARTIES } from "./harness/dom.mjs";
+import { IssueInRoomsName } from "../src/manager/panes/rooms-owner.js";
+
+const INVITE = "rooms/owner/invite/0.1";
+const MEMBERSHIP = "rooms/owner/issue-membership/0.1";
+const AUTHORITY = "rooms/owner/issue-authority/0.1";
+
+const ROOM = "did:webvh:QmRoom:rooms.example";
+const KEY = "room-northwind-signing";
+const SUBJECT = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
+
+const SIGNED = {
+ credential: "eyJhbGciOiJFZERTQSJ9.a-signed-credential",
+ credentialId: "urn:uuid:11111111-1111-4111-8111-111111111111",
+};
+
+const mount = async () => {
+ const a = agent({ [INVITE]: SIGNED, [MEMBERSHIP]: SIGNED, [AUTHORITY]: SIGNED });
+ const screen = await render(h(IssueInRoomsName, { parties: PARTIES } as never), {
+ chrome: { runtime: { sendMessage: a.sendMessage } },
+ });
+ return { a, screen };
+};
+
+/** Fill room, key and subject — every verb needs exactly these three. */
+const fill = async (screen: Awaited>["screen"]) => {
+ const text = screen.all("input").filter((el) => el.type === "text" || el.type === "");
+ await screen.type(text[0]!, ROOM);
+ await screen.type(text[1]!, KEY);
+ await screen.type(text[2]!, SUBJECT);
+};
+
+const pick = async (screen: Awaited>["screen"], label: string) =>
+ screen.click(screen.byText("label", label)!);
+
+// ── The three verbs are three tasks ─────────────────────────────────────────
+
+test("each verb sends its own task, and only that one", async () => {
+ for (const [label, uri] of [
+ ["Invitation", INVITE],
+ ["Membership", MEMBERSHIP],
+ ["Authority", AUTHORITY],
+ ]) {
+ const { a, screen } = await mount();
+ await pick(screen, label!);
+ await fill(screen);
+ await screen.click(screen.button("Issue"));
+
+ assert.deepEqual(
+ a.calls.map((c) => c.type.replace("https://trusttasks.org/spec/", "")),
+ [uri],
+ `${label} must send exactly ${uri}`,
+ );
+ }
+});
+
+// The key is named, never derived: nothing maps a room's DID to the key it was
+// minted with, and a surface that guessed would produce credentials that fail to
+// verify against the room's own document.
+test("the room and the signing key both travel, as given", async () => {
+ const { a, screen } = await mount();
+ await fill(screen);
+ await screen.click(screen.button("Issue"));
+
+ const { payload } = a.calls[0]! as { payload: Record };
+ assert.equal(payload.roomId, ROOM);
+ assert.equal(payload.signingKeyId, KEY);
+ assert.equal(payload.subject, SUBJECT);
+});
+
+// ── The credential is the only copy ─────────────────────────────────────────
+
+test("the signed credential stays on screen, and says nothing else holds it", async () => {
+ const { screen } = await mount();
+ await fill(screen);
+ await screen.click(screen.button("Issue"));
+
+ const text = screen.text();
+ assert.match(text, new RegExp(SIGNED.credential));
+ assert.match(text, new RegExp(SIGNED.credentialId));
+ assert.match(text, /nothing else has a copy/i);
+});
+
+// ── Authority is a set of actions, and one of them removes people ───────────
+
+test("authority carries the chosen actions", async () => {
+ const { a, screen } = await mount();
+ await pick(screen, "Authority");
+ await fill(screen);
+ await screen.click(screen.byText("label", "write")!);
+ await screen.click(screen.button("Issue"));
+
+ const { payload } = a.calls[0]! as { payload: { actions: string[] } };
+ assert.deepEqual([...payload.actions].sort(), ["read", "write"]);
+});
+
+// Conferring nothing is not a neutral default — it is a credential with no
+// purpose, and an owner who issued one would find out at the member's first
+// refused operation.
+test("an authority credential conferring nothing is refused before it is sent", async () => {
+ const { a, screen } = await mount();
+ await pick(screen, "Authority");
+ await fill(screen);
+ await screen.click(screen.byText("label", "read")!); // the only default, off again
+ await screen.click(screen.button("Issue"));
+
+ assert.deepEqual(a.calls, []);
+ assert.match(screen.text(), /conferring nothing/);
+});
+
+// `admin` mints epochs, and minting an epoch is how a member is removed — so a
+// party holding it can remove any other. That is not obvious from the word.
+test("choosing admin says what admin actually does", async () => {
+ const { screen } = await mount();
+ await pick(screen, "Authority");
+ await screen.click(screen.byText("label", "admin")!);
+ assert.match(screen.text(), /how a member is removed/);
+});
+
+// ── What the screen warns about ─────────────────────────────────────────────
+
+// Single-use bounds how many it admits, not how long it keeps admitting one.
+test("an invitation with no expiry says what that means", async () => {
+ const { screen } = await mount();
+ assert.match(screen.text(), /standing right to enter/);
+});
+
+test("nothing is signed while a required field is empty", async () => {
+ const { a, screen } = await mount();
+ await screen.click(screen.button("Issue"));
+ assert.deepEqual(a.calls, []);
+ assert.match(screen.text(), /Name the room/);
+});
+
+// The key is the field an operator is likeliest to think is optional, because
+// every other DID-shaped surface derives what it needs.
+test("the missing-key message says it is not derived", async () => {
+ const { screen } = await mount();
+ const text = screen.all("input").filter((el) => el.type === "text" || el.type === "");
+ await screen.type(text[0]!, ROOM);
+ assert.match(screen.text(), /not looked up from the room's DID/);
+});