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:
parent
fe90a9f0ba
commit
883528242c
4 changed files with 67 additions and 62 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue