Skip to content

Commit 2f6923b

Browse files
authored
Merge pull request #170 from OpenVTC/feat/persona-linkage-views
feat(console): click through a persona to what it actually presents
2 parents 5505c92 + dbb575a commit 2f6923b

3 files changed

Lines changed: 486 additions & 16 deletions

File tree

packages/extension/src/manager/panes/persona.tsx

Lines changed: 209 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { Loading, LoadError, Table, Truncated, type Column } from "../table.js";
7272
import { useAsync, type Async } from "../use-async.js";
7373
import { contextHeading, formatInstant } from "../format.js";
7474
import { isUnscopedHolder, type Authority, type Parties } from "../use-vta.js";
75+
import { scanForProfile } from "../profile-bindings.js";
7576
import {
7677
composeEntries,
7778
lockedRefs,
@@ -975,14 +976,23 @@ function DeleteProfile({
975976
* what says the value lives only in this profile — so it is rendered as a
976977
* statement rather than as three empty cells.
977978
*/
978-
function ResolvedProfile({ parties, profile }: { parties: Parties; profile: PoolProfile }) {
979+
function ResolvedProfile({
980+
parties,
981+
profileId,
982+
name,
983+
}: {
984+
parties: Parties;
985+
profileId: string;
986+
/** How to name it while loading and when it holds nothing. */
987+
name: string;
988+
}) {
979989
const resolved = useAsync(
980-
async () => personaProfileGet(managerSender, { ...parties, profileId: profile.profileId, resolve: true }),
981-
[parties.holder.did, parties.service.did, profile.profileId],
990+
async () => personaProfileGet(managerSender, { ...parties, profileId, resolve: true }),
991+
[parties.holder.did, parties.service.did, profileId],
982992
);
983993

984-
if (resolved.error) return <LoadError what={`what ${profile.name} presents`} error={resolved.error} />;
985-
if (!resolved.data) return <Loading what={`what ${profile.name} presents`} />;
994+
if (resolved.error) return <LoadError what={`what ${name} presents`} error={resolved.error} />;
995+
if (!resolved.data) return <Loading what={`what ${name} presents`} />;
986996

987997
const claims = resolved.data.resolved ?? [];
988998
if (claims.length === 0) {
@@ -1006,7 +1016,7 @@ function ResolvedProfile({ parties, profile }: { parties: Parties; profile: Pool
10061016
const inline = claim.attributeId === undefined;
10071017
return (
10081018
<div
1009-
key={`${profile.profileId}-claim-${i}`}
1019+
key={`${profileId}-claim-${i}`}
10101020
style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "baseline" }}
10111021
>
10121022
<span style={{ fontFamily: font.mono, fontSize: t.xs, minWidth: 150 }}>{claim.type}</span>
@@ -1020,29 +1030,127 @@ function ResolvedProfile({ parties, profile }: { parties: Parties; profile: Pool
10201030
);
10211031
}
10221032

1033+
/**
1034+
* Which personas present a profile, and where.
1035+
*
1036+
* The question a holder actually asks of a profile — "who knows me by this?" —
1037+
* and the console has to assemble it, because no single task answers it. The
1038+
* assembly, and the soundness argument that makes it exact, live in
1039+
* `profile-bindings.ts`; this renders the result.
1040+
*
1041+
* **A click, not a column.** Even bounded, it is a fan-out across every
1042+
* context, and the answer is the holder's linkage map — the artifact this
1043+
* family exists to keep from being assembled casually. A column would run it on
1044+
* every page load for every profile and leave the map on screen whether or not
1045+
* anyone asked.
1046+
*/
1047+
function ProfileBindings({
1048+
parties,
1049+
profile,
1050+
records,
1051+
}: {
1052+
parties: Parties;
1053+
profile: PoolProfile;
1054+
records: ContextRecord[];
1055+
}) {
1056+
const found = useAsync(
1057+
async () =>
1058+
scanForProfile(
1059+
records.map((r) => r.id),
1060+
{ profileId: profile.profileId, name: profile.name },
1061+
{
1062+
list: (contextId) => listBindings(managerSender, { ...parties, contextId }),
1063+
get: (contextId, personaDid) =>
1064+
getBinding(managerSender, { ...parties, contextId, personaDid }),
1065+
},
1066+
),
1067+
[parties.holder.did, parties.service.did, profile.profileId, profile.name, records.length],
1068+
);
1069+
1070+
if (found.error) return <LoadError what={`who presents ${profile.name}`} error={found.error} />;
1071+
if (!found.data) return <Loading what={`who presents ${profile.name}`} />;
1072+
1073+
const { rows, unreadable } = found.data;
1074+
1075+
return (
1076+
<div style={{ display: "grid", gap: 6, padding: "4px 0 6px" }}>
1077+
<span style={{ fontSize: t.xs, color: c.faint, textTransform: "uppercase", letterSpacing: 0.4 }}>
1078+
Presented by
1079+
</span>
1080+
{rows.length === 0 ? (
1081+
<span style={{ fontSize: t.sm, color: c.faint }}>
1082+
No persona presents this profile. Nothing discloses it, in any context.
1083+
</span>
1084+
) : (
1085+
rows.map((row) => (
1086+
<div
1087+
key={`${row.contextId}-${row.personaDid}`}
1088+
style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "baseline" }}
1089+
>
1090+
<span style={{ fontFamily: font.mono, fontSize: t.xs, wordBreak: "break-all" }}>
1091+
{row.personaDid}
1092+
</span>
1093+
<span style={{ color: c.muted, fontSize: t.sm }}>
1094+
in {contextHeading(records.find((r) => r.id === row.contextId), row.contextId)} ·{" "}
1095+
{row.claimCount} claim(s)
1096+
</span>
1097+
</div>
1098+
))
1099+
)}
1100+
{rows.length > 1 && (
1101+
// The whole reason a holder asks. Two personas presenting one profile
1102+
// present identical values, so anyone who sees both knows they are the
1103+
// same person — and no later narrowing undoes it for someone who
1104+
// already saw them.
1105+
<Note tone="warn">
1106+
{rows.length} personas present this profile. They disclose the same values, so anyone
1107+
who sees two of them knows they are the same person — permanently.
1108+
</Note>
1109+
)}
1110+
{unreadable.length > 0 && (
1111+
<Note tone="warn">
1112+
This answer is incomplete: your agent would not answer for {unreadable.join(", ")}. A
1113+
persona there could be presenting this profile without appearing above.
1114+
</Note>
1115+
)}
1116+
</div>
1117+
);
1118+
}
1119+
10231120
function ProfilesPanel({
10241121
parties,
10251122
authority,
10261123
attributes,
10271124
profiles,
1125+
records,
10281126
onChanged,
10291127
}: {
10301128
parties: Parties;
10311129
authority: Authority | null;
10321130
attributes: PoolAttribute[];
10331131
profiles: Async<PoolProfile[]>;
1132+
/** The contexts to look in when asked who presents a profile. */
1133+
records: ContextRecord[];
10341134
onChanged: () => void;
10351135
}) {
10361136
const [editing, setEditing] = useState<PoolProfile | null>(null);
10371137
const [creating, setCreating] = useState(false);
1038-
const [resolving, setResolving] = useState<string | null>(null);
1138+
/** Which row is expanded, and which of its two questions it is answering.
1139+
* One state rather than two, so opening either closes the other — a row
1140+
* showing both at once reads as one list. */
1141+
const [open, setOpen] = useState<{ profileId: string; view: "claims" | "where" } | null>(null);
10391142
const denied = holderGate(authority);
10401143

10411144
const byId = useMemo(
10421145
() => new Map(attributes.map((a) => [a.attributeId, a])),
10431146
[attributes],
10441147
);
10451148

1149+
const toggle = (profileId: string, view: "claims" | "where") =>
1150+
setOpen((current) =>
1151+
current?.profileId === profileId && current.view === view ? null : { profileId, view },
1152+
);
1153+
10461154
if (editing) {
10471155
return (
10481156
<ProfileEditor
@@ -1098,9 +1206,17 @@ function ProfilesPanel({
10981206
kind="quiet"
10991207
disabled={Boolean(denied)}
11001208
{...(denied ? { title: denied } : {})}
1101-
onClick={() => setResolving((id) => (id === p.profileId ? null : p.profileId))}
1209+
onClick={() => toggle(p.profileId, "claims")}
1210+
>
1211+
{open?.profileId === p.profileId && open.view === "claims" ? "Hide" : "What it presents"}
1212+
</Button>
1213+
<Button
1214+
kind="quiet"
1215+
disabled={Boolean(denied)}
1216+
{...(denied ? { title: denied } : {})}
1217+
onClick={() => toggle(p.profileId, "where")}
11021218
>
1103-
{resolving === p.profileId ? "Hide" : "What it presents"}
1219+
{open?.profileId === p.profileId && open.view === "where" ? "Hide" : "Who presents it"}
11041220
</Button>
11051221
<Button kind="quiet" onClick={() => setEditing(p)}>
11061222
Edit
@@ -1130,9 +1246,14 @@ function ProfilesPanel({
11301246
columns={columns}
11311247
rows={profiles.data}
11321248
rowKey={(p) => p.profileId}
1133-
expanded={(p) =>
1134-
resolving === p.profileId ? <ResolvedProfile parties={parties} profile={p} /> : null
1135-
}
1249+
expanded={(p) => {
1250+
if (open?.profileId !== p.profileId) return null;
1251+
return open.view === "claims" ? (
1252+
<ResolvedProfile parties={parties} profileId={p.profileId} name={p.name} />
1253+
) : (
1254+
<ProfileBindings parties={parties} profile={p} records={records} />
1255+
);
1256+
}}
11361257
empty="No profiles yet. Until there is one, no persona has anything to present."
11371258
/>
11381259
)}
@@ -1172,6 +1293,60 @@ function ProfilesPanel({
11721293
* whether bound, the profile's label, a claim count. Never the contents. */
11731294
type BindingRow = Awaited<ReturnType<typeof listBindings>>["personas"][number];
11741295

1296+
/**
1297+
* What one persona actually presents in one context.
1298+
*
1299+
* Two calls, and the first is not avoidable: `binding/list` gives a profile
1300+
* *name* and a count, never a `profileId` and never contents — thin by
1301+
* construction, because a binding read that returned values would make the
1302+
* disclosure gate decorative. So `binding/get` resolves the id, and
1303+
* `profile/get?resolve=true` resolves what it projects.
1304+
*
1305+
* **This is a truthful answer only because a pool edit now pushes.** The
1306+
* resolved profile is what the agent last materialised into the context; until
1307+
* VTI#1281 nothing called `rematerialise`, so the two could disagree and this
1308+
* view would have shown a holder values their verifiers were never given.
1309+
* Reading the profile is the right source *because* the push exists — not a
1310+
* convenient stand-in for the copy.
1311+
*/
1312+
function PersonaClaims({
1313+
parties,
1314+
contextId,
1315+
personaDid,
1316+
profileName,
1317+
}: {
1318+
parties: Parties;
1319+
contextId: string;
1320+
personaDid: string;
1321+
profileName: string;
1322+
}) {
1323+
const bound = useAsync(
1324+
async () => getBinding(managerSender, { ...parties, contextId, personaDid }),
1325+
[parties.holder.did, parties.service.did, contextId, personaDid],
1326+
);
1327+
1328+
if (bound.error) return <LoadError what={`what ${profileName} presents`} error={bound.error} />;
1329+
if (!bound.data) return <Loading what={`what ${profileName} presents`} />;
1330+
if (!bound.data.profileId) {
1331+
// `bound` was true a moment ago and the profile is gone now, or the agent
1332+
// withheld the id. Either way, say that rather than render an empty list
1333+
// that reads as "presents nothing".
1334+
return (
1335+
<div style={{ fontSize: t.sm, color: c.faint, padding: "6px 0" }}>
1336+
Your agent did not name the profile behind this binding, so there is nothing to resolve.
1337+
</div>
1338+
);
1339+
}
1340+
1341+
return (
1342+
<ResolvedProfile
1343+
parties={parties}
1344+
profileId={bound.data.profileId}
1345+
name={bound.data.profileName ?? profileName}
1346+
/>
1347+
);
1348+
}
1349+
11751350
function BindingsPanel({
11761351
parties,
11771352
authority,
@@ -1184,6 +1359,7 @@ function BindingsPanel({
11841359
records: ContextRecord[];
11851360
}) {
11861361
const [contextId, setContextId] = useState<string>("");
1362+
const [showing, setShowing] = useState<string | null>(null);
11871363
const [personaDid, setPersonaDid] = useState("");
11881364
const [profileId, setProfileId] = useState<string>("");
11891365
const [busy, setBusy] = useState(false);
@@ -1342,12 +1518,18 @@ function BindingsPanel({
13421518
key: "presents",
13431519
header: "Presents",
13441520
width: "220px",
1521+
// The profile name and a count are all `binding/list` returns — thin by
1522+
// construction at the agent, which never sends claim contents on this
1523+
// path. So the count is a link rather than an answer: it says how much
1524+
// there is, and clicking asks what it is.
13451525
render: (b) =>
13461526
b.bound ? (
1347-
<span>
1348-
{b.profileName ?? "a profile"}
1349-
<span style={{ color: c.muted }}> · {b.claimCount ?? 0} claim(s)</span>
1350-
</span>
1527+
<Button
1528+
kind="quiet"
1529+
onClick={() => setShowing((did) => (did === b.personaDid ? null : b.personaDid))}
1530+
>
1531+
{b.profileName ?? "a profile"} · {b.claimCount ?? 0} claim(s)
1532+
</Button>
13511533
) : (
13521534
<Pill tone="off">nothing</Pill>
13531535
),
@@ -1407,6 +1589,16 @@ function BindingsPanel({
14071589
columns={columns}
14081590
rows={bindings.data.personas}
14091591
rowKey={(b) => b.personaDid}
1592+
expanded={(b) =>
1593+
showing === b.personaDid && b.bound ? (
1594+
<PersonaClaims
1595+
parties={parties}
1596+
contextId={contextId}
1597+
personaDid={b.personaDid}
1598+
profileName={b.profileName ?? "this profile"}
1599+
/>
1600+
) : null
1601+
}
14101602
empty="No persona has ever been bound in this context. Name one below to start."
14111603
/>
14121604
{bindings.data.nextCursor && <Truncated what="the personas in this context" />}
@@ -1795,6 +1987,7 @@ export function PersonaPane({
17951987
authority={authority}
17961988
attributes={attributes.data ?? []}
17971989
profiles={profiles}
1990+
records={records}
17981991
onChanged={reloadAll}
17991992
/>
18001993

0 commit comments

Comments
 (0)