From 8696bc007237a3c4e5d4aa37b655f4cd061e8f16 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 19:40:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(manager):=20issue=20in=20a=20room's=20name?= =?UTF-8?q?=20=E2=80=94=20invitation,=20membership,=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner surface the console was missing, and the half of it that works today: all three tasks terminate at the operator's own VTA, so unlike `rooms/create` they are reachable from a browser. Three verbs rather than one, because the membership model has three ideas in it. 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. A membership credential is what a member presents afterwards, and 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. An authority credential is what they may do; a room can admit someone who may only read. The screen keeps the credential until it is dismissed, and says why. Nothing else holds a copy — the agent signs and keeps no record, because a room's membership lives in its credentials rather than in a roster any one party keeps — so a result that faded would destroy it. That is invariant I1 working as intended and it has a cost worth stating on screen: the owner is the only party who knows what they have issued. Two things the words have to carry, because neither is visible in the verb: an invitation with no expiry is a standing right to enter held by whoever ends up with the bytes (single-use bounds how many it admits, not how long it keeps admitting one), and `admin` mints epochs — which is how a member is removed, so a party holding it can remove any other. The signing key is typed in rather than derived. Nothing maps a room's DID to the key it was minted with, and a surface that guessed would mint credentials that fail to verify against the room's own document. Signed-off-by: Glenn Gore --- .../src/manager/panes/rooms-owner.tsx | 313 ++++++++++++++++++ .../extension/src/manager/panes/rooms.tsx | 6 + .../tests/rooms-owner.render.test.mts | 159 +++++++++ 3 files changed, 478 insertions(+) create mode 100644 packages/extension/src/manager/panes/rooms-owner.tsx create mode 100644 packages/extension/tests/rooms-owner.render.test.mts 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. + + )} +
+ )} + + {issued && setIssued(null)} />} + {error && {error}} + {pending && } + +
+ + {missing && ( + {missing} + )} +
+ + + 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/); +});