Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
assertSidebarMuteIntent,
mutateSidebarMute,
} from "./sidebar-mutes.mjs";
import {
assertSidebarStarIntent,
mutateSidebarStar,
Expand Down Expand Up @@ -561,10 +565,12 @@ export function relayBrokerPlugin({
[
"/api/relay/sidebar-assignment",
"/api/relay/sidebar-star",
"/api/relay/sidebar-mute",
].includes(route) &&
req.method === "POST"
) {
const starring = route === "/api/relay/sidebar-star";
const muting = route === "/api/relay/sidebar-mute";
let raw = "";
for await (const part of req) {
raw += part;
Expand All @@ -577,6 +583,7 @@ export function relayBrokerPlugin({
try {
intent = JSON.parse(raw);
if (starring) assertSidebarStarIntent(intent);
else if (muting) assertSidebarMuteIntent(intent);
else assertSidebarAssignmentIntent(intent);
} catch {
return json(res, 400, {
Expand All @@ -595,7 +602,13 @@ export function relayBrokerPlugin({
{
kinds: [30078],
authors: [viewer],
"#d": [starring ? "channel-stars" : "channel-sections"],
"#d": [
starring
? "channel-stars"
: muting
? "channel-mutes"
: "channel-sections",
],
limit: 1,
},
];
Expand Down Expand Up @@ -670,12 +683,13 @@ export function relayBrokerPlugin({
"Sidebar preference publication was not accepted",
);
};
return (starring ? mutateSidebarStar : mutateSidebarAssignment)(
intent,
key,
readHead,
publishEvent,
);
return (
starring
? mutateSidebarStar
: muting
? mutateSidebarMute
: mutateSidebarAssignment
)(intent, key, readHead, publishEvent);
});
sidebarMutations.set(relay, mutation);
try {
Expand Down Expand Up @@ -727,6 +741,7 @@ export function relayBrokerPlugin({
readState: true,
sidebarPreferenceWrites: true,
sidebarStarWrites: true,
sidebarMuteWrites: true,
agentLibrary: true,
live: true,
agentActivity: true,
Expand Down
90 changes: 90 additions & 0 deletions dev/sidebar-mutes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { finalizeEvent, getPublicKey, nip44 } from "nostr-tools";
import { decodeSidebarPreferences } from "./sidebar-preferences.mjs";

const COORDINATE = "channel-mutes";
export function assertSidebarMuteIntent(intent) {
if (
!intent ||
typeof intent !== "object" ||
Array.isArray(intent) ||
typeof intent.channelId !== "string" ||
!intent.channelId.trim() ||
intent.channelId.length > 256 ||
typeof intent.muted !== "boolean" ||
Object.keys(intent).some((key) => !["channelId", "muted"].includes(key))
)
throw new Error("Invalid sidebar mute intent");
}

/** One explicit mute intent against a fresh signed head; keep unmute tombstones. */
export function prepareSidebarMute(events, intent, secret, now = Date.now()) {
assertSidebarMuteIntent(intent);
// The shared bounded decoder verifies signature, own author, schema and budgets.
decodeSidebarPreferences(events, secret);
if (
events.length > 1 ||
events.some(
(event) =>
!event.tags.some(
([name, value]) => name === "d" && value === COORDINATE,
),
)
)
throw new Error("Invalid sidebar mute head");
const viewer = getPublicKey(secret);
const key = nip44.v2.utils.getConversationKey(secret, viewer);
try {
const head = events[0];
const current = head
? JSON.parse(nip44.v2.decrypt(head.content, key))
: { version: 1, channels: {} };
const previous = Object.hasOwn(current.channels, intent.channelId)
? current.channels[intent.channelId]
: undefined;
if (previous?.muted === intent.muted) return { mutes: current };
const mutes = {
...current,
channels: {
...current.channels,
[intent.channelId]: {
...previous,
muted: intent.muted,
updatedAt: Math.max(now, (previous?.updatedAt ?? 0) + 1),
},
},
};
const event = finalizeEvent(
{
kind: 30078,
content: nip44.v2.encrypt(JSON.stringify(mutes), key),
created_at: Math.max(
Math.floor(now / 1000),
(head?.created_at ?? 0) + 1,
),
tags: [
["d", COORDINATE],
["t", COORDINATE],
],
},
secret,
);
// Refuse over-budget changes rather than silently trimming other channels.
decodeSidebarPreferences([event], secret);
return { mutes, event };
} finally {
key.fill(0);
}
}

export async function mutateSidebarMute(intent, secret, readHead, publish) {
assertSidebarMuteIntent(intent);
const draft = prepareSidebarMute(await readHead(), intent, secret);
if (!draft.event) return draft.mutes;
await publish(draft.event);
const confirmation = prepareSidebarMute(await readHead(), intent, secret);
if (confirmation.event)
throw new Error(
"Sidebar mutes changed on another device; reload and try again",
);
return confirmation.mutes;
}
207 changes: 207 additions & 0 deletions dev/sidebar-mutes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { expect, it, vi } from "vitest";
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip44,
verifyEvent,
} from "nostr-tools";
import {
assertSidebarMuteIntent,
prepareSidebarMute,
mutateSidebarMute,
} from "./sidebar-mutes.mjs";
import {
decodeSidebarPreferences,
SIDEBAR_REQUEST_BYTES,
} from "./sidebar-preferences.mjs";

function harness() {
const secret = generateSecretKey();
const viewer = getPublicKey(secret);
return {
secret,
viewer,
encrypt(channels, overrides = {}) {
const key = nip44.v2.utils.getConversationKey(secret, viewer);
try {
return finalizeEvent(
{
kind: 30078,
created_at: 100,
tags: [["d", "channel-mutes"]],
content: nip44.v2.encrypt(
JSON.stringify({ version: 1, channels }),
key,
),
...overrides,
},
secret,
);
} finally {
key.fill(0);
}
},
};
}
it("rejects invalid intent shapes before relay reads", async () => {
const h = harness();
for (const intent of [
null,
[],
{},
{ channelId: "", muted: true },
{ channelId: "a" },
{ channelId: "a", muted: 1 },
{ channelId: "x".repeat(257), muted: true },
{ channelId: "a", muted: true, extra: 1 },
])
expect(() => assertSidebarMuteIntent(intent)).toThrow(
"Invalid sidebar mute intent",
);
const read = vi.fn();
await expect(mutateSidebarMute({}, h.secret, read, vi.fn())).rejects.toThrow(
"Invalid sidebar mute intent",
);
expect(read).not.toHaveBeenCalled();
});
it("encrypts explicit Mute/Unmute with monotonic timestamps and preserves unrelated tombstones", () => {
const h = harness();
const channels = {
alpha: { muted: false, updatedAt: 60000 },
beta: { muted: true, updatedAt: 2 },
gone: { muted: false, updatedAt: 3 },
};
const added = prepareSidebarMute(
[h.encrypt(channels)],
{ channelId: "alpha", muted: true },
h.secret,
50000,
);
expect(verifyEvent(added.event)).toBe(true);
expect(added.event).toMatchObject({
pubkey: h.viewer,
kind: 30078,
created_at: 101,
tags: [
["d", "channel-mutes"],
["t", "channel-mutes"],
],
});
expect(added.event.content).not.toContain("alpha");
expect(added.mutes.channels).toEqual({
...channels,
alpha: { muted: true, updatedAt: 60001 },
});
expect(decodeSidebarPreferences([added.event], h.secret).muted).toEqual([
"alpha",
"beta",
]);
const removed = prepareSidebarMute(
[added.event],
{ channelId: "alpha", muted: false },
h.secret,
50000,
);
expect(removed.mutes.channels).toEqual({
...channels,
alpha: { muted: false, updatedAt: 60002 },
});
expect(decodeSidebarPreferences([removed.event], h.secret).muted).toEqual([
"beta",
]);
expect(
prepareSidebarMute(
[removed.event],
{ channelId: "alpha", muted: false },
h.secret,
).event,
).toBeUndefined();
expect(
prepareSidebarMute([], { channelId: "new", muted: false }, h.secret, 50000)
.mutes.channels,
).toEqual({ new: { muted: false, updatedAt: 50000 } });
});
it("refuses untrusted, ambiguous, malformed and over-budget heads rather than seeding", () => {
const h = harness(),
other = harness();
const intent = { channelId: "alpha", muted: true };
const valid = h.encrypt({});
for (const events of [
null,
[other.encrypt({})],
[valid, valid],
[{ ...JSON.parse(JSON.stringify(valid)), sig: "0".repeat(128) }],
[h.encrypt({}, { tags: [["d", "channel-sections"]] })],
[
h.encrypt(
{},
{
tags: [
["d", "channel-mutes"],
["d", "channel-mutes"],
],
},
),
],
[h.encrypt({ alpha: { muted: true, updatedAt: -1 } })],
[h.encrypt({}, { content: "x".repeat(SIDEBAR_REQUEST_BYTES) })],
])
expect(() => prepareSidebarMute(events, intent, h.secret)).toThrow();
const full = Object.fromEntries(
Array.from({ length: 500 }, (_, i) => [
`id-${i}`,
{ muted: false, updatedAt: 1 },
]),
);
expect(() => prepareSidebarMute([h.encrypt(full)], intent, h.secret)).toThrow(
"budget exceeded",
);
});
it("confirms fresh retained state, including newer unrelated entries, and does not publish no-ops", async () => {
const h = harness();
let heads = [];
const read = vi.fn(async () => heads);
const publish = vi.fn(async () => {
heads = [
h.encrypt({
alpha: { muted: true, updatedAt: 1 },
beta: { muted: true, updatedAt: 2 },
}),
];
});
const intent = { channelId: "alpha", muted: true };
expect(
(await mutateSidebarMute(intent, h.secret, read, publish)).channels,
).toHaveProperty("beta");
expect(read).toHaveBeenCalledTimes(2);
expect(publish).toHaveBeenCalledOnce();
await mutateSidebarMute(intent, h.secret, read, publish);
expect(publish).toHaveBeenCalledOnce();
});
it("does not report success on read/publish failures or conflicting confirmation", async () => {
const h = harness(),
intent = { channelId: "alpha", muted: true };
const publish = vi.fn();
await expect(
mutateSidebarMute(
intent,
h.secret,
async () => {
throw new Error("read failed");
},
publish,
),
).rejects.toThrow("read failed");
expect(publish).not.toHaveBeenCalled();
const read = vi.fn(async () => []);
await expect(
mutateSidebarMute(intent, h.secret, read, async () => {
throw new Error("publish failed");
}),
).rejects.toThrow("publish failed");
expect(read).toHaveBeenCalledOnce();
await expect(
mutateSidebarMute(intent, h.secret, read, publish),
).rejects.toThrow("changed on another device");
});
Loading
Loading