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 d32bad05cf..dcde9a7edf 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,157 @@ 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 hides the channel's rows but leaves saved data to the community owner", + async (_case, reason, status) => { + const disk = emptyDisk(); + const h = setup({ persistence: disk }); + h.emit([roster(relay, "a", [viewer.pubkey], 10)]); + 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.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 }); + 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 }); + 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 channel 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 channel 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..6b4bbb9304 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, @@ -920,10 +921,40 @@ 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 decides what saved data + // survives: it keeps it for an ended sign-in. Until then, stop showing + // 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); discovery?.deny(channelId); allowed?.delete(channelId); @@ -931,22 +962,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, @@ -2083,7 +2099,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 ?? [])