Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions packages/core/src/admin/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,19 @@ export interface ContextDeleteParams {

/** The delete response, from the binding. The hand-written copy omitted
* `ext`, which SPEC §4.5.1 lets any agent send. */
export type ContextDeleteResult = VTAContextsDeleteResponsePayload;
export type ContextDeleteResult = VTAContextsDeleteResponsePayload & {
/** DIDs whose host copy the agent could not confirm removing. Empty is the
* ordinary case; non-empty means the deletion is not finished. */
daemonCleanupErrors: string[];
};

/** What deleting a context would destroy, as the agent reports it. */
export interface ContextDeletePreview {
id: string;
/** Sub-contexts that go with it, deepest first. Every other array here is
* the union over these and the named context, because that is what the
* deletion acts on. Empty for a leaf. */
subContexts: string[];
keys: string[];
webvhDids: string[];
/** Subjects whose ACL entry disappears entirely — this context (or the
Expand All @@ -54,9 +62,9 @@ export interface ContextDeletePreview {
*
* **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.
* context's own, and `subContexts` names them. Consumers used to derive that
* list from the context list; the agent reports it as of trust-tasks 0.21.4,
* and the agent is the one that decides what the cascade reaches.
*
* This returned three of the six arrays until now. `aclEntriesRemoved` in
* particular is the one an operator most needs — it names the subjects about
Expand All @@ -76,12 +84,14 @@ export async function contextPreviewDelete(
id: string;
keys?: string[];
webvhDids?: string[];
subContexts?: string[];
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[];
sub_contexts?: string[];
acl_entries_removed?: string[];
acl_entries_updated?: string[];
did_templates?: string[];
Expand All @@ -91,6 +101,7 @@ export async function contextPreviewDelete(
});
return {
id: payload.id,
subContexts: payload.subContexts ?? payload.sub_contexts ?? [],
keys: payload.keys ?? [],
webvhDids: payload.webvhDids ?? payload.webvh_dids ?? [],
aclEntriesRemoved: payload.aclEntriesRemoved ?? payload.acl_entries_removed ?? [],
Expand All @@ -109,9 +120,22 @@ export async function contextDelete(
{ id: params.id, force: params.force ?? false },
{ issuer: params.holder.did, recipient: params.service.did },
);
const payload = await sender.send<{ id: string; deleted?: boolean }>(envelope, {
const payload = await sender.send<{
id: string;
deleted?: boolean;
daemonCleanupErrors?: string[];
daemon_cleanup_errors?: string[];
}>(envelope, {
expectedResponseType: `${TASK_CONTEXTS_DELETE}#response`,
operationLabel: "vta/contexts/delete/1.0",
});
return { id: payload.id, deleted: payload.deleted ?? false };
return {
id: payload.id,
deleted: payload.deleted ?? false,
// A success that is not the whole story: these DIDs' records are gone
// and their published logs may still be served. The spec says a consumer
// MUST surface it rather than report the deletion as complete, so it is
// returned rather than dropped here.
daemonCleanupErrors: payload.daemonCleanupErrors ?? payload.daemon_cleanup_errors ?? [],
};
}
31 changes: 30 additions & 1 deletion packages/core/tests/admin.contexts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test("delete sends force explicitly, defaulting to false", async () => {
opts.expectedResponseType,
"https://trusttasks.org/spec/vta/contexts/delete/1.0#response",
);
assert.deepEqual(result, { id: "demo", deleted: true });
assert.deepEqual(result, { id: "demo", deleted: true, daemonCleanupErrors: [] });
});

test("force is passed through when the caller means it", async () => {
Expand All @@ -56,13 +56,15 @@ test("preview translates every snake_case list across the casing boundary", asyn
id: "demo",
keys: ["key-1"],
webvh_dids: ["did:webvh:QmX:h"],
sub_contexts: ["demo/sub"],
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",
subContexts: ["demo/sub"],
keys: ["key-1"],
webvhDids: ["did:webvh:QmX:h"],
aclEntriesRemoved: ["did:key:zGone"],
Expand Down Expand Up @@ -95,10 +97,37 @@ test("preview defaults every list when the context holds nothing", async () => {
const result = await contextPreviewDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" });
assert.deepEqual(result, {
id: "demo",
subContexts: [],
keys: [],
webvhDids: [],
aclEntriesRemoved: [],
aclEntriesUpdated: [],
didTemplates: [],
});
});

test("a delete that left host copies behind says so, rather than reporting done", async () => {
// The agent removed the records and the hosting server did not confirm
// removing the published logs, so those DIDs may still resolve. A success
// the caller must not read as a completed deletion.
const channel = recorder({
id: "demo",
deleted: true,
daemonCleanupErrors: ["did:webvh:QmX:h: daemon `h` rejected delete: 503"],
});
const result = await contextDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" });
assert.equal(result.deleted, true);
assert.deepEqual(result.daemonCleanupErrors, [
"did:webvh:QmX:h: daemon `h` rejected delete: 503",
]);
});

test("the snake_case spelling of the cleanup report is read too", async () => {
const channel = recorder({
id: "demo",
deleted: true,
daemon_cleanup_errors: ["did:webvh:QmY:h: orphaned"],
});
const result = await contextDelete(channel, { holder: HOLDER, service: SERVICE, id: "demo" });
assert.deepEqual(result.daemonCleanupErrors, ["did:webvh:QmY:h: orphaned"]);
});
35 changes: 5 additions & 30 deletions packages/extension/src/manager/panes/contexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,11 @@ function Destroyed({
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;
}) {
Expand All @@ -327,7 +318,7 @@ function DeleteContext({
// 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.subContexts.length > 0 ||
p.keys.length > 0 ||
p.webvhDids.length > 0 ||
p.aclEntriesRemoved.length > 0 ||
Expand All @@ -337,7 +328,7 @@ function DeleteContext({
forceLabel="Delete anyway, destroying everything listed above"
renderPreview={(p) => {
const nothing =
subContexts.length === 0 &&
p.subContexts.length === 0 &&
p.keys.length === 0 &&
p.webvhDids.length === 0 &&
p.aclEntriesRemoved.length === 0 &&
Expand All @@ -354,10 +345,10 @@ function DeleteContext({
{/* First, because it changes what every list below means:
those are the subtree's contents, not this context's. */}
<Destroyed
count={subContexts.length}
caption={`${plural(subContexts.length, "sub-context")} deleted with it — everything below is theirs too:`}
count={p.subContexts.length}
caption={`${plural(p.subContexts.length, "sub-context")} deleted with it — everything below is theirs too:`}
>
{subContexts.map((id) => (
{p.subContexts.map((id) => (
<li key={id} style={{ fontFamily: font.mono }}>
{id}
</li>
Expand Down Expand Up @@ -580,21 +571,6 @@ 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,
Expand Down Expand Up @@ -653,7 +629,6 @@ export function ContextsPane({
<DeleteContext
parties={parties}
record={record}
subContexts={descendantsOf(record.id, records)}
authority={authority}
onDeleted={onChanged}
/>
Expand Down
16 changes: 12 additions & 4 deletions packages/extension/tests/contexts-pane-delete.render.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ const ctx = (id: string) => ({
});

/** 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`. */
* `acme-corp`. The agent decides what the cascade reaches and says so in
* `subContexts`; the console no longer derives it, so `acme-corp`'s absence
* here is the agent's answer rather than the console's path matching. */
const RECORDS = [ctx("acme"), ctx("acme/eng"), ctx("acme/eng/ci"), ctx("acme-corp")];
const SUBTREE = ["acme/eng/ci", "acme/eng"];

const mount = async (preview: Record<string, unknown>, records = RECORDS) => {
const a = agent({
[PREVIEW]: { id: "acme", keys: [], webvhDids: [], ...preview },
[PREVIEW]: { id: "acme", keys: [], webvhDids: [], subContexts: SUBTREE, ...preview },
[DELETE]: { id: "acme", deleted: true },
[LIST_DIDS]: { dids: [] },
});
Expand Down Expand Up @@ -69,7 +72,11 @@ test("the sub-contexts that go with it are named, not merely implied", async ()
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");
assert.doesNotMatch(
text,
/acme-corp/,
"only what the agent named is shown — the console does not add to it",
);
});

test("a context holding nothing itself still asks, because its children go too", async () => {
Expand All @@ -88,7 +95,7 @@ test("a context holding nothing itself still asks, because its children go too",
});

test("a leaf holding nothing says so, and does not ask for force", async () => {
const { screen } = await mount({}, [ctx("acme"), ctx("acme-corp")]);
const { screen } = await mount({ subContexts: [] }, [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/);
Expand All @@ -100,6 +107,7 @@ test("grants and templates count as contents, not only keys and DIDs", async ()
// the force control entirely.
const { screen } = await mount(
{
subContexts: [],
aclEntriesRemoved: ["did:key:z6MkBankBot"],
didTemplates: ["bank-persona"],
},
Expand Down
Loading