From c8c5c8882137a75a82a8dea98342212af8e6a6eb Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Wed, 23 Sep 2026 20:34:30 -0700 Subject: [PATCH 1/5] feat(profiles): show verified agent owner and archive state Co-authored-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- docs/profiles.md | 27 ++ .../profiles/ProfileAgentIdentity.test.tsx | 453 ++++++++++++++++++ src/bundled/profiles/ProfileAgentIdentity.tsx | 189 ++++++++ src/bundled/profiles/ProfilePanel.tsx | 8 + src/bundled/profiles/Profiles.module.css | 24 + src/features/agents/owner-attestation.test.ts | 67 +++ src/features/agents/owner-attestation.ts | 62 +++ 7 files changed, 830 insertions(+) create mode 100644 src/bundled/profiles/ProfileAgentIdentity.test.tsx create mode 100644 src/bundled/profiles/ProfileAgentIdentity.tsx create mode 100644 src/features/agents/owner-attestation.test.ts create mode 100644 src/features/agents/owner-attestation.ts diff --git a/docs/profiles.md b/docs/profiles.md index 15f878e30..735b4fefa 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -45,6 +45,33 @@ add the agent again from the managed-agent profile. Older unguarded invitation records can be promoted to guarded intent when reused through that profile flow. Remove from outbox does not revoke an invitation already dispatched to the relay. +## Agent identity + +For an identity with an agent hint (below), the Info tab adds a small **Agent** +section. It shows only claims backed by verifiable relay evidence: + +- **Owner** comes from the NIP-OA `auth` tag on the identity's own winning + signature-verified kind 0 (newest `created_at`, lower id on ties). A + session-owned `session.observe` view supplies that event: it merges live + events, refreshes on reconnect and resets on purge. Verification is bound + to that exact event id, so an auth-only change or a lagging older read + never keeps or restores a previous owner. It requires exactly one tag, + owner ≠ agent, conditions evaluated against the event, and a valid BIP-340 + signature over `nostr:agent-auth::`. A verified owner is + shown as "Authorized by …", never as the author. It opens the owner's + profile in the same slot when the host can open it. A missing or invalid + tag reads **Not verified**, and a failed read or unavailable view reads + **Unknown**. The existing `isAgent` shape check, avatar shape and local + library never supply an owner. +- **Archive** reuses the relay-scoped `session.archives` snapshot: + archived, not archived, or unknown when unavailable or failed. + +A failed read shows **Retry agent details**. Reopening the profile also retries +a failed archive read once. Nothing retries automatically in a loop. + +Agent type and capabilities are not shown: buzz-app has no reader or contract +for their source (old Buzz kind 10100). This section has no controls. + ## Boundaries - Shared message UI recognizes author-avatar targets and identity-bound mentions. diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx new file mode 100644 index 000000000..0d00451ef --- /dev/null +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -0,0 +1,453 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { bytesToHex } from "nostr-tools/utils"; +import { StrictMode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { profileTarget } from "../../features/profiles/target"; +import type { ReadFilter, RelayEvent } from "../../features/relay/events"; +import type { LiveCallbacks } from "../../features/relay/live"; +import type { RelayData } from "../../features/relay/service"; +import { + createRelaySession, + type RelaySession, +} from "../../features/relay/session"; +import { + keypair, + type Key, + profile, + signed, +} from "../../features/relay/testing"; +import { ProfilePanel } from "./ProfilePanel"; + +const relayKey = keypair(); +const owners: { dispose(): void }[] = []; +afterEach(() => { + cleanup(); + for (const owner of owners.splice(0)) owner.dispose(); +}); + +function auth(agent: Key, owner: Key, sign = owner) { + const digest = new Uint8Array( + createHash("sha256").update(`nostr:agent-auth:${agent.pubkey}:`).digest(), + ); + return [ + "auth", + owner.pubkey, + "", + bytesToHex(schnorr.sign(digest, sign.secret)), + ]; +} +function agentProfile(agent: Key, tags: string[][]) { + return signed(agent, { + kind: 0, + content: JSON.stringify({ name: "Helper" }), + tags, + }); +} +function mount( + target: Key, + respond: (filter: ReadFilter) => RelayEvent[], + { + archiveAuthority, + wrap, + }: { + archiveAuthority?: string; + wrap?: (session: RelaySession) => RelaySession; + } = {}, +) { + const query = vi.fn(async (filters: readonly ReadFilter[]) => + filters.flatMap((filter) => respond(filter)), + ); + const owner = createRelaySession({ + viewer: keypair().pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + query, + media: () => undefined, + ...(archiveAuthority ? { archiveAuthority } : {}), + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }); + owners.push(owner); + const snapshot = { + status: "ready" as const, + generation: 1, + session: wrap ? wrap(owner.session) : owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const open = vi.fn(() => true); + render( + {}} + context={{ channelId: "c", canOpen: () => true, open }} + />, + ); + return { query, open }; +} +const kind0 = (filter: ReadFilter) => filter.kinds?.includes(0); + +it("shows a verified owner with profile ingress and relay archive state", async () => { + const agent = keypair(); + const owner = keypair(); + const archive = signed(relayKey, { + kind: 13535, + content: "", + tags: [["-"], ["p", agent.pubkey]], + }); + const { open } = mount( + agent, + (filter) => + filter.kinds?.includes(13535) + ? [archive] + : filter.authors?.includes(owner.pubkey) + ? [profile(owner, { name: "Owner Olivia" })] + : kind0(filter) + ? [agentProfile(agent, [auth(agent, owner)])] + : [], + { archiveAuthority: relayKey.pubkey }, + ); + const identity = await screen.findByRole("region", { + name: "Agent identity", + }); + const ownerLink = await screen.findByRole("button", { + name: "Open owner profile: Owner Olivia", + }); + expect(identity).toHaveTextContent("Authorized by Owner Olivia"); + await waitFor(() => + expect(identity).toHaveTextContent("Archived on this relay"), + ); + await userEvent.setup().click(ownerLink); + expect(open).toHaveBeenCalledWith(profileTarget(owner.pubkey)); +}); + +it("does not trust a well-formed attestation with an invalid signature", async () => { + const agent = keypair(); + const owner = keypair(); + mount(agent, (filter) => + kind0(filter) ? [agentProfile(agent, [auth(agent, owner, keypair())])] : [], + ); + const identity = await screen.findByRole("region", { + name: "Agent identity", + }); + await waitFor(() => + expect(identity).toHaveTextContent( + "Not verified — no valid owner attestation.", + ), + ); + expect(identity).toHaveTextContent("ArchiveUnknown"); + expect(identity).not.toHaveTextContent("Authorized by"); + expect(screen.queryByRole("button", { name: /owner profile/ })).toBeNull(); +}); + +it("reports an unknown owner when no relay view is available, then retries", async () => { + const agent = keypair(); + const observe = vi.fn(() => { + throw new Error("Relay view capacity unavailable"); + }); + mount( + agent, + (filter) => + kind0(filter) ? [profile(agent, { name: "Helper", is_agent: true })] : [], + { + wrap: (session) => { + let calls = 0; + return Object.create(session, { + observe: { + value: (filters: readonly ReadFilter[]) => + calls++ ? session.observe(filters) : observe(), + }, + }); + }, + }, + ); + + const identity = await screen.findByRole("region", { + name: "Agent identity", + }); + expect(identity).toHaveTextContent( + "Unknown — could not read this profile's attestation.", + ); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Retry agent details" })); + await waitFor(() => + expect(identity).toHaveTextContent( + "Not verified — no valid owner attestation.", + ), + ); + expect(observe).toHaveBeenCalledTimes(1); +}); + +it("adds no agent section or reads for a profile without an agent hint", async () => { + const person = keypair(); + const { query } = mount(person, (filter) => + kind0(filter) ? [profile(person, { name: "Person" })] : [], + ); + await screen.findByRole("heading", { name: "Person" }); + expect(screen.queryByRole("region", { name: "Agent identity" })).toBeNull(); + expect(query).toHaveBeenCalledTimes(1); +}); + +function timedProfile( + agent: Key, + tags: string[][], + time: number, + name = "Helper", +) { + return signed(agent, { + kind: 0, + content: JSON.stringify({ name, is_agent: true }), + tags, + created_at: time, + }); +} +function live(agent: Key, first: RelayEvent, archives = false) { + let latest = first; + let callbacks!: LiveCallbacks; + let archiveOffline = true; + const query = vi.fn(async (filters: readonly ReadFilter[]) => + filters.flatMap((filter) => { + if (filter.kinds?.includes(13535)) { + if (archiveOffline) throw new Error("offline"); + return [ + signed(relayKey, { + kind: 13535, + content: "", + tags: [["-"], ["p", agent.pubkey]], + }), + ]; + } + return kind0(filter) && filter.authors?.includes(agent.pubkey) + ? [latest] + : []; + }), + ); + const owner = createRelaySession({ + viewer: keypair().pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + ...(archives ? { archiveAuthority: relayKey.pubkey } : {}), + query, + media: () => undefined, + subscribe: (next) => { + callbacks = next; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + owners.push(owner); + const snapshot = { + status: "ready" as const, + generation: 1, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + const props = { + relay, + target: profileTarget(agent.pubkey) ?? "", + close() {}, + context: { channelId: "c", canOpen: () => true, open: () => true }, + }; + const mounted = render(); + return { + session: owner.session, + query, + receive: (event: RelayEvent) => + act(async () => { + callbacks.receive([event]); + }), + setLatest(event: RelayEvent) { + latest = event; + }, + recoverArchives() { + archiveOffline = false; + }, + reopen() { + mounted.unmount(); + render(); + }, + rerender() { + mounted.rerender(); + }, + }; +} +const ownerButton = (owner: Key) => ({ + name: `Open owner profile: ${owner.pubkey.slice(0, 10)}…`, +}); +const archiveReads = (query: ReturnType["query"]) => + query.mock.calls.filter(([filters]) => + filters.some((filter) => filter.kinds?.includes(13535)), + ).length; + +it.each(["remove", "duplicate", "replace"])( + "follows the current signed profile after an auth-only %s", + async (mode) => { + const agent = keypair(); + const a = keypair(); + const b = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); + await screen.findByRole("button", ownerButton(a)); + const before = h.session.profiles.snapshot().get(agent.pubkey); + const tags = + mode === "remove" + ? [] + : mode === "duplicate" + ? [auth(agent, a), auth(agent, a)] + : [auth(agent, b)]; + const next = timedProfile(agent, tags, 101); + h.setLatest(next); + await h.receive(next); + // A duplicate keeps the projected owner, so only event provenance can drive it. + if (mode === "duplicate") + expect(h.session.profiles.snapshot().get(agent.pubkey)).toBe(before); + h.rerender(); + const identity = screen.getByRole("region", { name: "Agent identity" }); + if (mode === "replace") await screen.findByRole("button", ownerButton(b)); + else + await waitFor(() => + expect(identity).toHaveTextContent("Not verified — no valid owner"), + ); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); + }, +); + +it("does not resurrect older provenance from a lagging finite read", async () => { + const agent = keypair(); + const a = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); + await screen.findByRole("button", ownerButton(a)); + // Live knows the newer profile; the finite replica still serves the older head. + await h.receive(timedProfile(agent, [], 101, "Renamed")); + await screen.findByRole("heading", { name: "Renamed" }); + const identity = screen.getByRole("region", { name: "Agent identity" }); + await waitFor(() => + expect(identity).toHaveTextContent("Not verified — no valid owner"), + ); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); +}); + +it("uses the lower event id between equal-time profiles", async () => { + const agent = keypair(); + const a = keypair(); + const b = keypair(); + const first = timedProfile(agent, [auth(agent, a)], 100); + const second = timedProfile(agent, [auth(agent, b)], 100); + const [low, high] = first.id < second.id ? [first, second] : [second, first]; + const [winner, loser] = low === first ? [a, b] : [b, a]; + const h = live(agent, high); + await screen.findByRole("button", ownerButton(loser)); + await h.receive(low); + await screen.findByRole("button", ownerButton(winner)); + await h.receive(high); + h.rerender(); + expect(screen.getByRole("button", ownerButton(winner))).toBeInTheDocument(); + expect(screen.queryByRole("button", ownerButton(loser))).toBeNull(); +}); + +it("retries a failed archive read from the pane", async () => { + const agent = keypair(); + const owner = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, owner)], 100), true); + const retry = await screen.findByRole("button", { + name: "Retry agent details", + }); + const identity = screen.getByRole("region", { name: "Agent identity" }); + expect(identity).toHaveTextContent("ArchiveUnknown"); + h.recoverArchives(); + await userEvent.setup().click(retry); + await waitFor(() => + expect(identity).toHaveTextContent("Archived on this relay"), + ); + expect(archiveReads(h.query)).toBe(2); + expect( + screen.queryByRole("button", { name: "Retry agent details" }), + ).toBeNull(); +}); + +it("retries a failed archive read once when the profile is reopened", async () => { + const agent = keypair(); + const owner = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, owner)], 100), true); + await screen.findByRole("button", { name: "Retry agent details" }); + // The failure stays visible; no automatic retry loop. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(archiveReads(h.query)).toBe(1); + h.recoverArchives(); + h.reopen(); + const identity = await screen.findByRole("region", { + name: "Agent identity", + }); + await waitFor(() => + expect(identity).toHaveTextContent("Archived on this relay"), + ); + expect(archiveReads(h.query)).toBe(2); +}); + +it("settles a known agent with no public profile as not verified", async () => { + const agent = keypair(); + const owner = createRelaySession({ + viewer: keypair().pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + query: async () => [], + media: () => undefined, + readAgentLibrary: async () => ({ + definitions: [], + identities: [{ pubkey: agent.pubkey, name: "Unpublished agent" }], + }), + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }); + owners.push(owner); + await owner.session.agentLibrary.refresh(); + let observed: ReturnType | undefined; + const session: RelaySession = Object.create(owner.session, { + observe: { + value: (filters: readonly ReadFilter[]) => { + observed = owner.session.observe(filters); + return observed; + }, + }, + }); + const snapshot = { status: "ready" as const, generation: 1, session }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + render( + + {}} + /> + , + ); + await waitFor(() => expect(observed?.snapshot().status).toBe("ready")); + expect(observed?.snapshot().events).toHaveLength(0); + const identity = screen.getByRole("region", { name: "Agent identity" }); + await waitFor(() => + expect(identity).toHaveTextContent( + "Not verified — no valid owner attestation.", + ), + ); + expect(identity).not.toHaveTextContent("Checking…"); +}); diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx new file mode 100644 index 000000000..2b395c0d5 --- /dev/null +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { attestedOwner } from "../../features/agents/owner-attestation"; +import { useIdentityNames } from "../../features/identity-names/react"; +import type { PanelProps } from "../../features/panels/service"; +import { profileTarget } from "../../features/profiles/target"; +import { type EventData, newer } from "../../features/relay/events"; +import { selectProfiles } from "../../features/relay/profile-selection"; +import type { + EventViewSnapshot, + RelaySession, +} from "../../features/relay/session"; +import { Button } from "../../shared/design-system/ui/Button"; +import styles from "./Profiles.module.css"; + +type ProfileView = ReturnType; +const noSubscribe = () => () => {}; +const pendingView = (): EventViewSnapshot => pendingSnapshot; +const failedView = (): EventViewSnapshot => failedSnapshot; +const pendingSnapshot: EventViewSnapshot = Object.freeze({ + status: "loading", + events: [], +}); +const failedSnapshot: EventViewSnapshot = Object.freeze({ + status: "error", + events: [], + error: "Relay view unavailable", +}); + +/** Public agent provenance from verifiable relay evidence only. Mounted for an + * agent hint, which decides visibility but never who owns the key. */ +export function ProfileAgentIdentity({ + session, + pubkey, + context, +}: { + session: RelaySession; + pubkey: string; + context: PanelProps["context"]; +}) { + // A session-owned view: live events, reconnect refresh and purge, no polling. + const [attempt, setAttempt] = useState(0); + const [view, setView] = useState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery. + useEffect(() => { + let owned: ProfileView; + try { + owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); + } catch { + setView(null); // Capacity or a closed session: honestly unknown, retryable. + return; + } + setView(owned); + return owned.dispose; + }, [session, pubkey, attempt]); + const fallback = view === null ? failedView : pendingView; + const events = useSyncExternalStore( + view?.subscribe ?? noSubscribe, + view?.snapshot ?? fallback, + view?.snapshot ?? fallback, + ); + // Provenance belongs to the winning signed kind 0 alone, never a display projection. + const latest = events.events + .filter( + (event) => + event.kind === 0 && + event.pubkey === pubkey && + event.delivery !== "failed", + ) + .reduce(newer, undefined); + const [verified, setVerified] = useState<{ id: string; owner?: string }>(); + useEffect(() => { + if (!latest) return; + let active = true; + void attestedOwner(latest).then((owner) => { + if (active) setVerified({ id: latest.id, ...(owner ? { owner } : {}) }); + }); + return () => { + active = false; + }; + }, [latest]); + const archives = session.archives; + const archiveSnapshot = useSyncExternalStore( + archives.subscribe, + archives.snapshot, + archives.snapshot, + ); + const archive = archives.state(pubkey); + // Fetch from idle (first mount, purge, reconnect invalidation). A prior error is + // retried once per mount (reopening the profile) or by Retry, never in a loop. + useEffect(() => { + if (archives.snapshot().status === "error") void archives.refresh(); + }, [archives]); + useEffect(() => { + if (events.status === "idle") void view?.refresh(); + }, [view, events.status]); + useEffect(() => { + if (archiveSnapshot.status === "idle") void archives.ensure(); + }, [archives, archiveSnapshot.status]); + const current = verified && latest && verified.id === latest.id; + const owner = current ? verified.owner : undefined; + const failed = + (!latest && events.status === "error") || + archiveSnapshot.status === "error"; + return ( +
+

