Skip to content
49 changes: 49 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,3 +1096,52 @@ test.each(["sign", "publish"])(
}
},
);

test("message/reaction deletions pass real signing and publication without admitting workflow or arbitrary deletion shapes", async () => {
const h = await harness((call) =>
Response.json({ accepted: true, event_id: call.body.id }),
);
try {
await h.start();
const template = {
kind: 5,
content: "",
created_at: h.event.created_at,
tags: [
["h", "c"],
["e", "a".repeat(64)],
["k", "7"],
],
};
const response = await h.post("sign", template);
expect(response.status).toBe(200);
const event = await response.json();
expect(verifyEvent(event)).toBe(true);
expect(event.kind).toBe(5);
expect((await h.post("publish", event)).status).toBe(200);
for (const route of ["sign", "publish"]) {
for (const tags of [
[
["h", "c"],
["e", "invalid"],
["k", "7"],
],
[
["h", "c"],
["e", "a".repeat(64)],
["k", "30030"],
],
[...template.tags, ["a", `30620:${h.event.pubkey}:workflow`]],
[...template.tags, ["h", "other"]],
[
["h", "c"],
["k", "7"],
],
])
expect((await h.post(route, { ...event, tags })).status).toBe(400);
}
expect(h.publications).toHaveLength(1);
} finally {
await h.close();
}
});
45 changes: 43 additions & 2 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,41 @@ export function validMessageTemplate(event) {
})()
);
}
/** Channel-local NIP-09 removal; the relay enforces authorship of each target. */
export function validMessageDeletion(event) {
if (
event?.kind !== 5 ||
event.content !== "" ||
!Number.isSafeInteger(event.created_at) ||
event.created_at < 0 ||
!Array.isArray(event.tags) ||
event.tags.length > 106 ||
!event.tags.every(
(tag) =>
Array.isArray(tag) &&
tag.length === 2 &&
tag.every((value) => typeof value === "string") &&
["h", "e", "k", "client-id"].includes(tag[0]),
)
)
return false;
const channels = event.tags.filter(([name]) => name === "h");
const targets = event.tags.filter(([name]) => name === "e");
const kinds = event.tags.filter(([name]) => name === "k");
return (
channels.length === 1 &&
channels[0][1].length > 0 &&
channels[0][1].length <= 256 &&
targets.length > 0 &&
targets.length <= 100 &&
targets.every(([, id]) => /^[0-9a-f]{64}$/.test(id)) &&
new Set(targets.map(([, id]) => id)).size === targets.length &&
kinds.length > 0 &&
kinds.length <= 3 &&
kinds.every(([, kind]) => ["7", "9", "40002"].includes(kind))
);
}

