Fix space auth prompts, invite failures, and admin check spam

Cache admin checks per space with a ttl, stop retrying auth after a denial,
surface invites we couldn't fetch a claim for, and trim access delays.
This commit is contained in:
Jon Staab 2026-07-27 15:09:13 -07:00
parent fe90a9f0ba
commit 883528242c
4 changed files with 67 additions and 62 deletions

View file

@ -42,7 +42,7 @@ export type InviteData = {
code?: string code?: string
} }
export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "ready" export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "noclaim" | "ready"
export type JoinRequestParams = { export type JoinRequestParams = {
url: string url: string
@ -55,9 +55,6 @@ export const isNetworkAuthError = (error: string) => {
return lower.includes("failed") || lower.includes("timeout") || lower.includes("network") return lower.includes("failed") || lower.includes("timeout") || lower.includes("network")
} }
export const isRequestNetworkError = (error: unknown) =>
error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")
export const parseInviteLink = (invite: string): InviteData | undefined => { export const parseInviteLink = (invite: string): InviteData | undefined => {
if (invite.length < 3 || !invite.includes(".")) { if (invite.length < 3 || !invite.includes(".")) {
return return
@ -108,12 +105,8 @@ export const shouldIgnoreError = (error: string) => {
return isIgnored || isAborted || isStrictNip29Relay return isIgnored || isAborted || isStrictNip29Relay
} }
export const deriveRelayAuthError = (url: string) => { export const deriveRelayAuthError = (url: string) =>
Pool.get().get(url).auth.attemptAuth(sign) derived([relaysMostlyRestricted, deriveSocket(url)], ([$relaysMostlyRestricted, $socket]) => {
return derived(
[relaysMostlyRestricted, deriveSocket(url)],
([$relaysMostlyRestricted, $socket]) => {
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) { if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
return stripPrefix($socket.auth.details) return stripPrefix($socket.auth.details)
} }
@ -121,9 +114,7 @@ export const deriveRelayAuthError = (url: string) => {
if ($relaysMostlyRestricted[url]) { if ($relaysMostlyRestricted[url]) {
return stripPrefix($relaysMostlyRestricted[url]) return stripPrefix($relaysMostlyRestricted[url])
} }
}, })
)
}
export const requestRelayClaim = async (url: string) => { export const requestRelayClaim = async (url: string) => {
const filters = [{kinds: [RELAY_INVITE], limit: 1}] const filters = [{kinds: [RELAY_INVITE], limit: 1}]
@ -229,12 +220,16 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
return `Failed to connect` return `Failed to connect`
} }
// Relays send their challenge right after the socket opens, so if none shows up
// shortly the relay doesn't use auth and we can go ahead and publish.
await poll({ await poll({
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(800),
condition: () => socket.auth.status === AuthStatus.Requested, condition: () => socket.auth.status === AuthStatus.Requested,
}) })
for (let i = 0; i < 3 && ![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status); i++) { // Stop at any terminal status — retrying a denied signature or a forbidden response
// just re-prompts the user's signer for an answer we already have.
for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) {
await socket.auth.retryAuth(sign) await socket.auth.retryAuth(sign)
await waitForAuth(socket) await waitForAuth(socket)
} }
@ -261,9 +256,9 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
export class Access { export class Access {
url: string url: string
authError: Readable<string | undefined> authError: Readable<string | undefined>
networkError = writable(false)
loading = writable(true) loading = writable(true)
claim = writable("") claim = writable("")
claimFailed = writable(false)
roomCode = writable<string | undefined>() roomCode = writable<string | undefined>()
roomInviteError = writable<string | undefined>() roomInviteError = writable<string | undefined>()
isExplicitAuthError: Readable<boolean> isExplicitAuthError: Readable<boolean>
@ -275,10 +270,8 @@ export class Access {
this.isExplicitAuthError = derived([this.authError], ([$authError]) => this.isExplicitAuthError = derived([this.authError], ([$authError]) =>
Boolean($authError && !isNetworkAuthError($authError)), Boolean($authError && !isNetworkAuthError($authError)),
) )
this.isGenericError = derived( this.isGenericError = derived([this.authError], ([$authError]) =>
[this.authError, this.networkError, this.isExplicitAuthError], Boolean($authError && isNetworkAuthError($authError)),
([$authError, $networkError, $isExplicitAuthError]) =>
$networkError || Boolean($authError && !$isExplicitAuthError),
) )
} }
@ -290,12 +283,14 @@ export class Access {
this.isExplicitAuthError, this.isExplicitAuthError,
this.roomInviteError, this.roomInviteError,
this.roomCode, this.roomCode,
this.claimFailed,
], ],
([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode]) => { ([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode, $claimFailed]) => {
if ($loading) return "loading" as const if ($loading) return "loading" as const
if ($isGeneric) return "network" as const if ($isGeneric) return "network" as const
if ($isExplicit || $roomInviteError) return "auth" as const if ($isExplicit || $roomInviteError) return "auth" as const
if (requireCode && !$roomCode) return "failed" as const if (requireCode && !$roomCode) return "failed" as const
if ($claimFailed) return "noclaim" as const
return "ready" as const return "ready" as const
}, },
@ -383,10 +378,10 @@ export class Access {
async prepareInvite(h?: string) { async prepareInvite(h?: string) {
this.loading.set(true) this.loading.set(true)
this.networkError.set(false)
try { // A request that times out or hits a closed socket resolves with no events, so an
const [[event], roomInviteResult] = await Promise.all([ // empty result is the only signal we get that the relay never answered.
const [events, roomInviteResult] = await Promise.all([
request({ request({
relays: [this.url], relays: [this.url],
autoClose: true, autoClose: true,
@ -394,24 +389,18 @@ export class Access {
filters: [{kinds: [RELAY_INVITE]}], filters: [{kinds: [RELAY_INVITE]}],
}), }),
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}),
sleep(2000), // Keep the spinner up long enough that a fast relay doesn't make it flash
sleep(300),
]) ])
this.claim.set(getTagValue("claim", event?.tags || []) || "") this.claim.set(getTagValue("claim", events[0]?.tags || []) || "")
this.claimFailed.set(events.length === 0)
if (h) { if (h) {
this.roomCode.set(roomInviteResult.code) this.roomCode.set(roomInviteResult.code)
this.roomInviteError.set(roomInviteResult.error) this.roomInviteError.set(roomInviteResult.error)
} }
} catch (error) {
this.claim.set("")
this.roomCode.set(undefined)
if (isRequestNetworkError(error)) {
this.networkError.set(true)
}
} finally {
this.loading.set(false) this.loading.set(false)
} }
} }
}

