Show an access gate when the relay refuses reads

This commit is contained in:
dtonon 2026-08-14 18:10:35 +01:00
parent 6ecd618bce
commit 9cc82dc1fe
5 changed files with 437 additions and 145 deletions

109
src/lib/access.svelte.ts Normal file
View file

@ -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<RelayAccess>("checking");
let message = $state<string | null>(null);
let info = $state<ForumRelayInfo | null>(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<string | null> {
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<RelayAccess> | 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<RelayAccess> {
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;
}

View file

@ -0,0 +1,106 @@
<script lang="ts">
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
import { auth, openLogin } from "$lib/auth.svelte";
import { joinRelay, joinState } from "$lib/join.svelte";
import { TITLE } from "$lib/config";
// The relay's NIP-11 name is readable even when nothing else is, so the
// gate can still say which forum this is.
const title = $derived(TITLE || relayAccess.info?.name || "Forum");
const description = $derived(relayAccess.info?.description ?? "");
const state = $derived(relayAccess.state);
// A logged-in user still refused with auth-required means the handshake
// failed rather than that they must log in; offer a retry instead.
const authFailed = $derived(state === "auth-required" && !!auth.signer);
function onLogin() {
openLogin(() => {
probeRelayAccess();
});
}
function onJoin() {
joinRelay(() => {
probeRelayAccess();
});
}
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<div class="flex min-h-dvh flex-col">
<div class="flex justify-end p-4">
<ThemeToggle />
</div>
<main
class="flex flex-1 flex-col items-center justify-center px-6 pb-24 text-center"
>
{#if relayAccess.info?.icon}
<img
src={relayAccess.info.icon}
alt=""
class="mb-6 h-20 w-20 rounded-full object-cover"
/>
{/if}
<h1 class="text-accent text-3xl font-semibold">{title}</h1>
{#if description}
<p class="mt-2 max-w-md text-neutral-600 dark:text-neutral-400">
{description}
</p>
{/if}
<div class="mt-8 max-w-md" role="status" aria-live="polite">
{#if state === "checking"}
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Checking access…
</p>
{:else if authFailed}
<p class="text-neutral-700 dark:text-neutral-300">
The relay did not accept your login.
</p>
<button
type="button"
onclick={() => probeRelayAccess()}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
>
Retry
</button>
{:else if state === "auth-required"}
<p class="text-neutral-700 dark:text-neutral-300">
You need to log in to access this forum.
</p>
<button
type="button"
onclick={onLogin}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
>
Log in
</button>
{:else if state === "restricted"}
<p class="text-neutral-700 dark:text-neutral-300">
This forum is for members only.
</p>
<button
type="button"
onclick={onJoin}
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white disabled:opacity-50"
>
{joinState.busy ? "Joining…" : "Join this forum"}
</button>
{:else if state === "blocked"}
<p class="text-neutral-700 dark:text-neutral-300">
You can't access this forum.
</p>
{#if relayAccess.message}
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{relayAccess.message}
</p>
{/if}
{/if}
</div>
</main>
</div>

View file

@ -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<Outcome> {
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<Outcome> {
}
function openModal(
groupId: string,
groupId: string | null,
refusal: Refusal,
cb: (() => void | Promise<void>) | 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<void>): Promise<void> {
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;
}
}

View file

@ -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<ForumRelayInfo> | null = null;
export function forumRelayInfo() {
export function forumRelayInfo(): Promise<ForumRelayInfo> {
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;
}