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
}
export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "ready"
export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "noclaim" | "ready"
export type JoinRequestParams = {
url: string
@ -55,9 +55,6 @@ export const isNetworkAuthError = (error: string) => {
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 => {
if (invite.length < 3 || !invite.includes(".")) {
return
@ -108,22 +105,16 @@ export const shouldIgnoreError = (error: string) => {
return isIgnored || isAborted || isStrictNip29Relay
}
export const deriveRelayAuthError = (url: string) => {
Pool.get().get(url).auth.attemptAuth(sign)
export const deriveRelayAuthError = (url: string) =>
derived([relaysMostlyRestricted, deriveSocket(url)], ([$relaysMostlyRestricted, $socket]) => {
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
return stripPrefix($socket.auth.details)
}
return derived(
[relaysMostlyRestricted, deriveSocket(url)],
([$relaysMostlyRestricted, $socket]) => {
if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) {
return stripPrefix($socket.auth.details)
}
if ($relaysMostlyRestricted[url]) {
return stripPrefix($relaysMostlyRestricted[url])
}
},
)
}
if ($relaysMostlyRestricted[url]) {
return stripPrefix($relaysMostlyRestricted[url])
}
})
export const requestRelayClaim = async (url: string) => {
const filters = [{kinds: [RELAY_INVITE], limit: 1}]
@ -229,12 +220,16 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
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({
signal: AbortSignal.timeout(3000),
signal: AbortSignal.timeout(800),
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 waitForAuth(socket)
}
@ -261,9 +256,9 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
export class Access {
url: string
authError: Readable<string | undefined>
networkError = writable(false)
loading = writable(true)
claim = writable("")
claimFailed = writable(false)
roomCode = writable<string | undefined>()
roomInviteError = writable<string | undefined>()
isExplicitAuthError: Readable<boolean>
@ -275,10 +270,8 @@ export class Access {
this.isExplicitAuthError = derived([this.authError], ([$authError]) =>
Boolean($authError && !isNetworkAuthError($authError)),
)
this.isGenericError = derived(
[this.authError, this.networkError, this.isExplicitAuthError],
([$authError, $networkError, $isExplicitAuthError]) =>
$networkError || Boolean($authError && !$isExplicitAuthError),
this.isGenericError = derived([this.authError], ([$authError]) =>
Boolean($authError && isNetworkAuthError($authError)),
)
}
@ -290,12 +283,14 @@ export class Access {
this.isExplicitAuthError,
this.roomInviteError,
this.roomCode,
this.claimFailed,
],
([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode]) => {
([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode, $claimFailed]) => {
if ($loading) return "loading" as const
if ($isGeneric) return "network" as const
if ($isExplicit || $roomInviteError) return "auth" as const
if (requireCode && !$roomCode) return "failed" as const
if ($claimFailed) return "noclaim" as const
return "ready" as const
},
@ -383,35 +378,29 @@ export class Access {
async prepareInvite(h?: string) {
this.loading.set(true)
this.networkError.set(false)
try {
const [[event], roomInviteResult] = await Promise.all([
request({
relays: [this.url],
autoClose: true,
signal: AbortSignal.timeout(10000),
filters: [{kinds: [RELAY_INVITE]}],
}),
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}),
sleep(2000),
])
// A request that times out or hits a closed socket resolves with no events, so an
// empty result is the only signal we get that the relay never answered.
const [events, roomInviteResult] = await Promise.all([
request({
relays: [this.url],
autoClose: true,
signal: AbortSignal.timeout(10000),
filters: [{kinds: [RELAY_INVITE]}],
}),
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}),
// 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) {
this.roomCode.set(roomInviteResult.code)
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)
if (h) {
this.roomCode.set(roomInviteResult.code)
this.roomInviteError.set(roomInviteResult.error)
}
this.loading.set(false)
}
}

View file

@ -118,7 +118,10 @@
<p>
This invite link includes access to the space and room. Anyone with the link can
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
required.
{/if}

View file

@ -136,7 +136,10 @@
{#snippet info()}
<p>
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
required.
{/if}

View file

@ -20,7 +20,7 @@ import {
sortEventsAsc,
} 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 {load} from "@welshman/net"
import {get} from "svelte/store"
@ -266,14 +266,24 @@ export enum MembershipStatus {
Granted,
}
export const deriveUserIsSpaceAdmin = (url?: string) =>
readable(false, set => {
if (url) {
manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res =>
set(Boolean(res.result?.length)),
)
// This costs a signature and an http round trip, so share one store per url and only
// re-check when the answer has had time to go stale (or when a check failed).
export const deriveUserIsSpaceAdmin = simpleCache(([url]: [string | undefined]) => {
let checkedAt = 0
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) => {
// Fetch member list and user add/remove events directly in this derivation.