mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
fix(desktop): authorize remote mentions at the publication boundary
Extract preparation, exact destination checks and retained recipient intent from #7114, independently of labels and profile presentation. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com> (cherry picked from commit 1976a2ac953b53d25df831b97d22c5318394e7c1) Signed-off-by: Logan Johnson <loganj@squareup.com> (cherry picked from commit ffac74d5a64c08a5d3bdad85a8f69c42ff75e232) Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -74,6 +74,7 @@ export default defineConfig({
|
||||
"**/composer-selection-formatting.spec.ts",
|
||||
"**/composer-tooltip-dismiss.spec.ts",
|
||||
"**/mentions.spec.ts",
|
||||
"**/remote-owned-mentions.spec.ts",
|
||||
"**/mention-spacing.spec.ts",
|
||||
"**/team-mentions.spec.ts",
|
||||
"**/persistent-agent-audience.spec.ts",
|
||||
|
||||
@@ -272,7 +272,11 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
managed agent. Independently operated relay agents with NIP-OA ownership
|
||||
remain eligible in every build when their verified owner's signed
|
||||
`respond_to` policy admits the viewer and relay membership includes the
|
||||
target channel. Marked builds require that verified owner coordinate but do
|
||||
target channel at publication. Owned nonmembers may be offered for preparation
|
||||
and Invite; this is not permission to publish. Final authorization refreshes
|
||||
the exact destination and retains captured selected identities across uploads
|
||||
and edits. Denial preserves the draft, never silently removes a selected key.
|
||||
See `docs/remote-mention-routing.md`. Marked builds require that verified owner coordinate but do
|
||||
not require it to equal the viewer; OSS builds retain compatibility with
|
||||
self-authored legacy directory records. Keep native discovery and send-time
|
||||
revalidation fail closed on invalid ownership or managed policy evidence,
|
||||
|
||||
@@ -548,3 +548,92 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => {
|
||||
|
||||
assert.deepEqual(coalesce([first, second]), [first, second]);
|
||||
});
|
||||
|
||||
test("owners remain admitted by allowlist policy without listing themselves", () => {
|
||||
assert.equal(
|
||||
relayAgentCanRespondInChannel(
|
||||
{
|
||||
ownerPubkey: CURRENT_PUBKEY,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["general"],
|
||||
},
|
||||
"general",
|
||||
CURRENT_PUBKEY,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("owned discovery does not require a shared channel, but sending does", () => {
|
||||
for (const respondTo of ["owner-only", "allowlist", "anyone"]) {
|
||||
const agent = {
|
||||
pubkey: PUB_B,
|
||||
ownerPubkey: CURRENT_PUBKEY,
|
||||
respondTo,
|
||||
respondToAllowlist: [],
|
||||
channelIds: [],
|
||||
};
|
||||
assert.equal(
|
||||
relayAgentIsSharedWithUser(agent, new Set(), CURRENT_PUBKEY),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("DM ownership is independent of local configuration and still requires membership", () => {
|
||||
const base = {
|
||||
currentPubkey: CURRENT_PUBKEY,
|
||||
managedAgentPubkeys: [PUB_A],
|
||||
sharedChannelIds: new Set(),
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: PUB_B,
|
||||
ownerPubkey: CURRENT_PUBKEY,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["dm"],
|
||||
},
|
||||
{
|
||||
pubkey: PUB_C,
|
||||
ownerPubkey: OTHER_OWNER_PUBKEY,
|
||||
respondTo: "anyone",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["dm"],
|
||||
},
|
||||
{
|
||||
pubkey: PUB_D,
|
||||
ownerPubkey: CURRENT_PUBKEY,
|
||||
respondTo: "nobody",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["dm"],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
getMentionableAgentPubkeys({
|
||||
...base,
|
||||
eligibilityScope: { type: "owned", channelId: "dm" },
|
||||
}),
|
||||
new Set([PUB_A, PUB_B]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getMentionableAgentPubkeys({
|
||||
...base,
|
||||
eligibilityScope: { type: "owned", channelId: "other" },
|
||||
}),
|
||||
new Set([PUB_A]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getMentionableAgentPubkeys({
|
||||
...base,
|
||||
eligibilityScope: { type: "owned", channelId: null },
|
||||
phase: "prepare",
|
||||
}),
|
||||
new Set([PUB_A, PUB_B]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,12 +34,17 @@ export function relayAgentIsSharedWithUser(
|
||||
? normalizePubkey(currentPubkey)
|
||||
: null;
|
||||
|
||||
// Ownership is relay identity, not local key custody. Like the harness's
|
||||
// author gate, every supported policy except nobody admits the owner.
|
||||
if (
|
||||
agent.respondTo === "owner-only" &&
|
||||
(agent.respondTo === "owner-only" ||
|
||||
agent.respondTo === "allowlist" ||
|
||||
agent.respondTo === "anyone") &&
|
||||
normalizedCurrentPubkey &&
|
||||
agent.ownerPubkey
|
||||
agent.ownerPubkey &&
|
||||
normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey
|
||||
) {
|
||||
return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) {
|
||||
@@ -71,6 +76,7 @@ export function relayAgentCanRespondInChannel(
|
||||
export type AgentEligibilityScope =
|
||||
| { type: "community" }
|
||||
| { type: "channel"; channelId: string }
|
||||
| { type: "owned"; channelId: string | null }
|
||||
| { type: "managed-only" };
|
||||
|
||||
export function getMentionableAgentPubkeys({
|
||||
@@ -79,9 +85,11 @@ export function getMentionableAgentPubkeys({
|
||||
managedAgentPubkeys,
|
||||
relayAgents,
|
||||
sharedChannelIds,
|
||||
phase = "publish",
|
||||
}: {
|
||||
currentPubkey?: string | null;
|
||||
eligibilityScope: AgentEligibilityScope;
|
||||
phase?: "prepare" | "publish";
|
||||
managedAgentPubkeys: Iterable<string>;
|
||||
relayAgents: readonly RelayAgent[] | undefined;
|
||||
sharedChannelIds: ReadonlySet<string>;
|
||||
@@ -94,13 +102,38 @@ export function getMentionableAgentPubkeys({
|
||||
const isAllowed =
|
||||
eligibilityScope.type === "managed-only"
|
||||
? false
|
||||
: eligibilityScope.type === "community"
|
||||
? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)
|
||||
: relayAgentCanRespondInChannel(
|
||||
agent,
|
||||
eligibilityScope.channelId,
|
||||
currentPubkey,
|
||||
);
|
||||
: eligibilityScope.type === "owned"
|
||||
? Boolean(
|
||||
currentPubkey &&
|
||||
agent.ownerPubkey &&
|
||||
normalizePubkey(agent.ownerPubkey) ===
|
||||
normalizePubkey(currentPubkey) &&
|
||||
relayAgentIsSharedWithUser(
|
||||
agent,
|
||||
sharedChannelIds,
|
||||
currentPubkey,
|
||||
) &&
|
||||
(phase === "prepare" ||
|
||||
(eligibilityScope.channelId !== null &&
|
||||
agent.channelIds.includes(eligibilityScope.channelId))),
|
||||
)
|
||||
: eligibilityScope.type === "community"
|
||||
? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)
|
||||
: phase === "prepare" &&
|
||||
currentPubkey &&
|
||||
agent.ownerPubkey &&
|
||||
normalizePubkey(agent.ownerPubkey) ===
|
||||
normalizePubkey(currentPubkey)
|
||||
? relayAgentIsSharedWithUser(
|
||||
agent,
|
||||
sharedChannelIds,
|
||||
currentPubkey,
|
||||
)
|
||||
: relayAgentCanRespondInChannel(
|
||||
agent,
|
||||
eligibilityScope.channelId,
|
||||
currentPubkey,
|
||||
);
|
||||
if (isAllowed) {
|
||||
pubkeys.add(normalizePubkey(agent.pubkey));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
@@ -278,8 +279,9 @@ export function ForumComposer({
|
||||
media.setPendingImeta(savedImeta);
|
||||
if (compact) setIsCompactExpanded(true);
|
||||
}
|
||||
} catch {
|
||||
// Keep the draft intact when authorization refresh fails.
|
||||
} catch (error) {
|
||||
// Fresh authorization failures must be visible; preserve the draft.
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
isSubmissionPendingRef.current = false;
|
||||
setIsSubmissionPending(false);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { revalidateAgentMentionPubkeys } from "./agentMentionRevalidation.ts";
|
||||
import {
|
||||
revalidateAgentMentionPubkeys,
|
||||
AgentMentionAuthorizationError,
|
||||
} from "./agentMentionRevalidation.ts";
|
||||
|
||||
const CURRENT = "a".repeat(64);
|
||||
const AGENT = "b".repeat(64);
|
||||
@@ -47,37 +50,170 @@ test("fresh managed evidence survives unrelated relay authorization errors", asy
|
||||
throw new Error("relay directory unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [HUMAN, LOCAL_AGENT]);
|
||||
});
|
||||
|
||||
test("relay-only agents still fail closed when relay discovery fails", async () => {
|
||||
const result = await revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
fetchRelayAgents: async () => {
|
||||
throw new Error("relay directory unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [HUMAN]);
|
||||
});
|
||||
|
||||
test("mixed evidence preserves only fresh managed agents and humans", async () => {
|
||||
const result = await revalidateAgentMentionPubkeys({
|
||||
...options(async () => ({
|
||||
profiles: { [AGENT]: { ownerPubkey: CURRENT } },
|
||||
missing: [LOCAL_AGENT],
|
||||
})),
|
||||
pubkeys: [HUMAN, LOCAL_AGENT, AGENT],
|
||||
agentPubkeys: new Set([LOCAL_AGENT, AGENT]),
|
||||
refetchManagedAgents: async () => ({
|
||||
data: [{ pubkey: LOCAL_AGENT }],
|
||||
error: null,
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
fetchRelayAgents: async () => {
|
||||
throw new Error("relay directory unavailable");
|
||||
},
|
||||
}),
|
||||
fetchRelayAgents: async () => {
|
||||
throw new Error("relay directory unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [HUMAN, LOCAL_AGENT]);
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("mixed evidence cannot silently drop an intended relay recipient", async () => {
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys({
|
||||
...options(async () => ({
|
||||
profiles: { [AGENT]: { ownerPubkey: CURRENT } },
|
||||
missing: [LOCAL_AGENT],
|
||||
})),
|
||||
pubkeys: [HUMAN, LOCAL_AGENT, AGENT],
|
||||
agentPubkeys: new Set([LOCAL_AGENT, AGENT]),
|
||||
refetchManagedAgents: async () => ({
|
||||
data: [{ pubkey: LOCAL_AGENT }],
|
||||
error: null,
|
||||
}),
|
||||
fetchRelayAgents: async () => {
|
||||
throw new Error("relay directory unavailable");
|
||||
},
|
||||
}),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("remote-owned membership does not depend on local runtime discovery", async () => {
|
||||
assert.deepEqual(
|
||||
await revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
refetchManagedAgents: async () => ({
|
||||
data: undefined,
|
||||
error: new Error("local unavailable"),
|
||||
}),
|
||||
fetchRelayAgents: async () => [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
ownerPubkey: CURRENT,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["general"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[HUMAN, AGENT],
|
||||
);
|
||||
});
|
||||
|
||||
test("stale local data is not authority when its refresh fails", async () => {
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
pubkeys: [HUMAN, LOCAL_AGENT, AGENT],
|
||||
agentPubkeys: new Set([LOCAL_AGENT, AGENT]),
|
||||
refetchManagedAgents: async () => ({
|
||||
data: [{ pubkey: LOCAL_AGENT }],
|
||||
error: new Error("local unavailable"),
|
||||
}),
|
||||
}),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("owned remote policy revocation and missing membership fail closed", async () => {
|
||||
for (const agent of [
|
||||
{ respondTo: "nobody", channelIds: ["general"] },
|
||||
{ respondTo: "owner-only", channelIds: [] },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
fetchRelayAgents: async () => [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
ownerPubkey: CURRENT,
|
||||
respondToAllowlist: [],
|
||||
...agent,
|
||||
},
|
||||
],
|
||||
}),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const type of ["channel", "owned"]) {
|
||||
test(`${type}: preparation admits owned nonmembers but publication requires actual membership`, async () => {
|
||||
let channelIds = [];
|
||||
const opts = {
|
||||
...options(),
|
||||
eligibilityScope: { type, channelId: "target" },
|
||||
sharedChannelIds: new Set(),
|
||||
fetchRelayAgents: async () => [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
ownerPubkey: CURRENT,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [],
|
||||
channelIds,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
await revalidateAgentMentionPubkeys({ ...opts, phase: "prepare" }),
|
||||
[HUMAN, AGENT],
|
||||
);
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys(opts),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
channelIds = ["target"];
|
||||
assert.deepEqual(await revalidateAgentMentionPubkeys(opts), [HUMAN, AGENT]);
|
||||
channelIds = ["other"];
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys(opts),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("preparation cannot bypass a fresh owner-policy denial", async () => {
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
phase: "prepare",
|
||||
fetchRelayAgents: async () => [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
ownerPubkey: CURRENT,
|
||||
respondTo: "nobody",
|
||||
respondToAllowlist: [],
|
||||
channelIds: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("publication cannot authorize a DM that still has no destination", async () => {
|
||||
await assert.rejects(
|
||||
revalidateAgentMentionPubkeys({
|
||||
...options(),
|
||||
eligibilityScope: { type: "owned", channelId: null },
|
||||
fetchRelayAgents: async () => [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
ownerPubkey: CURRENT,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["other"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
AgentMentionAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
filterAdmittedMentionPubkeys,
|
||||
getAgentMentionAdmission,
|
||||
getMentionableAgentPubkeys,
|
||||
type AgentEligibilityScope,
|
||||
@@ -9,6 +8,20 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import * as React from "react";
|
||||
|
||||
export type MentionRevalidationOptions = {
|
||||
phase?: "prepare" | "publish";
|
||||
intendedAgentPubkeys?: readonly string[];
|
||||
};
|
||||
|
||||
export class AgentMentionAuthorizationError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Could not authorize a mentioned agent. Check its access and channel membership, then retry or remove the mention.",
|
||||
);
|
||||
this.name = "AgentMentionAuthorizationError";
|
||||
}
|
||||
}
|
||||
|
||||
type DirectoryResult<T> = {
|
||||
data: T | undefined;
|
||||
error: Error | null;
|
||||
@@ -22,7 +35,9 @@ export async function revalidateAgentMentionPubkeys({
|
||||
sharedChannelIds,
|
||||
refetchManagedAgents,
|
||||
fetchRelayAgents,
|
||||
phase = "publish",
|
||||
}: {
|
||||
phase?: "prepare" | "publish";
|
||||
pubkeys: readonly string[];
|
||||
agentPubkeys: ReadonlySet<string>;
|
||||
currentPubkey: string | null;
|
||||
@@ -39,20 +54,21 @@ export async function revalidateAgentMentionPubkeys({
|
||||
}
|
||||
|
||||
const [managedResult, relayAgents] = await Promise.all([
|
||||
refetchManagedAgents(),
|
||||
refetchManagedAgents().catch(() => null),
|
||||
fetchRelayAgents([...requestedAgentPubkeys]).catch(() => null),
|
||||
]);
|
||||
const relayDirectoryReady = relayAgents !== null;
|
||||
if (managedResult.error !== null || managedResult.data === undefined) {
|
||||
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set());
|
||||
}
|
||||
|
||||
// Each directory proves only its own identities. A failed local runtime
|
||||
// query must neither veto fresh relay evidence nor admit stale local data.
|
||||
const managedPubkeys = new Set(
|
||||
managedResult.data.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
(managedResult?.error === null ? (managedResult.data ?? []) : []).map(
|
||||
(agent) => normalizePubkey(agent.pubkey),
|
||||
),
|
||||
);
|
||||
const mentionablePubkeys = getMentionableAgentPubkeys({
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
phase,
|
||||
managedAgentPubkeys: managedPubkeys,
|
||||
relayAgents: relayDirectoryReady ? relayAgents : [],
|
||||
sharedChannelIds,
|
||||
@@ -71,7 +87,12 @@ export async function revalidateAgentMentionPubkeys({
|
||||
);
|
||||
}),
|
||||
);
|
||||
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, admittedPubkeys);
|
||||
if (
|
||||
[...requestedAgentPubkeys].some((pubkey) => !admittedPubkeys.has(pubkey))
|
||||
) {
|
||||
throw new AgentMentionAuthorizationError();
|
||||
}
|
||||
return [...pubkeys];
|
||||
}
|
||||
|
||||
export function useAgentMentionRevalidation({
|
||||
@@ -90,22 +111,38 @@ export function useAgentMentionRevalidation({
|
||||
refetchManagedAgents: () => Promise<DirectoryResult<ManagedAgent[]>>;
|
||||
}) {
|
||||
return React.useCallback(
|
||||
(pubkeys: readonly string[]) =>
|
||||
revalidateAgentMentionPubkeys({
|
||||
(
|
||||
pubkeys: readonly string[],
|
||||
destinationChannelId?: string | null,
|
||||
options: MentionRevalidationOptions = {},
|
||||
) => {
|
||||
// A new DM can acquire its channel during preparation. Validate the
|
||||
// actual destination at publication, not the composer's original null id.
|
||||
const scope: AgentEligibilityScope = destinationChannelId
|
||||
? {
|
||||
type: eligibilityScope.type === "owned" ? "owned" : "channel",
|
||||
channelId: destinationChannelId,
|
||||
}
|
||||
: eligibilityScope;
|
||||
return revalidateAgentMentionPubkeys({
|
||||
pubkeys,
|
||||
agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]),
|
||||
agentPubkeys: new Set([
|
||||
...agentPubkeys,
|
||||
...getSelectedAgentPubkeys(),
|
||||
...(options.intendedAgentPubkeys ?? []).map(normalizePubkey),
|
||||
]),
|
||||
phase: options.phase,
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
eligibilityScope: scope,
|
||||
sharedChannelIds,
|
||||
refetchManagedAgents,
|
||||
fetchRelayAgents: (requestedPubkeys) =>
|
||||
revalidateRelayAgents(
|
||||
requestedPubkeys,
|
||||
eligibilityScope.type === "channel"
|
||||
? eligibilityScope.channelId
|
||||
: undefined,
|
||||
"channelId" in scope ? (scope.channelId ?? undefined) : undefined,
|
||||
),
|
||||
}),
|
||||
});
|
||||
},
|
||||
[
|
||||
agentPubkeys,
|
||||
currentPubkey,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
export function useDraftMentionRouting(params: {
|
||||
mentionMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
personaMentionMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
selectedAgentPubkeysRef: React.MutableRefObject<Set<string>>;
|
||||
selectedAgentNamesRef: React.MutableRefObject<string[]>;
|
||||
cancelAutocomplete: () => void;
|
||||
setSelectedNames: (names: string[]) => void;
|
||||
@@ -31,6 +32,11 @@ export function useDraftMentionRouting(params: {
|
||||
const restoreDraftMentionRefs = React.useCallback(
|
||||
(refs: readonly DraftMentionRef[]) => {
|
||||
params.cancelAutocomplete();
|
||||
params.selectedAgentPubkeysRef.current = new Set(
|
||||
refs
|
||||
.filter((ref) => ref.isAgent)
|
||||
.map((ref) => ref.pubkey.toLowerCase()),
|
||||
);
|
||||
const { names, agentNames } = replaceWithDraftMentionRefs(
|
||||
refs,
|
||||
params.mentionMapRef.current,
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import {
|
||||
filterAdmittedMentionPubkeys,
|
||||
filterCachedAgentSuggestions,
|
||||
getAdmittedAgentPubkeys,
|
||||
getAgentIdentityPubkeys,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
@@ -179,15 +177,20 @@ export function useMentions(
|
||||
() =>
|
||||
getMentionableAgentPubkeys({
|
||||
currentPubkey,
|
||||
phase: "prepare",
|
||||
eligibilityScope: mentionChannelId
|
||||
? { type: "channel", channelId: mentionChannelId }
|
||||
: { type: "managed-only" },
|
||||
: options?.channelType === "dm"
|
||||
? { type: "owned", channelId }
|
||||
: { type: "managed-only" },
|
||||
managedAgentPubkeys,
|
||||
relayAgents: relayAgentsQuery.data,
|
||||
sharedChannelIds,
|
||||
}),
|
||||
[
|
||||
currentPubkey,
|
||||
channelId,
|
||||
options?.channelType,
|
||||
managedAgentPubkeys,
|
||||
mentionChannelId,
|
||||
relayAgentsQuery.data,
|
||||
@@ -286,10 +289,6 @@ export function useMentions(
|
||||
relayAgentsQuery.data,
|
||||
],
|
||||
);
|
||||
const admittedAgentPubkeys = React.useMemo(
|
||||
() => getAdmittedAgentPubkeys(mentionCandidates),
|
||||
[mentionCandidates],
|
||||
);
|
||||
const mentionCandidatesWithTeams = React.useMemo(
|
||||
() => [
|
||||
...mentionCandidates,
|
||||
@@ -530,12 +529,15 @@ export function useMentions(
|
||||
appendUniqueName(current, trimmedName),
|
||||
);
|
||||
if (options?.isAgent) {
|
||||
selectedAgentMentionPubkeysRef.current.add(normalizePubkey(pubkey));
|
||||
selectedAgentMentionPubkeysRef.current.add(normalizePubkey(pubkey));
|
||||
selectedAgentMentionNamesRef.current = appendUniqueName(
|
||||
selectedAgentMentionNamesRef.current,
|
||||
trimmedName,
|
||||
);
|
||||
setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current);
|
||||
}
|
||||
return trimmedName;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -662,16 +664,11 @@ export function useMentions(
|
||||
selectedDisplayNames: personaMentionMapRef.current.keys(),
|
||||
memberCandidates: mentionCandidates,
|
||||
});
|
||||
return filterAdmittedMentionPubkeys(
|
||||
extracted,
|
||||
new Set([
|
||||
...agentIdentityPubkeys,
|
||||
...selectedAgentMentionPubkeysRef.current,
|
||||
]),
|
||||
admittedAgentPubkeys,
|
||||
);
|
||||
// Selections are intent, not cached authorization. Never discard a
|
||||
// selected key because a refresh removed it from the picker.
|
||||
return extracted;
|
||||
},
|
||||
[admittedAgentPubkeys, agentIdentityPubkeys, mentionCandidates],
|
||||
[mentionCandidates],
|
||||
);
|
||||
const getSelectedAgentPubkeys = React.useRef(
|
||||
() => selectedAgentMentionPubkeysRef.current,
|
||||
@@ -682,7 +679,9 @@ export function useMentions(
|
||||
currentPubkey,
|
||||
eligibilityScope: mentionChannelId
|
||||
? { type: "channel", channelId: mentionChannelId }
|
||||
: { type: "managed-only" },
|
||||
: options?.channelType === "dm"
|
||||
? { type: "owned", channelId }
|
||||
: { type: "managed-only" },
|
||||
sharedChannelIds,
|
||||
refetchManagedAgents: managedAgentsQuery.refetch,
|
||||
});
|
||||
@@ -721,6 +720,7 @@ export function useMentions(
|
||||
mentionMapRef,
|
||||
personaMentionMapRef,
|
||||
selectedAgentNamesRef: selectedAgentMentionNamesRef,
|
||||
selectedAgentPubkeysRef: selectedAgentMentionPubkeysRef,
|
||||
cancelAutocomplete: cancelMentionAutocomplete,
|
||||
setSelectedNames: setSelectedMentionNames,
|
||||
setSelectedAgentNames: setSelectedAgentMentionNames,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AgentMentionAuthorizationError } from "../lib/agentMentionRevalidation.ts";
|
||||
import { submitMessageEdit } from "./submitMessageEdit.ts";
|
||||
|
||||
const UNRESOLVED_USER = "b".repeat(64);
|
||||
@@ -96,13 +97,16 @@ test("edit save revalidates added mentions immediately before save", async () =>
|
||||
content.includes("@Agent") ? [agent] : [],
|
||||
revalidateMentionPubkeys: async (pubkeys) => {
|
||||
calls.push(["revalidate", pubkeys]);
|
||||
return [];
|
||||
throw new AgentMentionAuthorizationError();
|
||||
},
|
||||
restoreComposer: () => calls.push(["restore"]),
|
||||
setUploadError: (error) => calls.push(["error", error]),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["revalidate", [agent]],
|
||||
["save", []],
|
||||
["restore"],
|
||||
["error", new AgentMentionAuthorizationError().message],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -131,14 +135,17 @@ test("edit upload pause revalidates revoked mentions only after upload completes
|
||||
},
|
||||
revalidateMentionPubkeys: async (pubkeys) => {
|
||||
calls.push(["revalidate", pubkeys]);
|
||||
return [];
|
||||
throw new AgentMentionAuthorizationError();
|
||||
},
|
||||
restoreComposer: () => calls.push(["restore"]),
|
||||
setUploadError: (error) => calls.push(["error", error]),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
await completeUpload();
|
||||
assert.deepEqual(calls, [
|
||||
["revalidate", [agent]],
|
||||
["save", []],
|
||||
["restore"],
|
||||
["error", new AgentMentionAuthorizationError().message],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
AgentMentionAuthorizationError,
|
||||
type MentionRevalidationOptions,
|
||||
} from "@/features/messages/lib/agentMentionRevalidation";
|
||||
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import { hasMention } from "@/features/messages/lib/hasMention";
|
||||
@@ -40,7 +44,11 @@ type SubmitMessageEditOptions = Omit<
|
||||
ownerPubkey: string | null;
|
||||
restoreComposer: (draft: EditDraft) => void;
|
||||
restoreMentionRefs: (refs: DraftMentionRef[]) => void;
|
||||
revalidateMentionPubkeys: (pubkeys: readonly string[]) => Promise<string[]>;
|
||||
revalidateMentionPubkeys: (
|
||||
pubkeys: readonly string[],
|
||||
channelId?: string | null,
|
||||
options?: MentionRevalidationOptions,
|
||||
) => Promise<string[]>;
|
||||
shouldRestoreComposer: () => boolean;
|
||||
setDeferredUploadPending: (isPending: boolean) => void;
|
||||
save: (
|
||||
@@ -126,8 +134,15 @@ export async function submitMessageEdit({
|
||||
],
|
||||
);
|
||||
if (signal?.aborted) return;
|
||||
const revalidatedMentionPubkeys =
|
||||
await revalidateMentionPubkeys(addedMentionPubkeys);
|
||||
const revalidatedMentionPubkeys = await revalidateMentionPubkeys(
|
||||
addedMentionPubkeys,
|
||||
undefined,
|
||||
{
|
||||
intendedAgentPubkeys: draft.mentionRefs
|
||||
.filter((ref) => ref.isAgent)
|
||||
.map((ref) => ref.pubkey),
|
||||
},
|
||||
);
|
||||
if (signal?.aborted) return;
|
||||
await save(
|
||||
finalContent,
|
||||
@@ -143,8 +158,10 @@ export async function submitMessageEdit({
|
||||
onComplete: async (uploaded, signal) => {
|
||||
try {
|
||||
await finishEdit(uploaded, signal);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
restoreDraft();
|
||||
if (error instanceof AgentMentionAuthorizationError)
|
||||
setUploadError(error.message);
|
||||
} finally {
|
||||
setDeferredUploadPending(false);
|
||||
}
|
||||
@@ -164,7 +181,9 @@ export async function submitMessageEdit({
|
||||
|
||||
try {
|
||||
await finishEdit([]);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
restoreDraft();
|
||||
if (error instanceof AgentMentionAuthorizationError)
|
||||
setUploadError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatMessageSendError,
|
||||
getErrorMessage,
|
||||
mergeMentionRecipients,
|
||||
mentionRevalidationOptions,
|
||||
} from "./useMentionSendFlow.helpers.ts";
|
||||
|
||||
test("formatMessageSendError preserves the publication failure", () => {
|
||||
@@ -39,3 +40,24 @@ test("address-locked agents join explicit mentions without duplicating recipient
|
||||
"c".repeat(64),
|
||||
]);
|
||||
});
|
||||
|
||||
test("revalidation carries captured and prepared agent keys independently of the cleared composer", () => {
|
||||
const draft = {
|
||||
inlineAgentMentionPubkeys: ["A".repeat(64)],
|
||||
addressedAgentPubkeys: ["b".repeat(64)],
|
||||
};
|
||||
assert.deepEqual(mentionRevalidationOptions(draft, "prepare"), {
|
||||
phase: "prepare",
|
||||
intendedAgentPubkeys: ["a".repeat(64), "b".repeat(64)],
|
||||
});
|
||||
assert.deepEqual(
|
||||
mentionRevalidationOptions(draft, "publish", [
|
||||
"a".repeat(64),
|
||||
"c".repeat(64),
|
||||
]),
|
||||
{
|
||||
phase: "publish",
|
||||
intendedAgentPubkeys: ["a".repeat(64), "b".repeat(64), "c".repeat(64)],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MentionRevalidationOptions } from "@/features/messages/lib/agentMentionRevalidation";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import {
|
||||
type ImetaMedia,
|
||||
@@ -119,3 +120,36 @@ export function isManagedAgentRunning(agent: ManagedAgent) {
|
||||
export function isProviderBackedAgent(agent: ManagedAgent) {
|
||||
return agent.backend.type === "provider";
|
||||
}
|
||||
|
||||
/** Carry captured recipient identity through composer clearing and uploads. */
|
||||
export function mentionRevalidationOptions(
|
||||
draft: Pick<
|
||||
PendingNonMemberMentionSend,
|
||||
"inlineAgentMentionPubkeys" | "addressedAgentPubkeys"
|
||||
>,
|
||||
phase: "prepare" | "publish",
|
||||
preparedAgentPubkeys: readonly string[] = [],
|
||||
): MentionRevalidationOptions {
|
||||
return {
|
||||
phase,
|
||||
intendedAgentPubkeys: uniqueNormalizedPubkeys([
|
||||
...draft.inlineAgentMentionPubkeys,
|
||||
...draft.addressedAgentPubkeys,
|
||||
...preparedAgentPubkeys,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Explicit Send without inviting retains nonmembers only as reference tags. */
|
||||
export function withoutInvitingRecipients(draft: PendingNonMemberMentionSend) {
|
||||
const nonMemberPubkeys = new Set(draft.nonMemberPubkeys.map(normalizePubkey));
|
||||
return {
|
||||
mentionPubkeys: draft.mentionPubkeys.filter(
|
||||
(pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)),
|
||||
),
|
||||
outgoingTags: mergeOutgoingTagsWithReferenceMentions(
|
||||
draft.outgoingTags,
|
||||
nonMemberPubkeys,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,17 +32,19 @@ import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
|
||||
import {
|
||||
formatMessageSendError,
|
||||
getErrorMessage,
|
||||
mentionRevalidationOptions,
|
||||
withoutInvitingRecipients,
|
||||
isManagedAgentRunning,
|
||||
isProviderBackedAgent,
|
||||
mergeMentionRecipients,
|
||||
MENTION_REFERENCE_TAG,
|
||||
mergeOutgoingTagsWithReferenceMentions,
|
||||
type PendingNonMemberMentionSend,
|
||||
type SendMessageWithMentionFlowInput,
|
||||
resolvePreviewTags,
|
||||
uniqueNormalizedPubkeys,
|
||||
} from "./useMentionSendFlow.helpers";
|
||||
import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs";
|
||||
import { AgentMentionAuthorizationError } from "@/features/messages/lib/agentMentionRevalidation";
|
||||
import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types";
|
||||
|
||||
export function useMentionSendFlow({
|
||||
@@ -429,7 +431,11 @@ export function useMentionSendFlow({
|
||||
let uploadStarted = false;
|
||||
try {
|
||||
const admittedMentionPubkeys = uniqueNormalizedPubkeys(
|
||||
await mentions.revalidateMentionPubkeys(mentionPubkeys),
|
||||
await mentions.revalidateMentionPubkeys(
|
||||
mentionPubkeys,
|
||||
draft.capturedChannelId,
|
||||
mentionRevalidationOptions(draft, "prepare"),
|
||||
),
|
||||
);
|
||||
if (isSendCancelled()) return restoreComposerAfterFailure();
|
||||
if (!isMountedRef.current) return persistPreflightDraft();
|
||||
@@ -439,7 +445,9 @@ export function useMentionSendFlow({
|
||||
(pubkey) => admittedMentionPubkeySet.has(pubkey),
|
||||
),
|
||||
);
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey().catch(
|
||||
() => new Map<string, ManagedAgent>(),
|
||||
);
|
||||
if (isSendCancelled()) return restoreComposerAfterFailure();
|
||||
if (!isMountedRef.current) {
|
||||
persistPreflightDraft();
|
||||
@@ -539,7 +547,15 @@ export function useMentionSendFlow({
|
||||
if (!finalOutgoingTags || signal?.aborted || isSendCancelled())
|
||||
return;
|
||||
const revalidatedMentionPubkeys =
|
||||
await mentions.revalidateMentionPubkeys(mentionPubkeys);
|
||||
await mentions.revalidateMentionPubkeys(
|
||||
mentionPubkeys,
|
||||
sendChannelId,
|
||||
mentionRevalidationOptions(
|
||||
draft,
|
||||
"publish",
|
||||
preparedAgentPubkeys,
|
||||
),
|
||||
);
|
||||
if (signal?.aborted || isSendCancelled()) return;
|
||||
const finalTagsWithAgentAddress = [
|
||||
...finalOutgoingTags,
|
||||
@@ -598,7 +614,7 @@ export function useMentionSendFlow({
|
||||
await finishSend(uploaded, signal);
|
||||
} catch (error) {
|
||||
restoreComposerAfterFailure();
|
||||
toast.error(formatMessageSendError(error));
|
||||
toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error));
|
||||
} finally {
|
||||
settleUpload();
|
||||
}
|
||||
@@ -626,12 +642,14 @@ export function useMentionSendFlow({
|
||||
await finishSend([]);
|
||||
} catch (error) {
|
||||
restoreComposerAfterFailure();
|
||||
toast.error(formatMessageSendError(error));
|
||||
toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
restoreComposerAfterFailure();
|
||||
throw error;
|
||||
toast.error(
|
||||
getErrorMessage(error, "Could not send message. Please retry."),
|
||||
);
|
||||
} finally {
|
||||
if (draft.preparedLinkPreviews) {
|
||||
activePreparedLinkPreviews.delete(draft.preparedLinkPreviews);
|
||||
@@ -847,18 +865,8 @@ export function useMentionSendFlow({
|
||||
}, [mentions.getMentionDisplayName, pendingNonMemberSend]);
|
||||
const handleSendWithoutInviting = React.useCallback(() => {
|
||||
if (!pendingNonMemberSend) return;
|
||||
const nonMemberPubkeys = new Set(
|
||||
pendingNonMemberSend.nonMemberPubkeys.map((pubkey) =>
|
||||
normalizePubkey(pubkey),
|
||||
),
|
||||
);
|
||||
const mentionPubkeys = pendingNonMemberSend.mentionPubkeys.filter(
|
||||
(pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)),
|
||||
);
|
||||
const outgoingTags = mergeOutgoingTagsWithReferenceMentions(
|
||||
pendingNonMemberSend.outgoingTags,
|
||||
nonMemberPubkeys,
|
||||
);
|
||||
const { mentionPubkeys, outgoingTags } =
|
||||
withoutInvitingRecipients(pendingNonMemberSend);
|
||||
void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags);
|
||||
}, [completeSend, pendingNonMemberSend]);
|
||||
const handleInviteNonMembers = React.useCallback(() => {
|
||||
@@ -870,10 +878,14 @@ export function useMentionSendFlow({
|
||||
setNonMemberPromptError(null);
|
||||
void (async () => {
|
||||
const mentionPubkeys = uniqueNormalizedPubkeys(
|
||||
await mentions.revalidateMentionPubkeys([
|
||||
...pendingNonMemberSend.mentionPubkeys,
|
||||
...pendingNonMemberSend.nonMemberPubkeys,
|
||||
]),
|
||||
await mentions.revalidateMentionPubkeys(
|
||||
[
|
||||
...pendingNonMemberSend.mentionPubkeys,
|
||||
...pendingNonMemberSend.nonMemberPubkeys,
|
||||
],
|
||||
pendingNonMemberSend.capturedChannelId,
|
||||
mentionRevalidationOptions(pendingNonMemberSend, "prepare"),
|
||||
),
|
||||
);
|
||||
const admittedMentionPubkeys = new Set(mentionPubkeys);
|
||||
const originalNonMemberPubkeys = new Set(
|
||||
@@ -887,7 +899,9 @@ export function useMentionSendFlow({
|
||||
tag[0] !== MENTION_REFERENCE_TAG ||
|
||||
!originalNonMemberPubkeys.has(normalizePubkey(tag[1] ?? "")),
|
||||
);
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey().catch(
|
||||
() => new Map<string, ManagedAgent>(),
|
||||
);
|
||||
if (!isMountedRef.current) return;
|
||||
const peoplePubkeys: string[] = [];
|
||||
const relayAgentPubkeys: string[] = [];
|
||||
|
||||
@@ -4155,6 +4155,14 @@ function syncMockRelayAgentsFromManagedAgents() {
|
||||
},
|
||||
);
|
||||
|
||||
// Owned discovery includes nonmembers, but membership must come from the
|
||||
// actual roster, including additions and newly created DMs.
|
||||
for (const agent of baseAgents) {
|
||||
if (agent.owner_pubkey !== MOCK_IDENTITY_PUBKEY) continue;
|
||||
const membership = getManagedAgentRelayMembership(agent.pubkey);
|
||||
agent.channel_ids = membership.channelIds;
|
||||
agent.channels = membership.channels;
|
||||
}
|
||||
mockRelayAgents = [...baseAgents, ...managedAgentsAsRelay];
|
||||
}
|
||||
|
||||
@@ -13350,7 +13358,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
(agent) =>
|
||||
requested.has(agent.pubkey.toLowerCase()) &&
|
||||
!revoked.has(agent.pubkey.toLowerCase()) &&
|
||||
(!channelId || agent.channel_ids.includes(channelId)),
|
||||
(!channelId ||
|
||||
agent.channel_ids.includes(channelId) ||
|
||||
agent.owner_pubkey === MOCK_IDENTITY_PUBKEY),
|
||||
);
|
||||
}
|
||||
case "list_personas":
|
||||
|
||||
@@ -15,6 +15,20 @@ test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("outgoing-diagnostic", {
|
||||
body: JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
events: window.__BUZZ_E2E_SIGNED_EVENTS__,
|
||||
commands: window.__BUZZ_E2E_COMMAND_LOG__,
|
||||
})),
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const IN_CHANNEL_MANAGED_AGENT_PUBKEY =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY =
|
||||
@@ -1635,6 +1649,25 @@ test("forum sends revalidate relay-agent authorization before signing", async ({
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
||||
await page.getByRole("button", { name: "Start a new post..." }).click();
|
||||
|
||||
await page.evaluate(
|
||||
async ({ channelId, pubkey }) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("Mock bridge is not installed.");
|
||||
await invoke("add_channel_members", {
|
||||
channelId,
|
||||
pubkeys: [pubkey],
|
||||
role: "bot",
|
||||
});
|
||||
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["channels", channelId, "members"],
|
||||
});
|
||||
},
|
||||
{
|
||||
channelId: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
},
|
||||
);
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
await page.getByTestId("mention-autocomplete").getByText("quinn").click();
|
||||
@@ -1665,12 +1698,11 @@ test("forum sends revalidate relay-agent authorization before signing", async ({
|
||||
await expect(input).not.toContainText("later edit");
|
||||
|
||||
const outgoingContent = `@quinn hello\n[forum-race.pdf](https://mock.relay/media/${"f".repeat(64)}.pdf)`;
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, outgoingContent))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, outgoingContent))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(input).toContainText("@quinn hello");
|
||||
expect(await readOutgoingMentionPubkeys(page, outgoingContent)).toBeNull();
|
||||
});
|
||||
|
||||
test("managed agents use the channel roster for membership labels", async ({
|
||||
@@ -1942,15 +1974,14 @@ test("targeted revocation before send causes no agent side effects", async ({
|
||||
const baselineCommands = await readCommandLog(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(input).toHaveText("@quinn hello");
|
||||
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
||||
const commands = await readCommandLog(page);
|
||||
expect(commandCount(commands, "revalidate_relay_agents")).toBe(
|
||||
commandCount(baselineCommands, "revalidate_relay_agents") + 2,
|
||||
commandCount(baselineCommands, "revalidate_relay_agents") + 1,
|
||||
);
|
||||
expect(commandCount(commands, "list_relay_agents")).toBe(
|
||||
commandCount(baselineCommands, "list_relay_agents"),
|
||||
@@ -2055,12 +2086,11 @@ test("selected relay agents revoked after the invite prompt cause no side effect
|
||||
const baselineCommands = await readCommandLog(page);
|
||||
await inviteButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(input).toHaveText("@quinn hello");
|
||||
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
||||
const commands = await readCommandLog(page);
|
||||
for (const command of [
|
||||
"add_channel_members",
|
||||
@@ -2110,12 +2140,11 @@ test("selected relay agents revoked during send emit no p tag", async ({
|
||||
);
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(input).toHaveText("@quinn hello");
|
||||
expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull();
|
||||
});
|
||||
|
||||
test("owner-only builds admit cross-owner relay agents authorized by allowlist", async ({
|
||||
|
||||
246
desktop/tests/e2e/remote-owned-mentions.spec.ts
Normal file
246
desktop/tests/e2e/remote-owned-mentions.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import {
|
||||
installMockBridge,
|
||||
openNewMessagePage,
|
||||
TEST_IDENTITIES,
|
||||
} from "../helpers/bridge";
|
||||
|
||||
const OWNER = "deadbeef".repeat(8);
|
||||
const REMOTE = "ed".repeat(32);
|
||||
const GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
|
||||
async function install(page: Page) {
|
||||
await installMockBridge(page, {
|
||||
ownerOnlyAccessBuild: true,
|
||||
managedAgents: [],
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
displayName: "RemoteScout",
|
||||
ownerPubkey: OWNER,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
name: "RemoteScout",
|
||||
ownerPubkey: OWNER,
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [],
|
||||
channelNames: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
}
|
||||
async function select(page: Page) {
|
||||
await page.getByTestId("message-input").fill("@Remote");
|
||||
const row = page.getByTestId(`mention-suggestion-${REMOTE}`);
|
||||
await expect(row).toContainText("RemoteScout");
|
||||
await row.locator("button").first().click();
|
||||
await page.keyboard.type("hello");
|
||||
}
|
||||
async function sent(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
|
||||
.filter((event) => event.content === "@RemoteScout hello")
|
||||
.map((event) =>
|
||||
event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]),
|
||||
);
|
||||
if (signed.length) return signed;
|
||||
// New DMs deliberately use the acknowledged native HTTP command rather
|
||||
// than JS sign_event. Assert its exact outgoing recipients, not fake crypto.
|
||||
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).flatMap((call) => {
|
||||
const payload = call.payload as {
|
||||
content?: string;
|
||||
mentionPubkeys?: string[];
|
||||
};
|
||||
return call.command === "send_channel_message" &&
|
||||
payload.content === "@RemoteScout hello"
|
||||
? [payload.mentionPubkeys ?? []]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
}
|
||||
async function assertNoLocalLifecycle(page: Page) {
|
||||
const commands = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMANDS__ ?? [],
|
||||
);
|
||||
for (const command of [
|
||||
"start_managed_agent",
|
||||
"create_managed_agent",
|
||||
"attach_managed_agent",
|
||||
]) {
|
||||
expect(commands).not.toContain(command);
|
||||
}
|
||||
}
|
||||
for (const role of ["member", "bot"] as const) {
|
||||
test(`owned ${role} with empty local roster emits exact p tag`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await page.evaluate(
|
||||
async ({ pubkey, channelId, role }) => {
|
||||
await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", {
|
||||
channelId,
|
||||
pubkeys: [pubkey],
|
||||
role,
|
||||
});
|
||||
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["channels"],
|
||||
});
|
||||
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["relay-agents"],
|
||||
});
|
||||
},
|
||||
{ pubkey: REMOTE, channelId: GENERAL, role },
|
||||
);
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Invite", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
}
|
||||
test("owned nonmember uses authorized add before exact publication", async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
const invite = page.getByRole("button", { name: "Invite", exact: true });
|
||||
await expect(invite).toBeVisible();
|
||||
expect(await sent(page)).toEqual([]);
|
||||
await invite.click();
|
||||
await expect.poll(() => sent(page)).toEqual([[REMOTE]]);
|
||||
const calls = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMAND_LOG__ ?? [],
|
||||
);
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
command: "add_channel_members",
|
||||
payload: expect.objectContaining({ pubkeys: [REMOTE], role: "bot" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
for (const error of [
|
||||
"actor not authorized",
|
||||
"policy:nobody — this agent has disabled external channel additions",
|
||||
]) {
|
||||
test(`failed add keeps draft and sends nothing: ${error}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await select(page);
|
||||
await page.evaluate((error) => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.addChannelMembersErrors = [error];
|
||||
}, error);
|
||||
await page.getByTestId("send-message").click();
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect(page.getByText(error, { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
}
|
||||
test("selected owned agent revoked before add keeps draft and sends nothing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
await page.evaluate((pubkey) => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey];
|
||||
}, REMOTE);
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
});
|
||||
|
||||
for (const mode of ["existing", "new"] as const) {
|
||||
test(`${mode} DM prepares actual destination for owned relay mention`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
if (mode === "existing") {
|
||||
await page.getByTestId("channel-bob-tyler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler");
|
||||
} else {
|
||||
await openNewMessagePage(page);
|
||||
await page.getByTestId("new-dm-search").fill("bob");
|
||||
await page
|
||||
.getByTestId(`new-dm-result-${TEST_IDENTITIES.bob.pubkey}`)
|
||||
.click();
|
||||
await page.getByTestId("new-dm-search").press("Escape");
|
||||
}
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() => sent(page))
|
||||
.toEqual([[REMOTE, TEST_IDENTITIES.bob.pubkey]]);
|
||||
const calls = await page.evaluate(
|
||||
() => window.__BUZZ_E2E_COMMAND_LOG__ ?? [],
|
||||
);
|
||||
const checks = calls.filter(
|
||||
(call) => call.command === "revalidate_relay_agents",
|
||||
);
|
||||
const event = await page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).find(
|
||||
(event) => event.content === "@RemoteScout hello",
|
||||
),
|
||||
);
|
||||
expect(checks.at(-1)?.payload).toMatchObject({
|
||||
channelId:
|
||||
event?.tags.find((tag) => tag[0] === "h")?.[1] ??
|
||||
(
|
||||
calls.find((call) => call.command === "send_channel_message")
|
||||
?.payload as { channelId?: string }
|
||||
)?.channelId,
|
||||
pubkeys: [REMOTE],
|
||||
});
|
||||
await assertNoLocalLifecycle(page);
|
||||
});
|
||||
}
|
||||
|
||||
test("membership revoked at final publish keeps draft and emits no message", async ({
|
||||
page,
|
||||
}) => {
|
||||
await install(page);
|
||||
await select(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
// Let preparation succeed, but make the fresh final directory read fail.
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentListErrors = [
|
||||
null,
|
||||
null,
|
||||
"revoked at publication",
|
||||
];
|
||||
});
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(/Could not authorize a mentioned agent/),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("message-input")).toHaveText(
|
||||
"@RemoteScout hello",
|
||||
);
|
||||
expect(await sent(page)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ type MockSearchProfileSeed = {
|
||||
|
||||
type MockRelayAgentSeed = {
|
||||
pubkey: string;
|
||||
ownerPubkey?: string | null;
|
||||
name: string;
|
||||
agentType?: string;
|
||||
capabilities?: string[];
|
||||
|
||||
31
docs/remote-mention-routing.md
Normal file
31
docs/remote-mention-routing.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Remote mention preparation and publication
|
||||
|
||||
Owned relay agents can be selected and invited without local runtime custody.
|
||||
The picker and preparation phase evaluate policy but may admit an owned
|
||||
nonmember. Publication separately refreshes authorization for the exact eventual
|
||||
destination, including a newly created DM. Ownership is not membership and
|
||||
cached picker evidence is never publication authorization.
|
||||
|
||||
Selections are intent: a vanished/revoked key must fail visibly, retain the draft
|
||||
and send nothing, rather than silently dropping a recipient. Captured agent keys
|
||||
survive composer clearing, media upload, edits and asynchronous preparation.
|
||||
A failed local inventory read cannot veto authenticated relay identities or
|
||||
admit stale local runtimes. Locally managed runtime readiness remains a separate
|
||||
existing path; remote identities never gain synthetic local management records.
|
||||
|
||||
Chat offers explicit Invite or reference-only send without inviting. Failed adds,
|
||||
revoked policy, failed final authorization and cancellation preserve recoverable
|
||||
drafts. Standalone forum sends report authorization failures, but standalone
|
||||
forum invitation is a subsequent change reusing this phase contract.
|
||||
|
||||
Native discovery prerequisite: `docs/owned-agent-discovery.md` (PR6).
|
||||
Regression coverage: `agentAutocompleteEligibility.test.mjs`,
|
||||
`agentMentionRevalidation.test.mjs`, `useMentionSendFlow.helpers.test.mjs`,
|
||||
`submitMessageEdit.test.mjs`, `mentions.spec.ts`, and
|
||||
`remote-owned-mentions.spec.ts`. The new remote browser fixtures use a single-word
|
||||
name deliberately: mention separator behavior belongs to the independent PR1.
|
||||
|
||||
NIP-OA establishes ownership, not physical hosting, availability, or lifecycle
|
||||
control. Final native queries do not provide an atomic relay transaction with
|
||||
message publication. Independent review of both native and publication boundaries
|
||||
is required before landing.
|
||||
Reference in New Issue
Block a user