Skip to content
Merged
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
21 changes: 21 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,27 @@ test("local capacity is explicitly unsent, not relay quota; unknown upstream pub
}
}, 10000);

test("strong channel confirmation filters reach the upstream query unchanged", async () => {
const h = await harness(() => Response.json([]));
try {
const transport = await connectBrokerTransport(h.base);
const filters = [
{
kinds: [39002],
"#d": ["11111111-1111-4111-8111-111111111111"],
limit: 1,
consistency: "strong",
},
];
await transport.query(filters);
expect(h.calls).toHaveLength(1);
expect(h.calls[0].url).toBe(`${fixtureRelayUrl}/query`);
expect(h.calls[0].body).toEqual(filters);
} finally {
await h.close();
}
});

// Reader-to-host priority propagation control contributed by Brain.
test("reader and transport start foreground work without waiting for background completion", async () => {
let release;
Expand Down
33 changes: 33 additions & 0 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ locally authored event is **not proof of relay acceptance**. Signature-verified
membership, bounds and persistence. Domain folds can consume local payloads, but
must not let them manufacture relay-authored authority.

## Read-your-writes consistency

`fresh: true` prevents sharing an older in-flight request; it does not select
the writer. Channel creation/admission and recovery, DM opening, channel edits,
member administration, lifecycle confirmations, and mention preflights request
`consistency: "strong"` on their authoritative filters. Signed evidence remains
required; an accepted command is not membership, and writer routing does not
wait for asynchronous relay side effects. Existing cancellation and bounded
confirmation retries are unchanged.

Channel discovery accepts an explicit consistency option for post-write exact
reads and the full-roster fallback. A queued writer-backed refresh survives an
older in-flight pass or quota pause. If the writer-backed pass itself fails or
is interrupted, including its metadata phase, its next pass retains writer routing and the
existing cooldown; a successful pass returns later refreshes to ordinary routing.
Signed membership hints use writer-backed exact reads or the existing full-roster
fallback, including metadata. A replica pass superseding pending exact hint
confirmations queues a writer-backed pass to settle those grants. If list failure,
disconnect or cache clear retires queued or in-flight hint confirmations, the store
retains their writer requirement for the next deliberate refresh, Retry or
establishment, without starting an automatic recovery pass or bypassing cooldown.
Ordinary startup, browsing, reconnect, and DM visibility refresh stay replica-
eligible. The details editor and member-administration capability use writer-backed
state for their shared load/preflight/confirmation reads; the member dialog's
separate display-roster load remains replica-eligible. Work-session membership
preflights (including session sends and canvas saves) also use the writer, so a
just-added member does not fail the next operation.
Template setup also confirms exact Canvas/member events and selected Canvas heads
against the writer without replaying accepted commands. Agent deletion discovers
member channels and confirms each removal with writer-backed rosters; unreadable
rosters still fail closed. Ordinary Canvas browsing and standalone Canvas/recipe
save confirmation routing are unchanged by this policy.

## Community emoji

`session.emoji` owns the current community's kind-30030 `d=buzz:custom-emoji`
Expand Down
69 changes: 67 additions & 2 deletions src/bundled/channel-templates/agent-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { createAgentControl } from "../../features/agents/control";
import { controlFixture } from "../../features/agents/control-testing";
import { keypair, signed, roster } from "../../features/relay/testing";
import { matchesEvent } from "../../features/relay/projection";
import type { RelayEvent } from "../../features/relay/events";
import type { ReadFilter, RelayEvent } from "../../features/relay/events";
import { createOutbox, type OutgoingEvent } from "../../features/relay/outbox";
import type { EventTemplate } from "nostr-tools";
import type { KitRecord } from "../../features/channel-templates/model";
Expand Down Expand Up @@ -107,6 +107,8 @@ function harness(
});
const stored = new Map<string, KitRecord>();
const published: RelayEvent[] = [];
const replica = { lag: false };
const reads: ReadFilter[] = [];
const sign = vi.fn(async (value: EventTemplate) => signed(viewer, value));
let journal: readonly OutgoingEvent[] = [];
const channels = new Map<string, string[]>([
Expand Down Expand Up @@ -179,6 +181,7 @@ function harness(
},
},
query: async (filters) => {
reads.push(...filters);
if (filters.some((filter) => filter.kinds?.includes(40100)))
await beforeCanvasRead?.();
if (
Expand Down Expand Up @@ -215,7 +218,13 @@ function harness(
),
];
return events.filter((event) =>
filters.some((filter) => matchesEvent(event, filter)),
filters.some(
(filter) =>
matchesEvent(event, filter) &&
(!replica.lag ||
filter.consistency === "strong" ||
![9007, 9000, 40100, 39000, 39002].includes(event.kind)),
),
);
},
},
Expand All @@ -235,6 +244,8 @@ function harness(
native,
fixture,
published,
replica,
reads,
sign,
viewer,
journal: () => journal,
Expand Down Expand Up @@ -1269,6 +1280,60 @@ it.each([
},
);

it.each(["", "# Seed plan"])(
"finishes template setup against the writer with stale replicas and no live echoes (Canvas: %s)",
async (canvas) => {
const test = harness();
test.replica.lag = true;
try {
const id = await test.owner.session.channelCreation.create({
name: "Writer-confirmed setup",
visibility: "private",
setup: {
agents: [test.fixture.agent.pubkey],
canvas,
groupId: "",
templateId: "saved",
},
});
const receipt = `buzz-channel-setup.v2:https://relay.example.test:${test.viewer.pubkey}:${id}`;
// Receipt retirement is the completion barrier, not admission or publish ACK.
await waitFor(() => expect(localStorage.getItem(receipt)).toBeNull());
expect(test.owner.session.channelCreation.notices()).toEqual([]);
expect(test.published.map((event) => event.kind)).toEqual(
canvas ? [9007, 40100, 9000] : [9007, 9000],
);
expect(test.sign).toHaveBeenCalledTimes(test.published.length);
expect(test.journal()).toEqual([]);
expect(test.owner.session.channels.get?.(id)?.members).toContain(
test.fixture.agent.pubkey,
);
for (const event of test.published.filter((event) => event.kind !== 9007))
expect(test.reads).toContainEqual({
ids: [event.id],
limit: 1,
consistency: "strong",
});
if (canvas) {
const heads = test.reads.filter((filter) =>
filter.kinds?.includes(40100),
);
expect(heads).toHaveLength(3); // Before seeding, after save, before members.
expect(heads.every((filter) => filter.consistency === "strong")).toBe(
true,
);
// Ordinary browsing still uses the lagging replica, not the writer.
await expect(
test.owner.session.canvas.read(id),
).resolves.toBeUndefined();
expect(test.reads.at(-1)?.consistency).toBeUndefined();
}
} finally {
test.dispose();
}
},
);

it("retains a failed group placement independently and permits another Create", async () => {
const test = harness();
try {
Expand Down
44 changes: 44 additions & 0 deletions src/bundled/profiles/ProfileAgentArchive.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { afterEach, expect, it, vi } from "vitest";
import { createRelaySession } from "../../features/relay/session";
import type { LiveCallbacks } from "../../features/relay/live";
import type { RelayData } from "../../features/relay/service";
import { matchesEvent } from "../../features/relay/projection";
import {
archiveRelay,
keypair,
Expand Down Expand Up @@ -418,6 +419,49 @@ it("verified owner deletes through the base confirmation: confirmed channel remo
expect(fixture.instance.session.outbox?.snapshot()).toEqual([]),
);
});
it("deletes using writer rosters when replicas miss a recent channel and retain removed members", async () => {
const fixture = mountManaged(viewer, true, {
[room]: [viewer.pubkey, agent.pubkey],
});
const query = fixture.transport.query;
const replica = await query(
[
{
kinds: [39002],
authors: [relay.pubkey],
"#p": [agent.pubkey],
limit: 500,
},
],
new AbortController().signal,
);
// A recent addition exists only on the writer and is not in the loaded list.
fixture.channels[hidden] = [agent.pubkey];
expect(fixture.instance.session.channels.list().channels).not.toContainEqual(
expect.objectContaining({ id: hidden }),
);
fixture.transport.query = async (filters, ...rest) =>
(
await Promise.all(
filters.map((filter) =>
filter.kinds?.includes(39002) && filter.consistency !== "strong"
? replica.filter((event) => matchesEvent(event, filter))
: query([filter], ...rest),
),
)
).flat();
await confirmDelete(userEvent.setup());
await vi.waitFor(() => expect(fixture.close).toHaveBeenCalledOnce());
expect(fixture.published.map((event) => event.kind)).toEqual([
9001, 9001, 9035,
]);
expect(fixture.channels).toEqual({ [room]: [viewer.pubkey], [hidden]: [] });
expect(deletes(fixture)).toHaveLength(1);
await vi.waitFor(() =>
expect(fixture.instance.session.outbox?.snapshot()).toEqual([]),
);
});

it("cancel leaves the agent untouched", async () => {
const fixture = mountManaged();
const user = userEvent.setup();
Expand Down
23 changes: 21 additions & 2 deletions src/features/channel-members/administration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, expect, it, vi } from "vitest";
import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools";
import type { RelayEvent } from "../relay/events";
import type { ReadFilter, RelayEvent } from "../relay/events";
import { PublishRejected } from "../relay/outbox";
import { createRelaySession } from "../relay/session";
import { matchesEvent } from "../relay/projection";
Expand Down Expand Up @@ -65,7 +65,9 @@ function harness(actor = "owner", role: string | undefined = "member") {
...roster(actor, role),
];
let access = true;
const read = vi.fn(async (_filters?: unknown, _options?: unknown) => events);
const read = vi.fn(
async (_filters: readonly ReadFilter[], _options?: unknown) => events,
);
const sign = vi.fn(async (template: EventTemplate) =>
finalizeEvent(structuredClone(template), key),
);
Expand Down Expand Up @@ -110,6 +112,22 @@ function harness(actor = "owner", role: string | undefined = "member") {
};
}

it.each(["admin", "remove"] as const)(
"confirms %s while the replica retains the previous role",
async (role) => {
const h = harness();
const replica = h.events();
h.read.mockImplementation(async (filters) =>
filters.every((filter) => filter.consistency === "strong")
? h.events()
: replica,
);
await h.owner.capability.run(id, { ...change, role });
expect(h.owner.capability.snapshot(id).operation?.status).toBe("confirmed");
expect(h.publish).toHaveBeenCalledOnce();
},
);

it.each(["admin", "member", "guest", "remove"] as const)(
"confirms %s from exact fresh signed state, never acknowledgement alone",
async (role) => {
Expand All @@ -123,6 +141,7 @@ it.each(["admin", "member", "guest", "remove"] as const)(
expect(h.read.mock.calls[0]).toEqual([
[39000, 39001, 39002].map((kind) => ({
kinds: [kind],
consistency: "strong",
authors: [author],
"#d": [id],
limit: 1,
Expand Down
1 change: 1 addition & 0 deletions src/features/channel-members/administration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function createMemberAdministration({
const events = await reader.read(
[39000, 39001, 39002].map((kind) => ({
kinds: [kind],
consistency: "strong" as const,
authors: [relayAuthor],
"#d": [id],
limit: 1,
Expand Down
6 changes: 4 additions & 2 deletions src/features/channel-templates/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,14 @@ export function createChannelKit({
});
const canvas = Object.freeze({
available: !!outbox?.supports(40100),
async read(channel: string) {
async read(channel: string, options: Pick<ReadFilter, "consistency"> = {}) {
signal.throwIfAborted();
if (!canWrite(channel))
throw new Error("Canvas is unavailable after channel access changed");
return selectedHead(
await fresh([{ kinds: [40100], "#h": [channel], limit: 1 }]),
await fresh([
{ kinds: [40100], "#h": [channel], limit: 1, ...options },
]),
);
},
async save(channel: string, content: string, expected: string | undefined) {
Expand Down
17 changes: 16 additions & 1 deletion src/features/relay/channel-details.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function harness(role = "owner") {
record(39001, role === "member" ? [] : [["p", viewer, role]]),
record(39002, [["p", viewer, "", role]]),
];
const read = vi.fn(async () => events);
const read = vi.fn(async (_filters: readonly ReadFilter[]) => events);
const sign = vi.fn(async (event: EventTemplate) =>
finalizeEvent(structuredClone(event), key),
);
Expand Down Expand Up @@ -114,6 +114,7 @@ it("saves only after two fresh authority checks and projects confirmed readback"
expect(call).toEqual([
[39000, 39001, 39002].map((kind) => ({
kinds: [kind],
consistency: "strong",
authors: [relayAuthor],
"#d": [id],
limit: 1,
Expand All @@ -125,6 +126,20 @@ it("saves only after two fresh authority checks and projects confirmed readback"
expect(h.acceptDiscovery).toHaveBeenCalledWith([h.events()[0]]);
expect(h.owner.capability.snapshot(id)).toBeUndefined();
});
it("confirms channel edits while the replica still has the old metadata", async () => {
const h = harness();
const replica = h.events();
h.read.mockImplementation(async (filters) =>
filters.every((filter) => filter.consistency === "strong")
? h.events()
: replica,
);
const base = await h.owner.capability.load(id);
await h.owner.capability.save(base, draft);
expect(h.publish).toHaveBeenCalledOnce();
expect(h.acceptDiscovery).toHaveBeenLastCalledWith([h.events()[0]]);
expect(h.owner.capability.snapshot(id)).toBeUndefined();
});
it.each(["owner", "admin", "member"])(
"derives current direct %s authority",
async (role) => {
Expand Down
1 change: 1 addition & 0 deletions src/features/relay/channel-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function createChannelDetails({
const events = await reader.read(
[39000, 39001, 39002].map((kind) => ({
kinds: [kind],
consistency: "strong" as const,
authors: [relayAuthor],
"#d": [id],
limit: 1,
Expand Down
Loading
Loading