From 70216755cf29d13214e9ea175abfea43d07fae9c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 12:40:30 -0500 Subject: [PATCH 1/3] fix(relay): keep saved channels when the community refuses sign-in A read for one channel that met the relay's community-wide NIP-FI refusal ("auth-required: authentication required") ran the per-channel denial, which removes that channel's saved copy from disk. A refused first channel-directory read cleared the whole saved cache the same way. The relay service already owns community refusals and keeps saved data when only the sign-in ended, so these paths lost what it meant to keep. Per-channel and directory denial now leave community-wide refusals (sign-in or access) to the relay service. A refusal of one channel still forgets that channel. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/features/relay/revocation.test.ts | 67 +++++++++++++++++++++++++++ src/features/relay/store.ts | 12 ++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/features/relay/revocation.test.ts b/src/features/relay/revocation.test.ts index d32bad05cf..93067decc5 100644 --- a/src/features/relay/revocation.test.ts +++ b/src/features/relay/revocation.test.ts @@ -2,6 +2,7 @@ import { assert, afterEach, expect, it, vi } from "vitest"; import type { OutgoingEvent, OutboxStorage } from "./outbox"; import type { ChannelStoreOptions } from "./store"; import { createRelaySession } from "./session"; +import { ReadError } from "./errors"; import type { RelayEvent } from "./events"; import { type Key, @@ -911,3 +912,69 @@ it("a profile subscriber cannot restore a persisted head after revoking and regr h.session.channels.ensure("a"); expect(h.session.channels.window("a").rows).toEqual([]); }); + +function emptyDisk() { + return { + read: vi.fn(async () => []), + write: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + retain: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + close: vi.fn(), + }; +} + +it.each([ + ["an ended sign-in", "auth-required: authentication required", 401], + ["lost community access", "restricted: authorization denied", 403], +])( + "a channel read refused for %s leaves saved data to the community owner", + async (_case, reason, status) => { + const disk = emptyDisk(); + const h = setup({ prepared: true, persistence: disk }); + h.emit([roster(relay, "a", [viewer.pubkey], 10)]); + await flush(); + h.session.channels.ensure("a"); + await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); + for (const request of h.pending.splice(0)) + request.fail(new ReadError("denied", reason, status)); + await flush(); + expect(disk.remove).not.toHaveBeenCalled(); + expect(disk.clear).not.toHaveBeenCalled(); + }, +); + +it("a channel read refused for that channel alone still forgets its saved copy", async () => { + const disk = emptyDisk(); + const h = setup({ prepared: true, persistence: disk }); + h.emit([roster(relay, "a", [viewer.pubkey], 10)]); + await flush(); + h.session.channels.ensure("a"); + await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); + for (const request of h.pending.splice(0)) + request.fail(new ReadError("denied", "restricted: not a member", 403)); + await flush(); + await vi.waitFor(() => expect(disk.remove).toHaveBeenCalledWith("a")); +}); + +it.each([ + [ + "a community refusal keeps", + "auth-required: authentication required", + 401, + 0, + ], + ["a directory denial clears", "restricted: not a member", 403, 1], +])( + "%s saved data when the first channel directory read is refused", + async (_case, reason, status, clears) => { + const disk = emptyDisk(); + const h = setup({ persistence: disk }); + h.session.channels.ensureList(); + await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); + for (const request of h.pending.splice(0)) + request.fail(new ReadError("denied", reason, status)); + await flush(); + expect(disk.clear).toHaveBeenCalledTimes(clears); + }, +); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index dbf21bed1c..2fc410d046 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -4,6 +4,7 @@ import type { Outbox } from "./outbox"; import { MessageProjection } from "./message-projection"; import { createRelayProfiler, type RelayProfiler } from "./profiling"; import { isTransientFailure, ReadError, readErrorKind } from "./errors"; +import { refusalOfError } from "./http-admission"; import type { ChannelReference, ChannelList, @@ -924,6 +925,10 @@ export function createChannelStore( // A chat still starting is not there to deny yet; only a refused or // missing create ends it (see channelWrites.start). if (discovery?.isPending(channelId)) return; + // The community refused the account, not this channel. Its owner (the + // relay service's deny) ends the session, and keeps saved data for an + // ended sign-in; removing this channel here would lose it. + if (refusalOfError(error)) return; accessVersions.set(channelId, (accessVersions.get(channelId) ?? 0) + 1); discovery?.deny(channelId); allowed?.delete(channelId); @@ -2083,7 +2088,12 @@ export function createChannelStore( }; if (error instanceof ReadError && error.retryAfterMs !== undefined) listRetryAt = performance.now() + error.retryAfterMs; - if (denyAllOnFailure && readErrorKind(error) === "denied") { + if ( + denyAllOnFailure && + readErrorKind(error) === "denied" && + // A community-wide refusal belongs to the relay service (see denyChannel). + !refusalOfError(error) + ) { discovery.denyAll(); transport.revokeAccess(() => { for (const id of allowed ?? []) From 56a6a1462ee18ec4c3e5355309ff7d1b79c0377b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 12:57:48 -0500 Subject: [PATCH 2/3] fix(relay): settle a channel window on a community refusal Leaving a community refusal to the relay service skipped the whole per-channel denial, including the in-memory part. A refused page read then left its window loading with no read pending, and rows already on screen stayed visible until the service's deny arrived (or forever, if it never did). A community refusal now ends the window's reads and shows the refusal with no rows, the same as a channel denial. It still keeps the channel's access, list entry and saved copy for the relay service to decide on. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/features/relay/revocation.test.ts | 59 ++++++++++++++++++++++++--- src/features/relay/store.ts | 46 ++++++++++++--------- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/src/features/relay/revocation.test.ts b/src/features/relay/revocation.test.ts index 93067decc5..9c596fe72f 100644 --- a/src/features/relay/revocation.test.ts +++ b/src/features/relay/revocation.test.ts @@ -928,22 +928,69 @@ it.each([ ["an ended sign-in", "auth-required: authentication required", 401], ["lost community access", "restricted: authorization denied", 403], ])( - "a channel read refused for %s leaves saved data to the community owner", + "a channel read refused for %s hides the channel's rows but leaves saved data to the community owner", async (_case, reason, status) => { const disk = emptyDisk(); - const h = setup({ prepared: true, persistence: disk }); + const h = setup({ persistence: disk }); h.emit([roster(relay, "a", [viewer.pubkey], 10)]); - await flush(); h.session.channels.ensure("a"); + h.next().respond([ + message(alice, "a", "shown", 11), + bounds(relay, "a", "head", { has_more: false, next_cursor: null }), + ]); + await flush(); + expect(h.session.channels.window("a").rows).toHaveLength(1); + // A revalidation of the rows on screen meets the refusal. + h.session.channels.refresh?.("a"); await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); for (const request of h.pending.splice(0)) request.fail(new ReadError("denied", reason, status)); await flush(); + expect(h.session.channels.window("a")).toMatchObject({ + status: "error", + rows: [], + loadingOlder: false, + }); + expect(h.session.channels.window("a").refreshing).toBeUndefined(); + expect(h.pending).toEqual([]); expect(disk.remove).not.toHaveBeenCalled(); expect(disk.clear).not.toHaveBeenCalled(); }, ); +it("a first channel read refused by the community settles the window instead of loading forever", async () => { + const disk = emptyDisk(); + const h = setup({ prepared: true, persistence: disk }); + h.emit([roster(relay, "a", [viewer.pubkey], 10)]); + await flush(); + h.session.channels.ensure("a"); + await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); + for (const request of h.pending.splice(0)) + request.fail( + new ReadError("denied", "auth-required: authentication required", 401), + ); + await flush(); + expect(h.session.channels.window("a").status).toBe("error"); + expect(disk.remove).not.toHaveBeenCalled(); +}); + +it.each([ + ["the community", "auth-required: authentication required", 401, 0], + ["that channel alone", "restricted: not a channel member", 403, 1], +])( + "a single-channel session read refused by %s leaves the channel's saved copy to the right owner", + async (_case, reason, status, removals) => { + const disk = emptyDisk(); + const h = setup({ persistence: disk }); + h.emit([roster(relay, "a", [viewer.pubkey], 10)]); + const read = h.session.read([{ kinds: [9], "#h": ["a"], limit: 20 }]); + h.next().fail(new ReadError("denied", reason, status)); + await expect(read).rejects.toThrow(reason); + await flush(); + expect(disk.remove).toHaveBeenCalledTimes(removals); + }, +); + it("a channel read refused for that channel alone still forgets its saved copy", async () => { const disk = emptyDisk(); const h = setup({ prepared: true, persistence: disk }); @@ -952,7 +999,9 @@ it("a channel read refused for that channel alone still forgets its saved copy", h.session.channels.ensure("a"); await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); for (const request of h.pending.splice(0)) - request.fail(new ReadError("denied", "restricted: not a member", 403)); + request.fail( + new ReadError("denied", "restricted: not a channel member", 403), + ); await flush(); await vi.waitFor(() => expect(disk.remove).toHaveBeenCalledWith("a")); }); @@ -964,7 +1013,7 @@ it.each([ 401, 0, ], - ["a directory denial clears", "restricted: not a member", 403, 1], + ["a directory denial clears", "restricted: not a channel member", 403, 1], ])( "%s saved data when the first channel directory read is refused", async (_case, reason, status, clears) => { diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 2fc410d046..8cd032386d 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -921,14 +921,37 @@ export function createChannelStore( if (!discovery?.authorized(id) && unchangedPreview(id, version)) denyChannel(id, new Error("Conversation is unavailable")); } + /** Ends a window's reads and leaves it showing only the refusal. */ + function closeWindow(state: WindowState, error: unknown) { + state.generation++; + state.controller?.abort(); + state.noticeRead?.controller.abort(); + state.events = []; + state.notices.clear(); + state.noticeCursor = undefined; + state.floor = undefined; + state.traffic.clear(); + setWindow(state, { + status: "error", + rows: EMPTY_ROWS, + error: describe(error), + loadingOlder: false, + refreshing: undefined, + }); + } function denyChannel(channelId: string, error: unknown) { // A chat still starting is not there to deny yet; only a refused or // missing create ends it (see channelWrites.start). if (discovery?.isPending(channelId)) return; // The community refused the account, not this channel. Its owner (the - // relay service's deny) ends the session, and keeps saved data for an - // ended sign-in; removing this channel here would lose it. - if (refusalOfError(error)) return; + // relay service's deny) ends the session and decides what saved data + // survives: it keeps it for an ended sign-in. Until then, stop showing + // this channel's rows, but keep its access and saved copy. + if (refusalOfError(error)) { + const state = windows.get(channelId); + if (state) closeWindow(state, error); + return; + } accessVersions.set(channelId, (accessVersions.get(channelId) ?? 0) + 1); discovery?.deny(channelId); allowed?.delete(channelId); @@ -936,22 +959,7 @@ export function createChannelStore( heads.delete(channelId); tails.delete(channelId); const state = windows.get(channelId); - if (state) { - state.generation++; - state.controller?.abort(); - state.noticeRead?.controller.abort(); - state.events = []; - state.notices.clear(); - state.noticeCursor = undefined; - state.floor = undefined; - state.traffic.clear(); - setWindow(state, { - status: "error", - rows: EMPTY_ROWS, - error: describe(error), - loadingOlder: false, - }); - } + if (state) closeWindow(state, error); setList( { ...list, From 02dfacbcb78ea73103ff6407fe550b1f259e8c23 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 10 Nov 2026 13:06:20 -0500 Subject: [PATCH 3/3] fix(relay): report native refusals from the response, and drop cached heads The community owner (the relay service) heard a native refusal only through the window's relay-refusal event. If that listener failed to install, a channel refused for the whole community kept its cached head, preview and other session views until a deny that never came. Now every native response that carries the relay's community refusal also reports it to that community's observers, as the browser build's broker already does. The service takes the first report, so the event and the response reporting the same refusal change nothing. A community refusal of a channel read also drops that channel's in-memory head and tail, so revisiting the channel or reading its sidebar preview cannot bring the refused rows back before the service ends the session. The saved copy on disk is still left to the service. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/features/relay/native.test.ts | 43 +++++++++++++++++++++++++++ src/features/relay/native.ts | 28 +++++++++++++---- src/features/relay/revocation.test.ts | 39 ++++++++++++++++++++++++ src/features/relay/store.ts | 5 +++- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/features/relay/native.test.ts b/src/features/relay/native.test.ts index 0bdaeea32c..55053d200e 100644 --- a/src/features/relay/native.test.ts +++ b/src/features/relay/native.test.ts @@ -2252,3 +2252,46 @@ it.each([ await expect(work).rejects.toBeInstanceOf(ArtifactIncrementalError); await expect(work).rejects.toMatchObject({ code }); }); + +it.each([ + ["sign-in", 401, "authentication required\n"], + ["access", 403, "authorization denied\n"], + ["access", 403, JSON.stringify({ error: "relay_membership_required" })], +])( + "a refused response reports its %s refusal to the community's observer without the window event", + async (kind, status, body) => { + // This test environment has no Tauri window, so the event listener never + // installs: only the response itself can report the refusal. + const transport = await connectNativeTransport(community); + const refusals: string[] = []; + const stop = transport.observeRefusal?.((refusal) => + refusals.push(refusal), + ); + const answer = vi.mocked(invoke).getMockImplementation(); + vi.mocked(invoke).mockImplementation(async (command, args, options) => + command === "relay_http" && (args as Request).path === "/query" + ? { status, headers: {}, body } + : answer?.(command, args, options), + ); + await expect( + transport.query([{ kinds: [9], "#h": ["channel"], limit: 10 }]), + ).rejects.toThrow(); + expect(refusals).toEqual([kind]); + stop?.(); + }, +); + +it("an ordinary 403 reports no community refusal", async () => { + const transport = await connectNativeTransport(community); + const refusals: string[] = []; + const stop = transport.observeRefusal?.((refusal) => refusals.push(refusal)); + respond = () => ({ + status: 403, + body: { error: "restricted: not a channel member" }, + }); + await expect( + transport.query([{ kinds: [9], "#h": ["channel"], limit: 10 }]), + ).rejects.toThrow(); + expect(refusals).toEqual([]); + stop?.(); +}); diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index 35ed60aba3..c8573dec60 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -154,7 +154,10 @@ export async function nativeRelayRequest( // The shared admission slot stays held until native work settles: at // once for a cancelled read, otherwise within its timeout. bounded.throwIfAborted(); - return nativeResponse(result); + const response = nativeResponse(result); + if (response.status === 401 || response.status === 403) + await announceRefusal(community, response); + return response; } finally { if (id) bounded.removeEventListener("abort", cancel); } @@ -385,6 +388,21 @@ export function relaySelf(info: Record): string | undefined { type RefusalListener = (kind: CommunityRefusal) => void; const refusalListeners = new Map>(); let refusalEvents: Promise | undefined; +function notifyRefusal(community: string, kind: CommunityRefusal) { + for (const listener of [...(refusalListeners.get(community) ?? [])]) + listener(kind); +} +/** The response itself also reports a refusal, so the community's owner hears + * it even when the window event never arrives (its listener failed to + * install). The owner takes the first report; a repeat changes nothing. */ +async function announceRefusal(community: string, response: Response) { + const { admission } = await readApiFailure(response.clone()); + if (admission) + notifyRefusal( + new URL(communityDestination(community).url).origin, + admission, + ); +} /** Native reports a community's refusal from whichever signed request met it * (`observe_refusal` in relay.rs), so no command needs its own check. One * window listener serves every connection; it is ready before any connects. */ @@ -394,13 +412,11 @@ async function nativeRefusals(origin: string) { ({ payload }) => { const kind = payload.refusal; if (kind !== "sign-in" && kind !== "access") return; - for (const listener of [ - ...(refusalListeners.get(payload.community) ?? []), - ]) - listener(kind); + notifyRefusal(payload.community, kind); }, ).catch(() => { - // Without it a refusal still fails its own request; the next connect retries. + // Without it the failed response still reports the refusal (see + // announceRefusal); the next connect retries the listener. refusalEvents = undefined; }); await refusalEvents; diff --git a/src/features/relay/revocation.test.ts b/src/features/relay/revocation.test.ts index 9c596fe72f..dcde9a7edf 100644 --- a/src/features/relay/revocation.test.ts +++ b/src/features/relay/revocation.test.ts @@ -958,6 +958,45 @@ it.each([ }, ); +it.each([ + ["an ended sign-in", "auth-required: authentication required", 401], + ["lost community access", "restricted: authorization denied", 403], +])( + "revisiting a channel refused for %s does not bring its rows or preview back", + async (_case, reason, status) => { + const disk = emptyDisk(); + const h = setup({ prepared: true, persistence: disk }); + h.emit([roster(relay, "a", [viewer.pubkey], 10)]); + await flush(); + h.session.channels.ensure("a"); + await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); + h.next().respond([ + message(alice, "a", "shown", 11), + bounds(relay, "a", "head", { has_more: false, next_cursor: null }), + ]); + await flush(); + for (let i = 0; i < 10 && h.pending.length; i++) { + for (const request of h.pending.splice(0)) request.respond([]); + await flush(); + } + expect(h.session.channels.window("a").rows).toHaveLength(1); + h.session.channels.refresh?.("a"); + await vi.waitFor(() => expect(h.pending.length).toBeGreaterThan(0)); + for (const request of h.pending.splice(0)) + request.fail(new ReadError("denied", reason, status)); + await flush(); + h.session.channels.ensure("b"); + h.session.channels.ensure("a"); + await flush(); + expect(h.session.channels.window("a").rows).toEqual([]); + expect( + h.session.channels.list().channels.find((channel) => channel.id === "a") + ?.preview, + ).toBeUndefined(); + expect(disk.remove).not.toHaveBeenCalled(); + }, +); + it("a first channel read refused by the community settles the window instead of loading forever", async () => { const disk = emptyDisk(); const h = setup({ prepared: true, persistence: disk }); diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts index 8cd032386d..6b4bbb9304 100644 --- a/src/features/relay/store.ts +++ b/src/features/relay/store.ts @@ -946,10 +946,13 @@ export function createChannelStore( // The community refused the account, not this channel. Its owner (the // relay service's deny) ends the session and decides what saved data // survives: it keeps it for an ended sign-in. Until then, stop showing - // this channel's rows, but keep its access and saved copy. + // this channel's rows and preview, but keep its access and saved copy. if (refusalOfError(error)) { + heads.delete(channelId); + tails.delete(channelId); const state = windows.get(channelId); if (state) closeWindow(state, error); + setList(list); return; } accessVersions.set(channelId, (accessVersions.get(channelId) ?? 0) + 1);