Agent

+
+
Owner
+
+ {owner ? ( + + ) : current || (!latest && events.status === "ready") ? ( + "Not verified — no valid owner attestation." + ) : !latest && events.status === "error" ? ( + "Unknown — could not read this profile's attestation." + ) : ( + "Checking…" + )} +
+
Archive
+
+ {archive === "archived" + ? "Archived on this relay" + : archive === "not-archived" + ? "Not archived" + : "Unknown"} +
+
+ {failed && ( + + )} +
+ ); +} + +function OwnerLink({ + session, + owner, + context, +}: { + session: RelaySession; + owner: string; + context: PanelProps["context"]; +}) { + const selection = useMemo( + () => selectProfiles(session.profiles, [owner]), + [session.profiles, owner], + ); + const profiles = useSyncExternalStore( + selection.subscribe, + selection.snapshot, + selection.snapshot, + ); + useEffect(() => { + void session.profiles.ensure([owner], "background").catch(() => {}); + }, [session, owner]); + const identityName = useIdentityNames(session.names); + const name = identityName( + owner, + profiles.get(owner)?.name ?? `${owner.slice(0, 10)}…`, + ); + const target = profileTarget(owner); + return ( + <> + Authorized by{" "} + {target && context?.canOpen(target) ? ( + + ) : ( + name + )} + + ); +} diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 0b75db760..7f5312670 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -27,6 +27,7 @@ import { selectProfiles } from "../../features/relay/profile-selection"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import type { RelaySession } from "../../features/relay/session"; +import { ProfileAgentIdentity } from "./ProfileAgentIdentity"; import styles from "./Profiles.module.css"; export function ProfilePanel({ @@ -178,6 +179,13 @@ function ProfileDetails({

{profile.about}

)} {children} + {agentPubkeys.has(pubkey) && ( + + )} ({ + pubkey: agent, + kind: 1, + created_at: 1713956400, + tags, + ...overrides, +}); + +it("returns the owner for the NIP-OA vector", async () => { + expect(await attestedOwner(event([["auth", owner, conditions, sig]]))).toBe( + owner, + ); +}); + +it.each([ + ["no tag", []], + [ + "two tags", + [ + ["auth", owner, conditions, sig], + ["auth", owner, conditions, sig], + ], + ], + ["five elements", [["auth", owner, conditions, sig, "x"]]], + ["trailing delimiter", [["auth", owner, `${conditions}&`, sig]]], + ["leading zero", [["auth", owner, "kind=01", sig]]], + [ + "reordered conditions", + [["auth", owner, "created_at<1713957000&kind=1", sig]], + ], + ["tampered signature", [["auth", owner, conditions, `${sig.slice(0, -1)}8`]]], + ["uppercase owner", [["auth", owner.toUpperCase(), conditions, sig]]], +])("rejects %s", async (_name, tags) => { + expect(await attestedOwner(event(tags))).toBeUndefined(); +}); + +it("evaluates conditions against the event", async () => { + const tag = [["auth", owner, conditions, sig]]; + expect(await attestedOwner(event(tag, { kind: 0 }))).toBeUndefined(); + expect( + await attestedOwner(event(tag, { created_at: 1713957000 })), + ).toBeUndefined(); +}); + +it("rejects self-attestation and another agent key", async () => { + expect( + await attestedOwner( + event([["auth", owner, conditions, sig]], { pubkey: owner }), + ), + ).toBeUndefined(); + expect( + await attestedOwner( + event([["auth", owner, conditions, sig]], { pubkey: "b".repeat(64) }), + ), + ).toBeUndefined(); +}); diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts new file mode 100644 index 000000000..b8c3e57f4 --- /dev/null +++ b/src/features/agents/owner-attestation.ts @@ -0,0 +1,62 @@ +import { schnorr } from "@noble/curves/secp256k1.js"; +import { hexToBytes } from "nostr-tools/utils"; +import type { EventData } from "../relay/events"; + +const KEY = /^[0-9a-f]{64}$/; +const SIG = /^[0-9a-f]{128}$/; +const CLAUSE = /^(kind=|created_at<|created_at>)(0|[1-9][0-9]*)$/; + +/** NIP-OA owner of a signature-verified event, or undefined. Provenance only: + * the event stays authored by `event.pubkey`; the owner is never an author. */ +export async function attestedOwner( + event: Pick, +): Promise { + const tags = event.tags.filter((tag) => tag[0] === "auth"); + const [, owner, conditions, sig] = tags[0] ?? []; + if ( + tags.length !== 1 || + tags[0]?.length !== 4 || + !owner || + conditions === undefined || + !sig || + !KEY.test(owner) || + !SIG.test(sig) || + owner === event.pubkey || + !satisfied(conditions, event) + ) + return undefined; + try { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode( + `nostr:agent-auth:${event.pubkey}:${conditions}`, + ), + ); + return schnorr.verify( + hexToBytes(sig), + new Uint8Array(digest), + hexToBytes(owner), + ) + ? owner + : undefined; + } catch { + return undefined; + } +} + +function satisfied( + conditions: string, + event: Pick, +) { + if (conditions === "") return true; + return conditions.split("&").every((clause) => { + const [, name, digits] = CLAUSE.exec(clause) ?? []; + if (!name || !digits) return false; + const value = Number(digits); + if (name === "kind=") return value <= 65535 && event.kind === value; + if (value > 4294967295) return false; + return name === "created_at<" + ? event.created_at < value + : event.created_at > value; + }); +} From f9880b695e9d2a94eef1f3aafe0fffb5e3c56525 Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Wed, 23 Sep 2026 21:00:22 -0700 Subject: [PATCH 2/5] fix(profiles): seed agent owner from directory head and use formatPublicKey fallback Co-authored-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- docs/profiles.md | 10 +- .../profiles/ProfileAgentIdentity.test.tsx | 138 +++++++++++++++--- src/bundled/profiles/ProfileAgentIdentity.tsx | 8 +- src/features/relay/profile-directory.ts | 3 + 4 files changed, 132 insertions(+), 27 deletions(-) diff --git a/docs/profiles.md b/docs/profiles.md index 735b4fefa..ee1d4c984 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -53,15 +53,19 @@ section. It shows only claims backed by verifiable relay evidence: - **Owner** comes from the NIP-OA `auth` tag on the identity's own winning signature-verified kind 0 (newest `created_at`, lower id on ties). A session-owned `session.observe` view supplies that event: it merges live - events, refreshes on reconnect and resets on purge. Verification is bound + events, refreshes on reconnect and resets on purge. The profile directory's + retained signed head also seeds that choice, so reopening the pane after + cache eviction never accepts an older response than the profile the session + already shows. Verification is bound to that exact event id, so an auth-only change or a lagging older read never keeps or restores a previous owner. It requires exactly one tag, owner ≠ agent, conditions evaluated against the event, and a valid BIP-340 signature over `nostr:agent-auth::`. A verified owner is shown as "Authorized by …", never as the author. It opens the owner's profile in the same slot when the host can open it. A missing or invalid - tag reads **Not verified**, and a failed read or unavailable view reads - **Unknown**. The existing `isAgent` shape check, avatar shape and local + tag reads **Not verified**, and a failed read or unavailable view with no + retained head reads **Unknown**. Without a profile name, the owner is shown + with `formatPublicKey`. The existing `isAgent` shape check, avatar shape and local library never supply an owner. - **Archive** reuses the relay-scoped `session.archives` snapshot: archived, not archived, or unknown when unavailable or failed. diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx index 0d00451ef..da38a7ecf 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.test.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -19,8 +19,10 @@ import { keypair, type Key, profile, + roster, signed, } from "../../features/relay/testing"; +import { formatPublicKey } from "../../shared/identity/public-key"; import { ProfilePanel } from "./ProfilePanel"; const relayKey = keypair(); @@ -54,9 +56,11 @@ function mount( { archiveAuthority, wrap, + library, }: { archiveAuthority?: string; wrap?: (session: RelaySession) => RelaySession; + library?: string; } = {}, ) { const query = vi.fn(async (filters: readonly ReadFilter[]) => @@ -69,9 +73,18 @@ function mount( query, media: () => undefined, ...(archiveAuthority ? { archiveAuthority } : {}), + ...(library + ? { + readAgentLibrary: async () => ({ + definitions: [], + identities: [{ pubkey: library, name: "Library agent" }], + }), + } + : {}), subscribe: () => ({ update() {}, retry() {}, dispose() {} }), }); owners.push(owner); + if (library) void owner.session.agentLibrary.refresh(); const snapshot = { status: "ready" as const, generation: 1, @@ -155,28 +168,27 @@ it("reports an unknown owner when no relay view is available, then retries", asy const observe = vi.fn(() => { throw new Error("Relay view capacity unavailable"); }); - mount( - agent, - (filter) => - kind0(filter) ? [profile(agent, { name: "Helper", is_agent: true })] : [], - { - wrap: (session) => { - let calls = 0; - return Object.create(session, { - observe: { - value: (filters: readonly ReadFilter[]) => - calls++ ? session.observe(filters) : observe(), - }, - }); - }, + // A library-known agent with no public profile: no directory head to fall back on. + mount(agent, () => [], { + library: agent.pubkey, + wrap: (session) => { + let calls = 0; + return Object.create(session, { + observe: { + value: (filters: readonly ReadFilter[]) => + calls++ ? session.observe(filters) : observe(), + }, + }); }, - ); + }); const identity = await screen.findByRole("region", { name: "Agent identity", }); - expect(identity).toHaveTextContent( - "Unknown — could not read this profile's attestation.", + await waitFor(() => + expect(identity).toHaveTextContent( + "Unknown — could not read this profile's attestation.", + ), ); await userEvent .setup() @@ -233,8 +245,9 @@ function live(agent: Key, first: RelayEvent, archives = false) { : []; }), ); + const viewer = keypair().pubkey; const owner = createRelaySession({ - viewer: keypair().pubkey, + viewer, relayAuthor: relayKey.pubkey, scope: "wss://relay.example.test", ...(archives ? { archiveAuthority: relayKey.pubkey } : {}), @@ -264,14 +277,21 @@ function live(agent: Key, first: RelayEvent, archives = false) { close() {}, context: { channelId: "c", canOpen: () => true, open: () => true }, }; - const mounted = render(); + let mounted = render(); return { session: owner.session, + viewer, query, - receive: (event: RelayEvent) => + receive: (...events: RelayEvent[]) => act(async () => { - callbacks.receive([event]); + callbacks.receive(events); }), + close() { + mounted.unmount(); + }, + open() { + mounted = render(); + }, setLatest(event: RelayEvent) { latest = event; }, @@ -288,8 +308,12 @@ function live(agent: Key, first: RelayEvent, archives = false) { }; } const ownerButton = (owner: Key) => ({ - name: `Open owner profile: ${owner.pubkey.slice(0, 10)}…`, + name: `Open owner profile: ${formatPublicKey(owner.pubkey)}`, }); +const agentReads = (query: ReturnType["query"]) => + query.mock.calls.filter(([filters]) => + filters.some((filter) => filter.kinds?.includes(0)), + ).length; const archiveReads = (query: ReturnType["query"]) => query.mock.calls.filter(([filters]) => filters.some((filter) => filter.kinds?.includes(13535)), @@ -451,3 +475,73 @@ it("settles a known agent with no public profile as not verified", async () => { ); expect(identity).not.toHaveTextContent("Checking…"); }); + +it("keeps the newer signed head after closing, cache eviction and a stale reopen read", async () => { + const agent = keypair(); + const a = keypair(); + const sender = keypair(); + const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); + await screen.findByRole("button", ownerButton(a)); + // The relay keeps serving the older attested head; live saw its removal. + await h.receive(roster(relayKey, "c", [h.viewer, sender.pubkey])); + await h.receive(timedProfile(agent, [], 101)); + await waitFor(() => + expect( + screen.getByRole("region", { name: "Agent identity" }), + ).toHaveTextContent("Not verified — no valid owner"), + ); + h.close(); + // Churn the session's 8 MiB recent-event cache past the t=101 profile. + for (let batch = 0; batch < 9; batch++) + await h.receive( + ...Array.from({ length: 80 }, (_, index) => + signed(sender, { + kind: 9, + created_at: 200 + batch * 80 + index, + content: "x".repeat(12000), + tags: [["h", "c"]], + }), + ), + ); + const probe = h.session.observe([ + { kinds: [0], authors: [agent.pubkey], limit: 1 }, + ]); + expect(probe.snapshot().events).toHaveLength(0); + probe.dispose(); + const reads = agentReads(h.query); + h.open(); + const identity = await screen.findByRole("region", { + name: "Agent identity", + }); + // The reopened view's read returns the stale t=100 attested profile. + await waitFor(() => expect(agentReads(h.query)).toBeGreaterThan(reads)); + await act(async () => {}); + await waitFor(() => + expect(identity).toHaveTextContent("Not verified — no valid owner"), + ); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); +}); + +it("uses the directory's signed head when no relay view is available", async () => { + const agent = keypair(); + const owner = keypair(); + mount( + agent, + (filter) => + kind0(filter) ? [agentProfile(agent, [auth(agent, owner)])] : [], + { + wrap: (session) => + Object.create(session, { + observe: { + value: () => { + throw new Error("Relay view capacity unavailable"); + }, + }, + }), + }, + ); + await screen.findByRole("button", ownerButton(owner)); + expect( + screen.queryByRole("button", { name: "Retry agent details" }), + ).toBeNull(); +}); diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx index 2b395c0d5..f5c527326 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -10,6 +10,7 @@ import type { RelaySession, } from "../../features/relay/session"; import { Button } from "../../shared/design-system/ui/Button"; +import { formatPublicKey } from "../../shared/identity/public-key"; import styles from "./Profiles.module.css"; type ProfileView = ReturnType; @@ -59,6 +60,9 @@ export function ProfileAgentIdentity({ view?.snapshot ?? fallback, ); // Provenance belongs to the winning signed kind 0 alone, never a display projection. + // The directory's retained head outlives this view, so a reopened pane cannot + // accept an older response than the profile the rest of the session shows. + const head = session.profiles.event?.(pubkey); const latest = events.events .filter( (event) => @@ -66,7 +70,7 @@ export function ProfileAgentIdentity({ event.pubkey === pubkey && event.delivery !== "failed", ) - .reduce(newer, undefined); + .reduce(newer, head); const [verified, setVerified] = useState<{ id: string; owner?: string }>(); useEffect(() => { if (!latest) return; @@ -166,7 +170,7 @@ function OwnerLink({ const identityName = useIdentityNames(session.names); const name = identityName( owner, - profiles.get(owner)?.name ?? `${owner.slice(0, 10)}…`, + profiles.get(owner)?.name ?? formatPublicKey(owner) ?? owner, ); const target = profileTarget(owner); return ( diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index 9590d273b..681cba6e5 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -12,6 +12,8 @@ export interface ProfileQueries { subscribe(listener: () => void): () => void; /** Fetch missing profiles; optional enrichment can yield to conversation reads. */ ensure(ids: readonly string[], priority?: Priority): Promise; + /** The winning signed kind 0 retained for one identity, for provenance checks. */ + event?(pubkey: string): RelayEvent | undefined; } /** One bounded source of signed profile events. Display values are derived from it. */ @@ -116,6 +118,7 @@ export function createProfileDirectory( }; }, ensure, + event: (pubkey: string) => events.peek(pubkey), }); let localProfiles = ""; const unsubscribeLocal = local?.subscribe(() => { From 470b7576b8e8a2a598682befed2fa7db1799218d Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Wed, 23 Sep 2026 21:23:35 -0700 Subject: [PATCH 3/5] fix(profiles): seed agent owner from directory head only with a live view Co-authored-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- docs/profiles.md | 12 ++++++------ src/bundled/profiles/ProfileAgentIdentity.test.tsx | 14 ++++++++++---- src/bundled/profiles/ProfileAgentIdentity.tsx | 3 ++- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/profiles.md b/docs/profiles.md index ee1d4c984..cfed03b57 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -53,18 +53,18 @@ section. It shows only claims backed by verifiable relay evidence: - **Owner** comes from the NIP-OA `auth` tag on the identity's own winning signature-verified kind 0 (newest `created_at`, lower id on ties). A session-owned `session.observe` view supplies that event: it merges live - events, refreshes on reconnect and resets on purge. The profile directory's - retained signed head also seeds that choice, so reopening the pane after - cache eviction never accepts an older response than the profile the session - already shows. Verification is bound + events, refreshes on reconnect and resets on purge. While that view is live, + the profile directory's retained signed head also seeds that choice, so + reopening the pane after cache eviction never accepts an older response than + the profile the session already shows. Verification is bound to that exact event id, so an auth-only change or a lagging older read never keeps or restores a previous owner. It requires exactly one tag, owner ≠ agent, conditions evaluated against the event, and a valid BIP-340 signature over `nostr:agent-auth::`. A verified owner is shown as "Authorized by …", never as the author. It opens the owner's profile in the same slot when the host can open it. A missing or invalid - tag reads **Not verified**, and a failed read or unavailable view with no - retained head reads **Unknown**. Without a profile name, the owner is shown + tag reads **Not verified**, and a failed read with no retained head or an + unavailable view reads **Unknown**. Without a profile name, the owner is shown with `formatPublicKey`. The existing `isAgent` shape check, avatar shape and local library never supply an owner. - **Archive** reuses the relay-scoped `session.archives` snapshot: diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx index da38a7ecf..57b21b0de 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.test.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -522,7 +522,7 @@ it("keeps the newer signed head after closing, cache eviction and a stale reopen expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); }); -it("uses the directory's signed head when no relay view is available", async () => { +it("stays unknown without a relay view even when the directory holds a head", async () => { const agent = keypair(); const owner = keypair(); mount( @@ -540,8 +540,14 @@ it("uses the directory's signed head when no relay view is available", async () }), }, ); - await screen.findByRole("button", ownerButton(owner)); + await screen.findByRole("heading", { name: "Helper" }); + const identity = screen.getByRole("region", { name: "Agent identity" }); + // No view can signal an auth-only removal, so the directory head is not trusted here. + expect(identity).toHaveTextContent( + "Unknown — could not read this profile's attestation.", + ); + expect(screen.queryByRole("button", ownerButton(owner))).toBeNull(); expect( - screen.queryByRole("button", { name: "Retry agent details" }), - ).toBeNull(); + screen.getByRole("button", { name: "Retry agent details" }), + ).toBeInTheDocument(); }); diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx index f5c527326..d2a6a71b4 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -62,7 +62,8 @@ export function ProfileAgentIdentity({ // Provenance belongs to the winning signed kind 0 alone, never a display projection. // The directory's retained head outlives this view, so a reopened pane cannot // accept an older response than the profile the rest of the session shows. - const head = session.profiles.event?.(pubkey); + // Only a live view re-renders on auth-only head changes; without one, stay Unknown. + const head = view ? session.profiles.event?.(pubkey) : undefined; const latest = events.events .filter( (event) => From d0c833caaaabb17334c8f4714ec3afb779ebcafb Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 07:53:49 -0700 Subject: [PATCH 4/5] fix(profiles): notify profile directory subscribers on winning-event changes The directory suppressed notifications when a newer signed kind 0 kept the same display fields, so a head restored from disk (store.ts accept) never re-rendered a mounted agent pane and it kept showing a removed owner. Notify on winning-event changes and read the head in the pane through useSyncExternalStore. Adds real view-cap auth-only regressions. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- docs/profiles.md | 4 +- .../profiles/ProfileAgentIdentity.test.tsx | 169 ++++++++++++++++++ src/bundled/profiles/ProfileAgentIdentity.tsx | 10 +- src/features/relay/profile-details.test.ts | 5 +- src/features/relay/profile-directory.ts | 13 +- 5 files changed, 190 insertions(+), 11 deletions(-) diff --git a/docs/profiles.md b/docs/profiles.md index cfed03b57..10e031e4c 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -56,7 +56,9 @@ section. It shows only claims backed by verifiable relay evidence: events, refreshes on reconnect and resets on purge. While that view is live, the profile directory's retained signed head also seeds that choice, so reopening the pane after cache eviction never accepts an older response than - the profile the session already shows. Verification is bound + the profile the session already shows. The directory notifies subscribers + when that head changes even if display fields do not, so a head restored + from disk also updates the pane. Verification is bound to that exact event id, so an auth-only change or a lagging older read never keeps or restores a previous owner. It requires exactly one tag, owner ≠ agent, conditions evaluated against the event, and a valid BIP-340 diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx index 57b21b0de..e8e18278f 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.test.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -15,9 +15,16 @@ import { createRelaySession, type RelaySession, } from "../../features/relay/session"; +import type { + HeadPersistence, + SavedHead, +} from "../../features/relay/persistence"; import { + bounds, keypair, type Key, + message, + metadata, profile, roster, signed, @@ -551,3 +558,165 @@ it("stays unknown without a relay view even when the directory holds a head", as screen.getByRole("button", { name: "Retry agent details" }), ).toBeInTheDocument(); }); + +it("follows an auth-only head restored from disk while the pane is mounted", async () => { + const agent = keypair(); + const a = keypair(); + const viewer = keypair(); + const attested = timedProfile(agent, [auth(agent, a)], 100); + const removed = timedProfile(agent, [], 101); + let releaseDisk!: (records: SavedHead[]) => void; + const disk = new Promise((resolve) => { + releaseDisk = resolve; + }); + let readStarted!: () => void; + const reading = new Promise((resolve) => { + readStarted = resolve; + }); + const persistence: HeadPersistence = { + read: () => { + readStarted(); + return disk; + }, + write: async () => {}, + retain: async () => {}, + remove: async () => {}, + clear: async () => {}, + close() {}, + }; + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relayKey.pubkey, + scope: "wss://relay.example.test", + // The relay keeps serving the older, owner-attested profile. + query: async (filters: readonly ReadFilter[]) => + filters.some((filter) => filter.kinds?.includes(39002)) + ? [ + roster(relayKey, "a", [viewer.pubkey]), + metadata(relayKey, "a", "A"), + ] + : filters.some( + (filter) => + kind0(filter) && filter.authors?.includes(agent.pubkey), + ) + ? [attested] + : [], + media: () => undefined, + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }, + { prepared: true, persistence }, + ); + owners.push(owner); + const connection = { + status: "ready" as const, + generation: 1, + session: owner.session, + }; + const relay: RelayData = { + snapshot: () => connection, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + clearCache: async () => {}, + }; + owner.session.channels.ensureList(); + await reading; + render( + {}} + context={{ channelId: "a", canOpen: () => true, open: () => true }} + />, + ); + await screen.findByRole("button", ownerButton(a)); + releaseDisk([ + { + channelId: "a", + savedAt: Date.now(), + events: [ + message(agent, "a", "hi", 90), + bounds(relayKey, "a", "head", { has_more: false, next_cursor: null }), + ], + profiles: [removed], + }, + ]); + const identity = screen.getByRole("region", { name: "Agent identity" }); + await waitFor(() => + expect(identity).toHaveTextContent( + "Not verified — no valid owner attestation.", + ), + ); + expect(owner.session.profiles.event?.(agent.pubkey)?.id).toBe(removed.id); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); +}); + +it.each(["remove", "replace", "equal-time removal"])( + "stays unknown at the real view cap through an auth-only %s, then Retry recovers", + async (mode) => { + const agent = keypair(); + const a = keypair(); + const b = keypair(); + const first = timedProfile(agent, [auth(agent, a)], 100); + let next = timedProfile( + agent, + mode === "replace" ? [auth(agent, b)] : [], + 101, + ); + if (mode === "equal-time removal") + for ( + let nonce = 0; + next.created_at !== 100 || next.id > first.id; + nonce++ + ) + next = timedProfile(agent, [["nonce", String(nonce)]], 100); + const h = live(agent, first); + await screen.findByRole("button", ownerButton(a)); + h.close(); + const fillers: ReturnType[] = []; + try { + for (;;) fillers.push(h.session.observe([{ kinds: [1], limit: 1 }])); + } catch {} + try { + h.open(); + const identity = await screen.findByRole("region", { + name: "Agent identity", + }); + const unknown = async () => { + await waitFor(() => + expect(identity).toHaveTextContent( + "Unknown — could not read this profile's attestation.", + ), + ); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); + }; + await unknown(); + // The relay keeps serving the older attested head; live delivers the change. + await h.receive(next); + expect(h.session.profiles.event?.(agent.pubkey)?.id).toBe(next.id); + await unknown(); + const retry = () => + userEvent + .setup() + .click(screen.getByRole("button", { name: "Retry agent details" })); + await retry(); + await unknown(); + fillers.pop()?.dispose(); + await retry(); + if (mode === "replace") await screen.findByRole("button", ownerButton(b)); + else + await waitFor(() => + expect(identity).toHaveTextContent("Not verified — no valid owner"), + ); + expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); + // The recovered view keeps following later auth-only changes. + await h.receive(timedProfile(agent, [], 102)); + await waitFor(() => + expect(identity).toHaveTextContent("Not verified — no valid owner"), + ); + } finally { + for (const filler of fillers) filler.dispose(); + } + }, +); diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx index d2a6a71b4..4df56042e 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -62,8 +62,14 @@ export function ProfileAgentIdentity({ // Provenance belongs to the winning signed kind 0 alone, never a display projection. // The directory's retained head outlives this view, so a reopened pane cannot // accept an older response than the profile the rest of the session shows. - // Only a live view re-renders on auth-only head changes; without one, stay Unknown. - const head = view ? session.profiles.event?.(pubkey) : undefined; + // Subscribed, so any head change (live, read or disk restore) re-renders. + // Without a live view, stay Unknown. + const directoryHead = useSyncExternalStore( + session.profiles.subscribe, + () => session.profiles.event?.(pubkey), + () => session.profiles.event?.(pubkey), + ); + const head = view ? directoryHead : undefined; const latest = events.events .filter( (event) => diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index a60b24c6f..9ca1c96a0 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -101,9 +101,10 @@ it.each([ expect(selectedChanged).toHaveBeenCalledTimes(1); // A newer event with identical display values must still preserve identity. + // The directory still notifies: its winning signed event changed. directory.accept([profile(user, { name: "Mic", [field]: true }, 3)]); expect(selection.snapshot()).toBe(added); - expect(directoryChanged).toHaveBeenCalledTimes(1); + expect(directoryChanged).toHaveBeenCalledTimes(2); expect(selectedChanged).toHaveBeenCalledTimes(1); directory.accept([profile(user, { name: "Mic", ...removal }, 4)]); @@ -113,7 +114,7 @@ it.each([ directory.queries.snapshot().get(user.pubkey), ); expect(removed.get(user.pubkey)).toEqual({ name: "Mic" }); - expect(directoryChanged).toHaveBeenCalledTimes(2); + expect(directoryChanged).toHaveBeenCalledTimes(3); expect(selectedChanged).toHaveBeenCalledTimes(2); } finally { unsubscribeSelection(); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index 681cba6e5..a795e3616 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -38,9 +38,10 @@ export function createProfileDirectory( events.set(event.pubkey, event); changed = true; } - if (changed) publish(); + if (changed) publish(true); } - function publish() { + /** Notifies on display changes, and on winning-event changes for `event()` readers. */ + function publish(headChanged = false) { const next = foldProfiles([ ...events.keys().flatMap((id) => { const event = events.peek(id); @@ -63,11 +64,11 @@ export function createProfileDirectory( next.set(id, old); } if ( - next.size === snapshot.size && - [...next].every(([id, value]) => snapshot.get(id) === value) + next.size !== snapshot.size || + [...next].some(([id, value]) => snapshot.get(id) !== value) ) - return; - snapshot = next; + snapshot = next; + else if (!headChanged) return; for (const listener of listeners) notify(listener); } async function ensure( From 89bbe768dfbc689cbac85067613a1c020cd72ea6 Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 12:19:32 -0700 Subject: [PATCH 5/5] fix(profiles): match buzz "managed by" owner row and drop archive row Rename the owner row to "Managed by" with "(you)" for the viewer, hide it unless a verified owner is bound to the latest profile event, and drop the archive row, matching base Buzz desktop. Verification binding, the live-view gate, and directory head notification are unchanged. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- docs/profiles.md | 37 +- .../profiles/ProfileAgentIdentity.test.tsx | 330 ++++++------------ src/bundled/profiles/ProfileAgentIdentity.tsx | 104 ++---- src/bundled/profiles/ProfilePanel.tsx | 1 + src/bundled/profiles/Profiles.module.css | 14 - 5 files changed, 163 insertions(+), 323 deletions(-) diff --git a/docs/profiles.md b/docs/profiles.md index 10e031e4c..222c0a576 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -47,10 +47,11 @@ Remove from outbox does not revoke an invitation already dispatched to the relay ## Agent identity -For an identity with an agent hint (below), the Info tab adds a small **Agent** -section. It shows only claims backed by verifiable relay evidence: +For an identity with an agent hint (below), the Info tab adds a **Managed by** +row, matching Buzz desktop. It shows only a verified owner and is otherwise +absent: -- **Owner** comes from the NIP-OA `auth` tag on the identity's own winning +- The owner comes from the NIP-OA `auth` tag on the identity's own winning signature-verified kind 0 (newest `created_at`, lower id on ties). A session-owned `session.observe` view supplies that event: it merges live events, refreshes on reconnect and resets on purge. While that view is live, @@ -58,25 +59,21 @@ section. It shows only claims backed by verifiable relay evidence: reopening the pane after cache eviction never accepts an older response than the profile the session already shows. The directory notifies subscribers when that head changes even if display fields do not, so a head restored - from disk also updates the pane. Verification is bound - to that exact event id, so an auth-only change or a lagging older read - never keeps or restores a previous owner. It requires exactly one tag, - owner ≠ agent, conditions evaluated against the event, and a valid BIP-340 - signature over `nostr:agent-auth::`. A verified owner is - shown as "Authorized by …", never as the author. It opens the owner's - profile in the same slot when the host can open it. A missing or invalid - tag reads **Not verified**, and a failed read with no retained head or an - unavailable view reads **Unknown**. Without a profile name, the owner is shown - with `formatPublicKey`. The existing `isAgent` shape check, avatar shape and local - library never supply an owner. -- **Archive** reuses the relay-scoped `session.archives` snapshot: - archived, not archived, or unknown when unavailable or failed. - -A failed read shows **Retry agent details**. Reopening the profile also retries -a failed archive read once. Nothing retries automatically in a loop. + from disk also updates the pane. Verification is bound to that exact event + id, so an auth-only change or a lagging older read never keeps or restores a + previous owner. It requires exactly one tag, owner ≠ agent, conditions + evaluated against the event, and a valid BIP-340 signature over + `nostr:agent-auth::`. +- The row shows the owner's name (`formatPublicKey` without a profile name), + with "(you)" when the viewer is the owner. It opens the owner's profile in + the same slot when the host can open it. +- A missing or invalid tag, a failed read, or no available view shows no row. + Without a view nothing can signal an auth-only change, so the retained head is + not trusted; reopening the profile retries. The existing `isAgent` shape + check, avatar shape and local library never supply an owner. Agent type and capabilities are not shown: buzz-app has no reader or contract -for their source (old Buzz kind 10100). This section has no controls. +for their source (old Buzz kind 10100). This row has no controls. ## Boundaries diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx index e8e18278f..ef0ad1a7b 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.test.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -7,6 +7,7 @@ import userEvent from "@testing-library/user-event"; import { bytesToHex } from "nostr-tools/utils"; import { StrictMode } from "react"; import { afterEach, expect, it, vi } from "vitest"; +import { attestedOwner } from "../../features/agents/owner-attestation"; import { profileTarget } from "../../features/profiles/target"; import type { ReadFilter, RelayEvent } from "../../features/relay/events"; import type { LiveCallbacks } from "../../features/relay/live"; @@ -32,10 +33,36 @@ import { import { formatPublicKey } from "../../shared/identity/public-key"; import { ProfilePanel } from "./ProfilePanel"; +vi.mock("../../features/agents/owner-attestation", async (original) => { + const actual = + await original(); + return { attestedOwner: vi.fn(actual.attestedOwner) }; +}); +const verify = vi.mocked(attestedOwner); +/** Completion barrier: verification of exactly `event` (after call `since`) has + * finished and React has committed its result. */ +async function verifiedFor(event: RelayEvent, since = 0) { + await waitFor(() => + expect( + verify.mock.calls + .slice(since) + .some(([seen]) => (seen as RelayEvent).id === event.id), + ).toBe(true), + ); + await act(async () => { + await Promise.all( + verify.mock.results.slice(since).map((result) => result.value), + ); + }); +} +const identityRegion = () => + screen.queryByRole("region", { name: "Agent identity" }); + const relayKey = keypair(); const owners: { dispose(): void }[] = []; afterEach(() => { cleanup(); + verify.mockClear(); for (const owner of owners.splice(0)) owner.dispose(); }); @@ -61,25 +88,24 @@ function mount( target: Key, respond: (filter: ReadFilter) => RelayEvent[], { - archiveAuthority, wrap, library, + viewer = keypair().pubkey, }: { - archiveAuthority?: string; wrap?: (session: RelaySession) => RelaySession; library?: string; + viewer?: string; } = {}, ) { const query = vi.fn(async (filters: readonly ReadFilter[]) => filters.flatMap((filter) => respond(filter)), ); const owner = createRelaySession({ - viewer: keypair().pubkey, + viewer, relayAuthor: relayKey.pubkey, scope: "wss://relay.example.test", query, media: () => undefined, - ...(archiveAuthority ? { archiveAuthority } : {}), ...(library ? { readAgentLibrary: async () => ({ @@ -95,6 +121,7 @@ function mount( const snapshot = { status: "ready" as const, generation: 1, + viewer, session: wrap ? wrap(owner.session) : owner.session, }; const relay: RelayData = { @@ -117,95 +144,52 @@ function mount( } const kind0 = (filter: ReadFilter) => filter.kinds?.includes(0); -it("shows a verified owner with profile ingress and relay archive state", async () => { +it("shows a verified owner as Managed by with profile ingress", async () => { const agent = keypair(); const owner = keypair(); - const archive = signed(relayKey, { - kind: 13535, - content: "", - tags: [["-"], ["p", agent.pubkey]], - }); - const { open } = mount( - agent, - (filter) => - filter.kinds?.includes(13535) - ? [archive] - : filter.authors?.includes(owner.pubkey) - ? [profile(owner, { name: "Owner Olivia" })] - : kind0(filter) - ? [agentProfile(agent, [auth(agent, owner)])] - : [], - { archiveAuthority: relayKey.pubkey }, + const { open } = mount(agent, (filter) => + filter.authors?.includes(owner.pubkey) + ? [profile(owner, { name: "Owner Olivia" })] + : kind0(filter) + ? [agentProfile(agent, [auth(agent, owner)])] + : [], ); - const identity = await screen.findByRole("region", { - name: "Agent identity", - }); const ownerLink = await screen.findByRole("button", { name: "Open owner profile: Owner Olivia", }); - expect(identity).toHaveTextContent("Authorized by Owner Olivia"); - await waitFor(() => - expect(identity).toHaveTextContent("Archived on this relay"), - ); + expect(identityRegion()).toHaveTextContent("Managed byOwner Olivia"); + expect(identityRegion()).not.toHaveTextContent("(you)"); await userEvent.setup().click(ownerLink); expect(open).toHaveBeenCalledWith(profileTarget(owner.pubkey)); }); -it("does not trust a well-formed attestation with an invalid signature", async () => { +it("marks the owner as you when the viewer owns the agent", async () => { const agent = keypair(); const owner = keypair(); - mount(agent, (filter) => - kind0(filter) ? [agentProfile(agent, [auth(agent, owner, keypair())])] : [], + mount( + agent, + (filter) => + filter.authors?.includes(owner.pubkey) + ? [profile(owner, { name: "Owner Olivia" })] + : kind0(filter) + ? [agentProfile(agent, [auth(agent, owner)])] + : [], + { viewer: owner.pubkey }, ); - const identity = await screen.findByRole("region", { - name: "Agent identity", + await screen.findByRole("button", { + name: "Open owner profile: Owner Olivia (you)", }); - await waitFor(() => - expect(identity).toHaveTextContent( - "Not verified — no valid owner attestation.", - ), - ); - expect(identity).toHaveTextContent("ArchiveUnknown"); - expect(identity).not.toHaveTextContent("Authorized by"); - expect(screen.queryByRole("button", { name: /owner profile/ })).toBeNull(); + expect(identityRegion()).toHaveTextContent("Managed byOwner Olivia (you)"); }); -it("reports an unknown owner when no relay view is available, then retries", async () => { +it("does not trust a well-formed attestation with an invalid signature", async () => { const agent = keypair(); - const observe = vi.fn(() => { - throw new Error("Relay view capacity unavailable"); - }); - // A library-known agent with no public profile: no directory head to fall back on. - mount(agent, () => [], { - library: agent.pubkey, - wrap: (session) => { - let calls = 0; - return Object.create(session, { - observe: { - value: (filters: readonly ReadFilter[]) => - calls++ ? session.observe(filters) : observe(), - }, - }); - }, - }); - - const identity = await screen.findByRole("region", { - name: "Agent identity", - }); - await waitFor(() => - expect(identity).toHaveTextContent( - "Unknown — could not read this profile's attestation.", - ), - ); - await userEvent - .setup() - .click(screen.getByRole("button", { name: "Retry agent details" })); - await waitFor(() => - expect(identity).toHaveTextContent( - "Not verified — no valid owner attestation.", - ), - ); - expect(observe).toHaveBeenCalledTimes(1); + const owner = keypair(); + const forged = agentProfile(agent, [auth(agent, owner, keypair())]); + mount(agent, (filter) => (kind0(filter) ? [forged] : [])); + await verifiedFor(forged); + expect(identityRegion()).toBeNull(); + expect(screen.queryByRole("button", { name: /owner profile/ })).toBeNull(); }); it("adds no agent section or reads for a profile without an agent hint", async () => { @@ -231,33 +215,19 @@ function timedProfile( created_at: time, }); } -function live(agent: Key, first: RelayEvent, archives = false) { +function live(agent: Key, first: RelayEvent) { let latest = first; let callbacks!: LiveCallbacks; - let archiveOffline = true; const query = vi.fn(async (filters: readonly ReadFilter[]) => - filters.flatMap((filter) => { - if (filter.kinds?.includes(13535)) { - if (archiveOffline) throw new Error("offline"); - return [ - signed(relayKey, { - kind: 13535, - content: "", - tags: [["-"], ["p", agent.pubkey]], - }), - ]; - } - return kind0(filter) && filter.authors?.includes(agent.pubkey) - ? [latest] - : []; - }), + filters.flatMap((filter) => + kind0(filter) && filter.authors?.includes(agent.pubkey) ? [latest] : [], + ), ); const viewer = keypair().pubkey; const owner = createRelaySession({ viewer, relayAuthor: relayKey.pubkey, scope: "wss://relay.example.test", - ...(archives ? { archiveAuthority: relayKey.pubkey } : {}), query, media: () => undefined, subscribe: (next) => { @@ -302,9 +272,6 @@ function live(agent: Key, first: RelayEvent, archives = false) { setLatest(event: RelayEvent) { latest = event; }, - recoverArchives() { - archiveOffline = false; - }, reopen() { mounted.unmount(); render(); @@ -321,10 +288,6 @@ const agentReads = (query: ReturnType["query"]) => query.mock.calls.filter(([filters]) => filters.some((filter) => filter.kinds?.includes(0)), ).length; -const archiveReads = (query: ReturnType["query"]) => - query.mock.calls.filter(([filters]) => - filters.some((filter) => filter.kinds?.includes(13535)), - ).length; it.each(["remove", "duplicate", "replace"])( "follows the current signed profile after an auth-only %s", @@ -348,12 +311,11 @@ it.each(["remove", "duplicate", "replace"])( if (mode === "duplicate") expect(h.session.profiles.snapshot().get(agent.pubkey)).toBe(before); h.rerender(); - const identity = screen.getByRole("region", { name: "Agent identity" }); if (mode === "replace") await screen.findByRole("button", ownerButton(b)); - else - await waitFor(() => - expect(identity).toHaveTextContent("Not verified — no valid owner"), - ); + else { + await verifiedFor(next); + expect(identityRegion()).toBeNull(); + } expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); }, ); @@ -364,12 +326,11 @@ it("does not resurrect older provenance from a lagging finite read", async () => const h = live(agent, timedProfile(agent, [auth(agent, a)], 100)); await screen.findByRole("button", ownerButton(a)); // Live knows the newer profile; the finite replica still serves the older head. - await h.receive(timedProfile(agent, [], 101, "Renamed")); + const renamed = timedProfile(agent, [], 101, "Renamed"); + await h.receive(renamed); await screen.findByRole("heading", { name: "Renamed" }); - const identity = screen.getByRole("region", { name: "Agent identity" }); - await waitFor(() => - expect(identity).toHaveTextContent("Not verified — no valid owner"), - ); + await verifiedFor(renamed); + expect(identityRegion()).toBeNull(); expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); }); @@ -391,46 +352,7 @@ it("uses the lower event id between equal-time profiles", async () => { expect(screen.queryByRole("button", ownerButton(loser))).toBeNull(); }); -it("retries a failed archive read from the pane", async () => { - const agent = keypair(); - const owner = keypair(); - const h = live(agent, timedProfile(agent, [auth(agent, owner)], 100), true); - const retry = await screen.findByRole("button", { - name: "Retry agent details", - }); - const identity = screen.getByRole("region", { name: "Agent identity" }); - expect(identity).toHaveTextContent("ArchiveUnknown"); - h.recoverArchives(); - await userEvent.setup().click(retry); - await waitFor(() => - expect(identity).toHaveTextContent("Archived on this relay"), - ); - expect(archiveReads(h.query)).toBe(2); - expect( - screen.queryByRole("button", { name: "Retry agent details" }), - ).toBeNull(); -}); - -it("retries a failed archive read once when the profile is reopened", async () => { - const agent = keypair(); - const owner = keypair(); - const h = live(agent, timedProfile(agent, [auth(agent, owner)], 100), true); - await screen.findByRole("button", { name: "Retry agent details" }); - // The failure stays visible; no automatic retry loop. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(archiveReads(h.query)).toBe(1); - h.recoverArchives(); - h.reopen(); - const identity = await screen.findByRole("region", { - name: "Agent identity", - }); - await waitFor(() => - expect(identity).toHaveTextContent("Archived on this relay"), - ); - expect(archiveReads(h.query)).toBe(2); -}); - -it("settles a known agent with no public profile as not verified", async () => { +it("shows no owner for a known agent with no public profile", async () => { const agent = keypair(); const owner = createRelaySession({ viewer: keypair().pubkey, @@ -474,13 +396,9 @@ it("settles a known agent with no public profile as not verified", async () => { ); await waitFor(() => expect(observed?.snapshot().status).toBe("ready")); expect(observed?.snapshot().events).toHaveLength(0); - const identity = screen.getByRole("region", { name: "Agent identity" }); - await waitFor(() => - expect(identity).toHaveTextContent( - "Not verified — no valid owner attestation.", - ), - ); - expect(identity).not.toHaveTextContent("Checking…"); + await act(async () => {}); + expect(identityRegion()).toBeNull(); + expect(verify).not.toHaveBeenCalled(); }); it("keeps the newer signed head after closing, cache eviction and a stale reopen read", async () => { @@ -491,12 +409,10 @@ it("keeps the newer signed head after closing, cache eviction and a stale reopen await screen.findByRole("button", ownerButton(a)); // The relay keeps serving the older attested head; live saw its removal. await h.receive(roster(relayKey, "c", [h.viewer, sender.pubkey])); - await h.receive(timedProfile(agent, [], 101)); - await waitFor(() => - expect( - screen.getByRole("region", { name: "Agent identity" }), - ).toHaveTextContent("Not verified — no valid owner"), - ); + const removed = timedProfile(agent, [], 101); + await h.receive(removed); + await verifiedFor(removed); + expect(identityRegion()).toBeNull(); h.close(); // Churn the session's 8 MiB recent-event cache past the t=101 profile. for (let batch = 0; batch < 9; batch++) @@ -516,20 +432,17 @@ it("keeps the newer signed head after closing, cache eviction and a stale reopen expect(probe.snapshot().events).toHaveLength(0); probe.dispose(); const reads = agentReads(h.query); + const since = verify.mock.calls.length; h.open(); - const identity = await screen.findByRole("region", { - name: "Agent identity", - }); // The reopened view's read returns the stale t=100 attested profile. await waitFor(() => expect(agentReads(h.query)).toBeGreaterThan(reads)); await act(async () => {}); - await waitFor(() => - expect(identity).toHaveTextContent("Not verified — no valid owner"), - ); + await verifiedFor(removed, since); + expect(identityRegion()).toBeNull(); expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); }); -it("stays unknown without a relay view even when the directory holds a head", async () => { +it("shows no owner without a relay view even when the directory holds a head", async () => { const agent = keypair(); const owner = keypair(); mount( @@ -548,15 +461,10 @@ it("stays unknown without a relay view even when the directory holds a head", as }, ); await screen.findByRole("heading", { name: "Helper" }); - const identity = screen.getByRole("region", { name: "Agent identity" }); + await act(async () => {}); // No view can signal an auth-only removal, so the directory head is not trusted here. - expect(identity).toHaveTextContent( - "Unknown — could not read this profile's attestation.", - ); - expect(screen.queryByRole("button", ownerButton(owner))).toBeNull(); - expect( - screen.getByRole("button", { name: "Retry agent details" }), - ).toBeInTheDocument(); + expect(identityRegion()).toBeNull(); + expect(verify).not.toHaveBeenCalled(); }); it("follows an auth-only head restored from disk while the pane is mounted", async () => { @@ -564,7 +472,9 @@ it("follows an auth-only head restored from disk while the pane is mounted", asy const a = keypair(); const viewer = keypair(); const attested = timedProfile(agent, [auth(agent, a)], 100); - const removed = timedProfile(agent, [], 101); + // Same owner key, forged signature: the displayed profile is identical, so only + // the directory's winning-event notification can drive the pane. + const removed = timedProfile(agent, [auth(agent, a, keypair())], 101); let releaseDisk!: (records: SavedHead[]) => void; const disk = new Promise((resolve) => { releaseDisk = resolve; @@ -642,18 +552,14 @@ it("follows an auth-only head restored from disk while the pane is mounted", asy profiles: [removed], }, ]); - const identity = screen.getByRole("region", { name: "Agent identity" }); - await waitFor(() => - expect(identity).toHaveTextContent( - "Not verified — no valid owner attestation.", - ), - ); + await verifiedFor(removed); + expect(identityRegion()).toBeNull(); expect(owner.session.profiles.event?.(agent.pubkey)?.id).toBe(removed.id); expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); }); it.each(["remove", "replace", "equal-time removal"])( - "stays unknown at the real view cap through an auth-only %s, then Retry recovers", + "shows no owner at the real view cap through an auth-only %s, then a reopen recovers", async (mode) => { const agent = keypair(); const a = keypair(); @@ -678,43 +584,39 @@ it.each(["remove", "replace", "equal-time removal"])( try { for (;;) fillers.push(h.session.observe([{ kinds: [1], limit: 1 }])); } catch {} + const atCap = verify.mock.calls.length; try { h.open(); - const identity = await screen.findByRole("region", { - name: "Agent identity", - }); - const unknown = async () => { - await waitFor(() => - expect(identity).toHaveTextContent( - "Unknown — could not read this profile's attestation.", - ), - ); - expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); + // Without a view nothing is verified, so no owner can appear later either. + const empty = async () => { + await screen.findByRole("heading", { name: "Helper" }); + await act(async () => {}); + expect(identityRegion()).toBeNull(); + expect(verify.mock.calls.length).toBe(atCap); }; - await unknown(); + await empty(); // The relay keeps serving the older attested head; live delivers the change. await h.receive(next); expect(h.session.profiles.event?.(agent.pubkey)?.id).toBe(next.id); - await unknown(); - const retry = () => - userEvent - .setup() - .click(screen.getByRole("button", { name: "Retry agent details" })); - await retry(); - await unknown(); + await empty(); + h.close(); + h.open(); + await empty(); fillers.pop()?.dispose(); - await retry(); + h.close(); + const since = verify.mock.calls.length; + h.open(); if (mode === "replace") await screen.findByRole("button", ownerButton(b)); - else - await waitFor(() => - expect(identity).toHaveTextContent("Not verified — no valid owner"), - ); + else { + await verifiedFor(next, since); + expect(identityRegion()).toBeNull(); + } expect(screen.queryByRole("button", ownerButton(a))).toBeNull(); // The recovered view keeps following later auth-only changes. - await h.receive(timedProfile(agent, [], 102)); - await waitFor(() => - expect(identity).toHaveTextContent("Not verified — no valid owner"), - ); + const later = timedProfile(agent, [], 102); + await h.receive(later); + await verifiedFor(later, since); + expect(identityRegion()).toBeNull(); } finally { for (const filler of fillers) filler.dispose(); } diff --git a/src/bundled/profiles/ProfileAgentIdentity.tsx b/src/bundled/profiles/ProfileAgentIdentity.tsx index 4df56042e..d8b211e2c 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.tsx @@ -16,54 +16,49 @@ import styles from "./Profiles.module.css"; type ProfileView = ReturnType; const noSubscribe = () => () => {}; const pendingView = (): EventViewSnapshot => pendingSnapshot; -const failedView = (): EventViewSnapshot => failedSnapshot; const pendingSnapshot: EventViewSnapshot = Object.freeze({ status: "loading", events: [], }); -const failedSnapshot: EventViewSnapshot = Object.freeze({ - status: "error", - events: [], - error: "Relay view unavailable", -}); -/** Public agent provenance from verifiable relay evidence only. Mounted for an - * agent hint, which decides visibility but never who owns the key. */ +/** "Managed by" from verifiable relay evidence only: a verified NIP-OA owner on + * the agent's winning signed kind 0. Mounted for an agent hint, which decides + * visibility but never who owns the key. Renders nothing without a verified owner. */ export function ProfileAgentIdentity({ session, pubkey, + viewer, context, }: { session: RelaySession; pubkey: string; + viewer: string | undefined; context: PanelProps["context"]; }) { // A session-owned view: live events, reconnect refresh and purge, no polling. - const [attempt, setAttempt] = useState(0); + // Capacity or a closed session leaves no view; reopening the profile retries. const [view, setView] = useState(); - // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery. useEffect(() => { let owned: ProfileView; try { owned = session.observe([{ kinds: [0], authors: [pubkey], limit: 1 }]); } catch { - setView(null); // Capacity or a closed session: honestly unknown, retryable. + setView(null); return; } setView(owned); return owned.dispose; - }, [session, pubkey, attempt]); - const fallback = view === null ? failedView : pendingView; + }, [session, pubkey]); const events = useSyncExternalStore( view?.subscribe ?? noSubscribe, - view?.snapshot ?? fallback, - view?.snapshot ?? fallback, + view?.snapshot ?? pendingView, + view?.snapshot ?? pendingView, ); // Provenance belongs to the winning signed kind 0 alone, never a display projection. // The directory's retained head outlives this view, so a reopened pane cannot // accept an older response than the profile the rest of the session shows. // Subscribed, so any head change (live, read or disk restore) re-renders. - // Without a live view, stay Unknown. + // Without a live view nothing can signal an auth-only change, so show nothing. const directoryHead = useSyncExternalStore( session.profiles.subscribe, () => session.profiles.event?.(pubkey), @@ -89,66 +84,23 @@ export function ProfileAgentIdentity({ active = false; }; }, [latest]); - const archives = session.archives; - const archiveSnapshot = useSyncExternalStore( - archives.subscribe, - archives.snapshot, - archives.snapshot, - ); - const archive = archives.state(pubkey); - // Fetch from idle (first mount, purge, reconnect invalidation). A prior error is - // retried once per mount (reopening the profile) or by Retry, never in a loop. - useEffect(() => { - if (archives.snapshot().status === "error") void archives.refresh(); - }, [archives]); useEffect(() => { if (events.status === "idle") void view?.refresh(); }, [view, events.status]); - useEffect(() => { - if (archiveSnapshot.status === "idle") void archives.ensure(); - }, [archives, archiveSnapshot.status]); - const current = verified && latest && verified.id === latest.id; - const owner = current ? verified.owner : undefined; - const failed = - (!latest && events.status === "error") || - archiveSnapshot.status === "error"; + const owner = + verified && latest && verified.id === latest.id + ? verified.owner + : undefined; + if (!owner) return null; return (
-

Agent

-
-
Owner
-
- {owner ? ( - - ) : current || (!latest && events.status === "ready") ? ( - "Not verified — no valid owner attestation." - ) : !latest && events.status === "error" ? ( - "Unknown — could not read this profile's attestation." - ) : ( - "Checking…" - )} -
-
Archive
-
- {archive === "archived" - ? "Archived on this relay" - : archive === "not-archived" - ? "Not archived" - : "Unknown"} -
-
- {failed && ( - - )} +

Managed by

+
); } @@ -156,10 +108,12 @@ export function ProfileAgentIdentity({ function OwnerLink({ session, owner, + self, context, }: { session: RelaySession; owner: string; + self: boolean; context: PanelProps["context"]; }) { const selection = useMemo( @@ -175,14 +129,14 @@ function OwnerLink({ void session.profiles.ensure([owner], "background").catch(() => {}); }, [session, owner]); const identityName = useIdentityNames(session.names); - const name = identityName( + const shown = identityName( owner, profiles.get(owner)?.name ?? formatPublicKey(owner) ?? owner, ); + const name = self ? `${shown} (you)` : shown; const target = profileTarget(owner); return ( - <> - Authorized by{" "} +
{target && context?.canOpen(target) ? (
); } diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 7f5312670..d8411ce71 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -183,6 +183,7 @@ function ProfileDetails({ )} diff --git a/src/bundled/profiles/Profiles.module.css b/src/bundled/profiles/Profiles.module.css index e23c7ac9d..4a7e63e82 100644 --- a/src/bundled/profiles/Profiles.module.css +++ b/src/bundled/profiles/Profiles.module.css @@ -157,17 +157,3 @@ margin: 0; color: var(--text-subtle); } -.agentIdentity dl { - display: grid; - grid-template-columns: max-content 1fr; - gap: var(--space-2) var(--space-4); - margin: 0; -} -.agentIdentity dt { - color: var(--text-subtle); -} -.agentIdentity dd { - margin: 0; - min-width: 0; - overflow-wrap: anywhere; -}