/** Only explicit bot enrollment; never removal, role elevation or arbitrary kind-9000 tags. */
export function validAgentEnrollment(event) {
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
Expand Down Expand Up @@ -1249,7 +1284,10 @@ export function relayBrokerPlugin({
sent: false,
});
}
} else if (![7, 9].includes(filters?.kind)) {
} else if (
![7, 9].includes(filters?.kind) &&
!validMessageDeletion(filters)
) {
try {
validateWorkflowEvent(
{ ...filters, pubkey: signing ? viewer : filters.pubkey },
Expand All @@ -1262,7 +1300,10 @@ export function relayBrokerPlugin({
sent: false,
});
}
} else if (!validMessageTemplate(filters))
} else if (
[7, 9].includes(filters?.kind) &&
!validMessageTemplate(filters)
)
return json(res, 400, { error: "Message rejected" });
// Never sign or publish after the requesting browser has left.
cancel.signal.throwIfAborted();
Expand Down
38 changes: 32 additions & 6 deletions src/bundled/emoji/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Tabs } from "../../shared/design-system/ui/Tabs";
import { Button } from "../../shared/design-system/ui/Button";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import {
type RefObject,
useEffect,
useId,
useLayoutEffect,
Expand Down Expand Up @@ -37,14 +38,21 @@ export function EmojiPicker({
disabled,
insert,
reaction = false,
externalTrigger,
}: {
session: RelaySession;
scope: string;
disabled: boolean;
insert(value: string): void;
reaction?: boolean;
externalTrigger?: {
ref: RefObject<HTMLButtonElement | null>;
id: string;
close(): void;
finalFocus(): HTMLButtonElement | false;
};
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(!!externalTrigger);
const [tab, setTab] = useState<"emoji" | "gifs">("emoji");
const [gifAvailability, setGifAvailability] = useState<{
community: string;
Expand All @@ -60,7 +68,8 @@ export function EmojiPicker({
const search = useRef("");
const onInsert = useRef(insert);
onInsert.current = insert;
const id = useId();
const ownId = useId();
const id = externalTrigger?.id ?? ownId;
const community = reaction ? undefined : communityFromScope(scope);
const gifs = community
? gifAvailability?.community === community
Expand Down Expand Up @@ -295,21 +304,32 @@ export function EmojiPicker({
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
const target = externalTrigger
? externalTrigger.finalFocus()
: trigger.current;
if (target) target.focus();
}
}}
>
{reaction ? <Popover.Trigger render={button} /> : button}
{externalTrigger ? null : reaction ? (
<Popover.Trigger render={button} />
) : (
button
)}
{reaction ? (
<Popover.Portal>
<Popover.Positioner
anchor={externalTrigger?.ref}
side="bottom"
align="start"
sideOffset={6}
collisionPadding={16}
className={styles.reactionPositioner}
>
<Popover.Popup render={picker} />
<Popover.Popup
render={picker}
finalFocus={externalTrigger?.finalFocus}
/>
</Popover.Positioner>
</Popover.Portal>
) : open && !disabled ? (
Expand All @@ -318,7 +338,13 @@ export function EmojiPicker({
</fieldset>
);
return reaction ? (
<Popover.Root open={open && !disabled} onOpenChange={setOpen}>
<Popover.Root
open={open && !disabled}
onOpenChange={setOpen}
onOpenChangeComplete={(isOpen) => {
if (!isOpen) externalTrigger?.close();
}}
>
{controlsView}
</Popover.Root>
) : (
Expand Down
82 changes: 82 additions & 0 deletions src/bundled/emoji/ReactionPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useLayoutEffect, useId, useRef, useState } from "react";
import type { ReactionToolProps } from "../../features/conversation/contracts";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import { SmileyStickerIcon } from "../../shared/design-system/icons";
import { EmojiPicker } from "./EmojiPicker";

/** Plugin-owned coordination; inactive rows keep only their accessible trigger. */
export function createReactionPicker() {
let active: (() => void) | undefined;
return function ReactionPicker({
session,
scope,
disabled,
select,
}: ReactionToolProps) {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const close = useRef(() => {
setOpen(false);
if (active === close) active = undefined;
}).current;
const id = useId();
useLayoutEffect(
() => () => {
if (active === close) active = undefined;
},
[close],
);
useLayoutEffect(() => {
if (disabled) {
close();
if (active === close) active = undefined;
}
}, [disabled, close]);
return (
<>
<IconButton
ref={trigger}
size="toolbar"
type="button"
aria-label="Add reaction"
title="Add reaction"
aria-haspopup="dialog"
aria-expanded={open && !disabled}
aria-controls={open && !disabled ? id : undefined}
disabled={disabled}
icon={<SmileyStickerIcon size={18} aria-hidden="true" />}
onClick={() => {
if (open) {
close();
return;
}
active?.();
active = close;
void session.emoji.ensure();
setOpen(true);
}}
/>
{open && !disabled && (
<EmojiPicker
session={session}
scope={scope}
disabled={disabled}
insert={select}
reaction
externalTrigger={{
ref: trigger,
id,
close,
finalFocus: () =>
active === close &&
trigger.current?.isConnected &&
!trigger.current.disabled
? trigger.current
: false,
}}
/>
)}
</>
);
};
}
17 changes: 2 additions & 15 deletions src/bundled/emoji/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { emojiQuery } from "./emoji-query";
import type { PluginModule } from "../../plugins/api";
import type {
ComposerToolProps,
ReactionToolProps,
InlineContent,
} from "../../features/conversation/contracts";
import { emojiMatches } from "../../features/relay/emoji";
import { createReactionPicker } from "./ReactionPicker";
import { EmojiPicker } from "./EmojiPicker";
import { CustomEmoji } from "./CustomEmoji";
import { copyEmoji } from "./copy-emoji";
Expand Down Expand Up @@ -34,20 +34,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
ctx.conversation.registerTool({
id: "picker",
title: "Emoji",
reactionComponent: ({
session,
scope,
disabled,
select,
}: ReactionToolProps) => (
<EmojiPicker
session={session}
scope={scope}
disabled={disabled}
insert={select}
reaction
/>
),
reactionComponent: createReactionPicker(),
component: ({
session,
scope,
Expand Down
Loading
Loading