diff --git a/packages/core/src/admin/contexts.ts b/packages/core/src/admin/contexts.ts index be537f4..c314bd7 100644 --- a/packages/core/src/admin/contexts.ts +++ b/packages/core/src/admin/contexts.ts @@ -32,17 +32,41 @@ export interface ContextDeleteParams { * `ext`, which SPEC §4.5.1 lets any agent send. */ export type ContextDeleteResult = VTAContextsDeleteResponsePayload; +/** What deleting a context would destroy, as the agent reports it. */ +export interface ContextDeletePreview { + id: string; + keys: string[]; + webvhDids: string[]; + /** Subjects whose ACL entry disappears entirely — this context (or the + * subtree with it) was the only scope they held. */ + aclEntriesRemoved: string[]; + /** Subjects who keep an entry, with this scope removed from it. */ + aclEntriesUpdated: string[]; + didTemplates: string[]; +} + /** * What deleting this context would destroy. * * Worth calling first, every time: the keys and DIDs a context holds do not * come back, and `force` exists precisely because the agent refuses to take * them with it by accident. Show the operator this list, then delete. + * + * **Every array covers the whole subtree**, because the deletion does — the + * agent counts the sub-contexts' keys, DIDs and grants alongside this + * context's own. What it does not yet report is *which* sub-contexts those + * are; `vta/contexts/preview-delete/1.0` has no member for that, so a + * consumer wanting to name them derives them from the context list. + * + * This returned three of the six arrays until now. `aclEntriesRemoved` in + * particular is the one an operator most needs — it names the subjects about + * to lose their authority outright — and dropping it here meant no consumer + * could show it however carefully it was rendered. */ export async function contextPreviewDelete( sender: TrustTaskSender, params: Omit, -): Promise<{ id: string; keys: string[]; webvhDids: string[] }> { +): Promise { const envelope = buildTrustTask( TASK_CONTEXTS_PREVIEW_DELETE, { id: params.id }, @@ -52,9 +76,15 @@ export async function contextPreviewDelete( id: string; keys?: string[]; webvhDids?: string[]; - /** Pre-fold spelling, still sent by an agent that has not taken the + aclEntriesRemoved?: string[]; + aclEntriesUpdated?: string[]; + didTemplates?: string[]; + /** Pre-fold spellings, still sent by an agent that has not taken the * camelCase change. Accepted on read; never emitted. */ webvh_dids?: string[]; + acl_entries_removed?: string[]; + acl_entries_updated?: string[]; + did_templates?: string[]; }>(envelope, { expectedResponseType: `${TASK_CONTEXTS_PREVIEW_DELETE}#response`, operationLabel: "vta/contexts/preview-delete/1.0", @@ -63,6 +93,9 @@ export async function contextPreviewDelete( id: payload.id, keys: payload.keys ?? [], webvhDids: payload.webvhDids ?? payload.webvh_dids ?? [], + aclEntriesRemoved: payload.aclEntriesRemoved ?? payload.acl_entries_removed ?? [], + aclEntriesUpdated: payload.aclEntriesUpdated ?? payload.acl_entries_updated ?? [], + didTemplates: payload.didTemplates ?? payload.did_templates ?? [], }; } diff --git a/packages/core/tests/admin.contexts.mjs b/packages/core/tests/admin.contexts.mjs index e7986bd..18082ab 100644 --- a/packages/core/tests/admin.contexts.mjs +++ b/packages/core/tests/admin.contexts.mjs @@ -51,14 +51,54 @@ test("a delete the agent refused reports deleted:false rather than throwing", as assert.equal(result.deleted, false); }); -test("preview translates webvh_dids across the casing boundary", async () => { - const channel = recorder({ id: "demo", keys: ["key-1"], webvh_dids: ["did:webvh:QmX:h"] }); +test("preview translates every snake_case list across the casing boundary", async () => { + const channel = recorder({ + id: "demo", + keys: ["key-1"], + webvh_dids: ["did:webvh:QmX:h"], + acl_entries_removed: ["did:key:zGone"], + acl_entries_updated: ["did:key:zNarrowed"], + did_templates: ["persona"], + }); const result = await contextPreviewDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); - assert.deepEqual(result, { id: "demo", keys: ["key-1"], webvhDids: ["did:webvh:QmX:h"] }); + assert.deepEqual(result, { + id: "demo", + keys: ["key-1"], + webvhDids: ["did:webvh:QmX:h"], + aclEntriesRemoved: ["did:key:zGone"], + aclEntriesUpdated: ["did:key:zNarrowed"], + didTemplates: ["persona"], + }); }); -test("preview defaults both lists when the context holds nothing", async () => { +test("preview reads the camelCase spellings the spec actually declares", async () => { + // The snake_case arms above are the compatibility path, for an agent that + // has not taken the camelCase change. These are what a conforming agent + // sends, and reading them was what the client was missing: three of the six + // lists were dropped on the floor, so no consumer could show them. + const channel = recorder({ + id: "demo", + keys: [], + webvhDids: [], + aclEntriesRemoved: ["did:key:zGone"], + aclEntriesUpdated: ["did:key:zNarrowed"], + didTemplates: ["persona"], + }); + const result = await contextPreviewDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); + assert.deepEqual(result.aclEntriesRemoved, ["did:key:zGone"]); + assert.deepEqual(result.aclEntriesUpdated, ["did:key:zNarrowed"]); + assert.deepEqual(result.didTemplates, ["persona"]); +}); + +test("preview defaults every list when the context holds nothing", async () => { const channel = recorder({ id: "demo" }); const result = await contextPreviewDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" }); - assert.deepEqual(result, { id: "demo", keys: [], webvhDids: [] }); + assert.deepEqual(result, { + id: "demo", + keys: [], + webvhDids: [], + aclEntriesRemoved: [], + aclEntriesUpdated: [], + didTemplates: [], + }); }); diff --git a/packages/extension/src/manager/panes/contexts.tsx b/packages/extension/src/manager/panes/contexts.tsx index 902f806..86bef2c 100644 --- a/packages/extension/src/manager/panes/contexts.tsx +++ b/packages/extension/src/manager/panes/contexts.tsx @@ -12,7 +12,11 @@ import { contextsUpdateDid, type ContextRecord, } from "@openvtc/pnm-core"; -import { contextDelete, contextPreviewDelete } from "@openvtc/pnm-core/admin"; +import { + contextDelete, + contextPreviewDelete, + type ContextDeletePreview, +} from "@openvtc/pnm-core/admin"; import { Button, Did, Empty, Note, Panel } from "../../ui.js"; import { LoadError } from "../table.js"; import { c, t, font } from "../../theme.js"; @@ -48,11 +52,7 @@ function Label({ children }: { children: React.ReactNode }) { } /** What deleting a context would destroy, in the agent's own words. */ -interface DeletePreview { - id: string; - keys: string[]; - webvhDids: string[]; -} +type DeletePreview = ContextDeletePreview; function CreateContext({ parties, @@ -266,14 +266,44 @@ function EditContext({ ); } +/** A destroyed-things list: a count, a caption, and the items under it. */ +function Destroyed({ + count, + caption, + children, +}: { + count: number; + caption: string; + children: React.ReactNode; +}) { + if (count === 0) return null; + return ( +
+
+ {count} {caption} +
+
    {children}
+
+ ); +} + function DeleteContext({ parties, record, + subContexts, authority, onDeleted, }: { parties: Parties; record: ContextRecord; + /** The subtree going with it, deepest first. + * + * Derived by the caller from the context list rather than read off the + * preview, because `vta/contexts/preview-delete/1.0` has no member naming + * them. It still has to be shown: the preview's own arrays already *count* + * what the sub-contexts hold, so without this the operator sees keys and + * DIDs that belong to contexts the panel never mentions. */ + subContexts: string[]; authority: Authority | null; onDeleted: () => void; }) { @@ -284,54 +314,112 @@ function DeleteContext({ return ( label="Delete context" disabledReason={denied} preview={() => contextPreviewDelete(managerSender, { ...parties, id: record.id })} - needsForce={(p) => p.keys.length > 0 || p.webvhDids.length > 0} - forceLabel="Delete anyway, destroying the keys and DIDs listed above" - renderPreview={(p) => ( - <> - - Deleting {record.name || record.id} is irreversible. - - {p.keys.length === 0 && p.webvhDids.length === 0 ? ( - Your agent reports it holds no keys and no DIDs. - ) : ( - <> - {p.keys.length > 0 && ( -
-
- {p.keys.length} key{p.keys.length === 1 ? "" : "s"} destroyed: -
-
    - {p.keys.map((k) => ( -
  • {k}
  • - ))} -
-
- )} - {p.webvhDids.length > 0 && ( -
-
- {p.webvhDids.length} DID{p.webvhDids.length === 1 ? "" : "s"} destroyed: -
-
    - {p.webvhDids.map((d) => ( -
  • - -
  • - ))} -
-
- )} - - )} - - )} + // Every list, not two of them. A context whose only contents are a + // grant or a template — or whose contents are all in a sub-context — + // used to read as empty here, so the panel sent `force: false` and the + // agent refused with nothing on screen explaining why. + needsForce={(p) => + subContexts.length > 0 || + p.keys.length > 0 || + p.webvhDids.length > 0 || + p.aclEntriesRemoved.length > 0 || + p.aclEntriesUpdated.length > 0 || + p.didTemplates.length > 0 + } + forceLabel="Delete anyway, destroying everything listed above" + renderPreview={(p) => { + const nothing = + subContexts.length === 0 && + p.keys.length === 0 && + p.webvhDids.length === 0 && + p.aclEntriesRemoved.length === 0 && + p.aclEntriesUpdated.length === 0 && + p.didTemplates.length === 0; + const plural = (n: number, word: string) => `${word}${n === 1 ? "" : "s"}`; + return ( + <> + Deleting {record.name || record.id} is irreversible. + {nothing ? ( + Your agent reports it holds nothing, and has no sub-contexts. + ) : ( + <> + {/* First, because it changes what every list below means: + those are the subtree's contents, not this context's. */} + + {subContexts.map((id) => ( +
  • + {id} +
  • + ))} +
    + + {p.keys.map((k) => ( +
  • + {k} +
  • + ))} +
    + + {p.webvhDids.map((d) => ( +
  • + +
  • + ))} +
    + {/* Losing every scope is a different event from losing one, + and the agent distinguishes them — so does this. */} + + {p.aclEntriesRemoved.map((d) => ( +
  • + +
  • + ))} +
    + + {p.aclEntriesUpdated.map((d) => ( +
  • + +
  • + ))} +
    + + {p.didTemplates.map((n) => ( +
  • + {n} +
  • + ))} +
    + + )} + + ); + }} commit={async (force) => { await contextDelete(managerSender, { ...parties, id: record.id, force }); }} @@ -492,6 +580,21 @@ function ContextDid({ ); } +/** + * The contexts strictly below `id`, deepest first. + * + * Path-derived, because a context id *is* its path — `acme/eng/ci` is under + * `acme`, and the agent builds its own cascade the same way. Segment-wise so + * that `acme-corp` is not read as a child of `acme`. + */ +function descendantsOf(id: string, records: ContextRecord[]): string[] { + const prefix = `${id}/`; + return records + .map((r) => r.id) + .filter((cid) => cid !== id && cid.startsWith(prefix)) + .sort((a, b) => b.split("/").length - a.split("/").length || a.localeCompare(b)); +} + export function ContextsPane({ parties, authority, @@ -550,6 +653,7 @@ export function ContextsPane({ diff --git a/packages/extension/tests/contexts-pane-delete.render.test.mts b/packages/extension/tests/contexts-pane-delete.render.test.mts new file mode 100644 index 0000000..81a5587 --- /dev/null +++ b/packages/extension/tests/contexts-pane-delete.render.test.mts @@ -0,0 +1,114 @@ +// The delete-a-context panel, rendered. +// +// Deleting a context deletes its whole subtree. The panel used to say nothing +// about that, and the omission was not cosmetic in either direction: +// +// **A parent holding nothing of its own read as harmless.** The preview showed +// "no keys and no DIDs", `needsForce` was false, so the panel sent +// `force: false` — and the agent refused, because sub-contexts exist. The +// operator saw a refusal for a deletion the screen had just described as +// destroying nothing. +// +// **A parent holding one key of its own read as almost harmless.** `force` +// went true on the strength of that one key, the confirmation listed it, and +// the agent destroyed every context below it and everything they held. The +// checkbox said "destroying the keys and DIDs listed above"; the list was one +// key, and the deletion was a subtree. +// +// Both are the same missing fact, so both are pinned here. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { agent, h, render, PARTIES } from "./harness/dom.mjs"; +import { ContextsPane } from "../src/manager/panes/contexts.js"; + +const PREVIEW = "vta/contexts/preview-delete/1.0"; +const DELETE = "vta/contexts/delete/1.0"; +const LIST_DIDS = "vta/webvh/dids/list/1.0"; + +const ctx = (id: string) => ({ + id, + name: id, + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + basePath: "m/26'/2'/0'", +}); + +/** A subtree: `acme` over `acme/eng` over `acme/eng/ci`, plus an unrelated + * `acme-corp` that must not be read as a child of `acme`. */ +const RECORDS = [ctx("acme"), ctx("acme/eng"), ctx("acme/eng/ci"), ctx("acme-corp")]; + +const mount = async (preview: Record, records = RECORDS) => { + const a = agent({ + [PREVIEW]: { id: "acme", keys: [], webvhDids: [], ...preview }, + [DELETE]: { id: "acme", deleted: true }, + [LIST_DIDS]: { dids: [] }, + }); + const screen = await render( + h(ContextsPane, { + parties: PARTIES, + authority: null, + records, + selected: "acme", + onChanged: () => {}, + } as never), + { chrome: { runtime: { sendMessage: a.sendMessage } } }, + ); + return { a, screen }; +}; + +/** Open the delete panel's preview step. */ +const openPreview = async (screen: { button: (s: string) => Element; click: (e: Element) => Promise; settle: () => Promise }) => { + await screen.click(screen.button("Delete context")); + await screen.settle(); +}; + +test("the sub-contexts that go with it are named, not merely implied", async () => { + const { screen } = await mount({}); + await openPreview(screen as never); + const text = (screen as never as { text: () => string }).text(); + assert.match(text, /acme\/eng\/ci/, "the deepest sub-context is not on screen"); + assert.match(text, /acme\/eng(?!\/)/, "the intermediate sub-context is not on screen"); + assert.doesNotMatch(text, /acme-corp/, "a sibling whose id merely shares a prefix is not a child"); +}); + +test("a context holding nothing itself still asks, because its children go too", async () => { + // The agent reports no keys and no DIDs — the exact answer that used to + // produce "holds nothing" and a `force: false` the agent then refused. + const { screen } = await mount({}); + await openPreview(screen as never); + assert.doesNotMatch( + (screen as never as { text: () => string }).text(), + /holds nothing, and has no sub-contexts/, + "a context with three sub-contexts was described as holding nothing", + ); + // The force control is offered, which is what makes the deletion possible + // at all: the agent refuses this subtree without it. + assert.match((screen as never as { text: () => string }).text(), /Delete anyway/); +}); + +test("a leaf holding nothing says so, and does not ask for force", async () => { + const { screen } = await mount({}, [ctx("acme"), ctx("acme-corp")]); + await openPreview(screen as never); + const text = (screen as never as { text: () => string }).text(); + assert.match(text, /holds nothing, and has no sub-contexts/); + assert.doesNotMatch(text, /Delete anyway/, "an empty leaf must not need force"); +}); + +test("grants and templates count as contents, not only keys and DIDs", async () => { + // A context whose only contents are a grant used to render empty and skip + // the force control entirely. + const { screen } = await mount( + { + aclEntriesRemoved: ["did:key:z6MkBankBot"], + didTemplates: ["bank-persona"], + }, + [ctx("acme"), ctx("acme-corp")], + ); + await openPreview(screen as never); + const text = (screen as never as { text: () => string }).text(); + assert.match(text, /losing access entirely/, "the subject losing all authority is not shown"); + assert.match(text, /z6MkBankBot|z6MkB/, "the subject is not named"); + assert.match(text, /bank-persona/, "the template is not named"); + assert.match(text, /Delete anyway/, "a context holding a grant must need force"); +});