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
43 changes: 43 additions & 0 deletions src/features/relay/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
});
28 changes: 22 additions & 6 deletions src/features/relay/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -385,6 +388,21 @@ export function relaySelf(info: Record<string, unknown>): string | undefined {
type RefusalListener = (kind: CommunityRefusal) => void;
const refusalListeners = new Map<string, Set<RefusalListener>>();
let refusalEvents: Promise<unknown> | 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. */
Expand All @@ -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;
Expand Down
155 changes: 155 additions & 0 deletions src/features/relay/revocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
},
);
55 changes: 38 additions & 17 deletions src/features/relay/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -920,33 +921,48 @@ 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);
transport?.revokeAccess(() => {
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,
Expand Down Expand Up @@ -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 ?? [])
Expand Down
Loading