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:
Logan Johnson
2026-08-31 13:42:37 -04:00
parent 7c09f0c0ca
commit 1f7e36cb89
19 changed files with 855 additions and 134 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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]),
);
});

View File

@@ -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));
}

View File

@@ -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);

View File

@@ -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,
);
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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],
]);
});

View File

@@ -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);
}
}

View File

@@ -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)],
},
);
});

View File

@@ -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,
),
};
}

View File

@@ -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[] = [];

View File

@@ -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":

View File

@@ -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 ({

View 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([]);
});

View File

@@ -56,6 +56,7 @@ type MockSearchProfileSeed = {
type MockRelayAgentSeed = {
pubkey: string;
ownerPubkey?: string | null;
name: string;
agentType?: string;
capabilities?: string[];

View 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.