View file

@ -118,7 +118,10 @@
<p> <p>
This invite link includes access to the space and room. Anyone with the link can This invite link includes access to the space and room. Anyone with the link can
join by opening it in {PLATFORM_NAME}. join by opening it in {PLATFORM_NAME}.
{#if !$claim} {#if $inviteStatus === "noclaim"}
We weren't able to get an invite code from this space, so additional steps might
be required.
{:else if !$claim}
This space did not issue a claim for this link, so additional steps might be This space did not issue a claim for this link, so additional steps might be
required. required.
{/if} {/if}

View file

@ -136,7 +136,10 @@
{#snippet info()} {#snippet info()}
<p> <p>
This invite link can be used by clicking "Add Space" and pasting it there. This invite link can be used by clicking "Add Space" and pasting it there.
{#if !$claim} {#if $inviteStatus === "noclaim"}
We weren't able to get an invite code from this space, so additional steps might
be required.
{:else if !$claim}
This space did not issue a claim for this link, so additional steps might be This space did not issue a claim for this link, so additional steps might be
required. required.
{/if} {/if}

View file

@ -20,7 +20,7 @@ import {
sortEventsAsc, sortEventsAsc,
} from "@welshman/util" } from "@welshman/util"
import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util" import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util"
import {first, sortBy, spec, uniq} from "@welshman/lib" import {first, simpleCache, sortBy, spec, uniq} from "@welshman/lib"
import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app" import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {get} from "svelte/store" import {get} from "svelte/store"
@ -266,14 +266,24 @@ export enum MembershipStatus {
Granted, Granted,
} }
export const deriveUserIsSpaceAdmin = (url?: string) => // This costs a signature and an http round trip, so share one store per url and only
readable(false, set => { // re-check when the answer has had time to go stale (or when a check failed).
if (url) { export const deriveUserIsSpaceAdmin = simpleCache(([url]: [string | undefined]) => {
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res => let checkedAt = 0
set(Boolean(res.result?.length)),
) return readable(false, set => {
if (url && checkedAt < Date.now() - 300_000) {
checkedAt = Date.now()
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []})
.then(({result}) => set(Boolean(result?.length)))
.catch(error => {
checkedAt = 0
console.error(error)
})
} }
}) })
})
export const deriveUserSpaceMembershipStatus = (url: string) => { export const deriveUserSpaceMembershipStatus = (url: string) => {
// Fetch member list and user add/remove events directly in this derivation. // Fetch member list and user add/remove events directly in this derivation.