From 9cc82dc1fe62312e04249a6e47698becddb52e74 Mon Sep 17 00:00:00 2001 From: dtonon Date: Fri, 14 Aug 2026 18:10:35 +0100 Subject: [PATCH] Show an access gate when the relay refuses reads --- src/lib/access.svelte.ts | 109 +++++++++++++ src/lib/components/RelayGate.svelte | 106 ++++++++++++ src/lib/join.svelte.ts | 95 +++++++---- src/lib/relay.ts | 30 +++- src/routes/+layout.svelte | 242 +++++++++++++++------------- 5 files changed, 437 insertions(+), 145 deletions(-) create mode 100644 src/lib/access.svelte.ts create mode 100644 src/lib/components/RelayGate.svelte diff --git a/src/lib/access.svelte.ts b/src/lib/access.svelte.ts new file mode 100644 index 0000000..1710cd1 --- /dev/null +++ b/src/lib/access.svelte.ts @@ -0,0 +1,109 @@ +import type { AbstractRelay } from "@nostr/tools/abstract-relay"; +import { auth } from "$lib/auth.svelte"; +import { ensureForumRelay, forumRelayInfo } from "$lib/relay"; +import type { ForumRelayInfo } from "$lib/relay"; + +// Whether the forum relay will serve this visitor at all. A relay can refuse +// every read (auth-required for anonymous visitors, restricted for logged-in +// non-members, blocked for bans), in which case the app would otherwise just +// look empty; the layout shows a gate page instead until a probe comes back +// open. +export type RelayAccess = + | "checking" + | "open" + | "auth-required" + | "restricted" + | "blocked"; + +let state = $state("checking"); +let message = $state(null); +let info = $state(null); +// Bumped when a probe completes, so listeners can react to every result even +// when the state itself didn't change (open → open after a login). +let probes = $state(0); + +export const relayAccess = { + get state() { + return state; + }, + get message() { + return message; + }, + get info() { + return info; + }, + get probes() { + return probes; + }, +}; + +// Sends a throwaway REQ and reads the CLOSED reason: nostr-tools' query helpers +// discard it, and it is the only cross-relay signal for a read gate. No `#h` +// in the filter, so a private group's per-group auth-required (handled by the +// room itself) can't masquerade as a relay-wide one. +function probeRead(relay: AbstractRelay): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (reason: string | null) => { + if (settled) return; + settled = true; + try { + sub.close(); + } catch {} + resolve(reason); + }; + const sub = relay.subscribe([{ kinds: [39000], limit: 1 }], { + onevent() {}, + oneose() { + finish(null); + }, + onclose(reason) { + finish(reason); + }, + }); + setTimeout(() => finish(null), 4000); + }); +} + +function classify(reason: string | null): RelayAccess { + const r = (reason ?? "").toLowerCase(); + if (r.startsWith("auth-required:")) return "auth-required"; + if (r.startsWith("restricted:")) return "restricted"; + if (r.startsWith("blocked:")) return "blocked"; + return "open"; +} + +let inFlight: Promise | null = null; + +// Re-evaluates access for the current signer. Concurrent calls share one probe. +// An anonymous visitor on a relay whose NIP-11 declares auth_required is gated +// without waiting for the relay to refuse a request. +export function probeRelayAccess(): Promise { + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const relayInfo = await forumRelayInfo(); + info = relayInfo; + let next: RelayAccess; + let reason: string | null = null; + if (!auth.signer && relayInfo.authRequired) { + next = "auth-required"; + } else { + try { + reason = await probeRead(await ensureForumRelay()); + } catch { + reason = null; // connection failure: the pages surface their own errors + } + next = classify(reason); + } + state = next; + message = + next === "open" ? null : (reason?.replace(/^[a-z-]+:\s*/i, "") ?? null); + probes++; + return next; + } finally { + inFlight = null; + } + })(); + return inFlight; +} diff --git a/src/lib/components/RelayGate.svelte b/src/lib/components/RelayGate.svelte new file mode 100644 index 0000000..9fa2102 --- /dev/null +++ b/src/lib/components/RelayGate.svelte @@ -0,0 +1,106 @@ + + + + {title} + + +
+
+ +
+
+ {#if relayAccess.info?.icon} + + {/if} +

{title}

+ {#if description} +

+ {description} +

+ {/if} + +
+ {#if state === "checking"} +

+ Checking access… +

+ {:else if authFailed} +

+ The relay did not accept your login. +

+ + {:else if state === "auth-required"} +

+ You need to log in to access this forum. +

+ + {:else if state === "restricted"} +

+ This forum is for members only. +

+ + {:else if state === "blocked"} +

+ You can't access this forum. +

+ {#if relayAccess.message} +

+ {relayAccess.message} +

+ {/if} + {/if} +
+
+
diff --git a/src/lib/join.svelte.ts b/src/lib/join.svelte.ts index a63dca3..5a94c31 100644 --- a/src/lib/join.svelte.ts +++ b/src/lib/join.svelte.ts @@ -319,34 +319,52 @@ const fail = ( // Runs the two-level join: relay first (a group join needs relay membership), // then group. Everything free happens silently; the first refusal is reported // as the step needing user input. +// Relay-level join (NIP-43). `refused` is set when the relay declined for an +// unclear reason: a group refusal right after is then more likely the relay +// gate than the group's. +async function ensureRelayAccess( + pubkey: string, + codes: Codes, +): Promise<{ outcome: Outcome; refused: boolean }> { + await ensureRelayMembershipChecked(pubkey); + if (relayMembership[pubkey] !== "guest") { + return { outcome: { ok: true }, refused: false }; + } + const tags: string[][] = [["-"]]; + if (codes.relay) tags.push(["claim", codes.relay]); + try { + await publishSigned(28934, tags); + relayMembership[pubkey] = "member"; + } catch (e) { + const msg = reason(e); + if (prefixed(msg, "duplicate:")) { + relayMembership[pubkey] = "member"; + } else if (prefixed(msg, "restricted:")) { + // A bare request refused is the expected probe, not a user error + return { + outcome: fail( + "relay-code", + codes.relay ? stripPrefix(msg) : null, + true, + ), + refused: true, + }; + } else if (prefixed(msg, "blocked:")) { + return { outcome: fail("error", stripPrefix(msg)), refused: true }; + } else { + return { outcome: { ok: true }, refused: true }; + } + } + return { outcome: { ok: true }, refused: false }; +} + async function ensureAccess(groupId: string, codes: Codes): Promise { const pubkey = auth.user?.pubkey; if (!pubkey) return fail("error", "Not logged in"); - await ensureRelayMembershipChecked(pubkey); - // Set when the relay refused the join for an unclear reason: a group - // refusal right after is then more likely the relay gate than the group's. - let relayRefused = false; - if (relayMembership[pubkey] === "guest") { - const tags: string[][] = [["-"]]; - if (codes.relay) tags.push(["claim", codes.relay]); - try { - await publishSigned(28934, tags); - relayMembership[pubkey] = "member"; - } catch (e) { - const msg = reason(e); - if (prefixed(msg, "duplicate:")) { - relayMembership[pubkey] = "member"; - } else if (prefixed(msg, "restricted:")) { - // A bare request refused is the expected probe, not a user error - return fail("relay-code", codes.relay ? stripPrefix(msg) : null, true); - } else if (prefixed(msg, "blocked:")) { - return fail("error", stripPrefix(msg)); - } else { - relayRefused = true; - } - } - } + const relay = await ensureRelayAccess(pubkey, codes); + if (!relay.outcome.ok) return relay.outcome; + const relayRefused = relay.refused; await ensureMembershipChecked(groupId); const k = memberKey(groupId); @@ -383,7 +401,7 @@ async function ensureAccess(groupId: string, codes: Codes): Promise { } function openModal( - groupId: string, + groupId: string | null, refusal: Refusal, cb: (() => void | Promise) | null, ) { @@ -449,7 +467,7 @@ export function closeJoinModal() { // Re-runs the join with the code entered for the current step. Errors keep the // modal open for a retry; a further gate switches the modal to that step. export async function submitJoinCode(code?: string) { - if (!modalGroup || busy) return; + if (!modalOpen || busy) return; busy = true; modalError = null; const groupId = modalGroup; @@ -457,7 +475,12 @@ export async function submitJoinCode(code?: string) { const codes: Codes = modalStep === "relay-code" ? { relay: code } : { group: code }; try { - const outcome = await ensureAccess(groupId, codes); + const pubkey = auth.user?.pubkey; + const outcome = groupId + ? await ensureAccess(groupId, codes) + : pubkey + ? (await ensureRelayAccess(pubkey, codes)).outcome + : fail("error", "Not logged in"); if (!outcome.ok) { openModal(groupId, outcome, cb); return; @@ -472,3 +495,21 @@ export async function submitJoinCode(code?: string) { busy = false; } } + +// Relay-only join, for a relay that refuses reads from non-members: no group +// is involved, so the modal (if any) targets the relay alone. +export async function joinRelay(cb: () => void | Promise): Promise { + const pubkey = auth.user?.pubkey; + if (!pubkey || busy) return; + busy = true; + try { + const { outcome } = await ensureRelayAccess(pubkey, {}); + if (!outcome.ok) { + openModal(null, outcome, cb); + return; + } + await cb(); + } finally { + busy = false; + } +} diff --git a/src/lib/relay.ts b/src/lib/relay.ts index 7c88ca7..22a4663 100644 --- a/src/lib/relay.ts +++ b/src/lib/relay.ts @@ -108,11 +108,20 @@ export function resetForumConnection(): void { } // NIP-11 document of the forum relay, fetched once per session. Used for NIP-43 -// discovery: clients must only send relay join requests to relays advertising -// it, and the `self` pubkey signs the membership events we read back. -let relayInfo: Promise<{ self: string | null; nips: string[] }> | null = null; +// discovery (clients must only send relay join requests to relays advertising +// it; `self` signs the membership events we read back) and for the access gate, +// which can show the relay's name before anything else is readable. +export type ForumRelayInfo = { + self: string | null; + nips: string[]; + name: string; + description: string; + icon: string; + authRequired: boolean; +}; +let relayInfo: Promise | null = null; -export function forumRelayInfo() { +export function forumRelayInfo(): Promise { if (!relayInfo) { relayInfo = import("@nostr/tools/nip11") .then(({ fetchRelayInformation }) => fetchRelayInformation(RELAY_URL)) @@ -123,9 +132,20 @@ export function forumRelayInfo() { self: typeof self === "string" ? self : null, // Relays mix numbers and strings in this list nips: (info.supported_nips ?? []).map(String), + name: info.name ?? "", + description: info.description ?? "", + icon: info.icon ?? "", + authRequired: info.limitation?.auth_required === true, }; }) - .catch(() => ({ self: null, nips: [] })); + .catch(() => ({ + self: null, + nips: [], + name: "", + description: "", + icon: "", + authRequired: false, + })); } return relayInfo; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8270607..db96394 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,9 +12,10 @@ import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte"; import DeleteModal from "$lib/components/DeleteModal.svelte"; import Toast from "$lib/components/Toast.svelte"; + import RelayGate from "$lib/components/RelayGate.svelte"; import { page } from "$app/state"; import { afterNavigate } from "$app/navigation"; - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import { auth, restoreSession } from "$lib/auth.svelte"; import { loadGroup } from "$lib/group.svelte"; import { ensureMembershipChecked } from "$lib/join.svelte"; @@ -25,6 +26,7 @@ import { seedProfiles } from "$lib/profiles.svelte"; import { startChat } from "$lib/chat.svelte"; import { resetForumConnection } from "$lib/relay"; + import { relayAccess, probeRelayAccess } from "$lib/access.svelte"; import { activeGroup, setActiveGroup } from "$lib/active.svelte"; import { MODE, @@ -68,21 +70,36 @@ if (nav.type !== "popstate") mainEl?.scrollTo(0, 0); }); - onMount(async () => { + // Nothing is fetched until the relay confirms it will serve this visitor: + // the probe runs once the stored signer is in place (restoreSession sets it + // synchronously, before its profile fetch), and every probe that ends open + // (re)loads the forum below. + onMount(() => { + restoreSession(); + probeRelayAccess(); + }); + + async function loadForum() { loadResources(); loadPartials(); - const tasks = [restoreSession(), loadGroup()]; + const tasks = [loadGroup()]; if (mode === "full") tasks.push(loadGroups()); await Promise.all(tasks); // Resources are filtered by the admin set; in full mode that means every // room's admins, needed on every page for the sidebar. if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id)); seedProfiles(auth.user?.pubkey ?? null); + } + + // Each completed probe that finds the relay open reloads the room views: on + // first load, after a login (private/hidden rooms appear) or logout (they + // vanish), and after joining a members-only relay. + $effect(() => { + relayAccess.probes; + if (relayAccess.state === "open") untrack(loadForum); }); - // Reload the room views when the user logs in or out: a logged-in member sees - // their private/hidden rooms, so the listing must be re-fetched over the now - // (de)authenticated connection. sessionEpoch only bumps on explicit + // Re-probe when the user logs in or out. sessionEpoch only bumps on explicit // login/logout, never on the silent restore that onMount already covers. let lastEpoch = -1; $effect(() => { @@ -91,17 +108,12 @@ const first = lastEpoch === -1; const loggedOut = auth.user === null; lastEpoch = epoch; - if (first) return; // initial mount: onMount already loaded everything + if (first) return; // On logout, drop the authenticated connection so the relay stops serving // the previous user's private rooms; login reuses the open connection, // which ensureForumReady authenticates via its stored challenge. if (loggedOut) resetForumConnection(); - loadGroup(); - if (mode === "full") { - loadGroups().then(() => - loadRoomAdmins(groupsStore.list.map((g) => g.id)), - ); - } + probeRelayAccess(); }); // Full mode: the room route defines the active group. Thread pages set it @@ -167,118 +179,122 @@ -
- (menuOpen = true)} /> +{#if relayAccess.state !== "open"} + +{:else}
- -
(menuOpen = true)} /> +
- - -
- {@render children()} -
-
- {#if showChat} - - (chatExpanded = !chatExpanded)} - mobileActive={mobileView === "chat"} - /> - {:else if showDiscussions} - - + + {#if showChat} + + (chatExpanded = !chatExpanded)} + mobileActive={mobileView === "chat"} + /> + {:else if showDiscussions} + + + {/if} +
+ {#if showChat} + {/if}
- {#if showChat} - - {/if} - +{/if}