flotilla/src/app/access.ts

420 lines
11 KiB
TypeScript
Raw Normal View History

2026-07-28 16:16:26 +00:00
import {derived, writable, type Readable} from "svelte/store"
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib"
2026-07-28 16:16:26 +00:00
import {AuthStatus, SocketStatus} from "@welshman/net"
import type {Socket} from "@welshman/net"
import {
displayRelayUrl,
isRelayUrl,
makeEvent,
normalizeRelayUrl,
2026-09-09 02:24:05 +00:00
relay,
2026-07-28 16:16:26 +00:00
FOLLOWS,
MESSAGING_RELAYS,
PROFILE,
RELAYS,
2026-08-18 19:55:08 +00:00
type ManagementResponse,
} from "@welshman/util"
2026-08-21 15:31:53 +00:00
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
2026-07-28 16:16:26 +00:00
import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util"
2026-08-18 19:55:08 +00:00
import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core"
import {PLATFORM_URL} from "@app/env"
import {relaysMostlyRestricted} from "@app/policies"
import {Push} from "@app/push"
import {deriveSocket} from "@app/relays"
import {notificationSettings, removeTrustedRelay, setSpaceNotifications} from "@app/settings"
2026-07-28 16:16:26 +00:00
import {syncApplicationData} from "@app/sync"
export const ROOM_CREATE_INVITE = 9009
export type InviteData = {
url: string
claim: string
h?: string
code?: string
}
export const isNetworkAuthError = (error: string) => {
const lower = error.toLowerCase()
return lower.includes("failed") || lower.includes("timeout") || lower.includes("network")
}
export const parseInviteLink = (invite: string): InviteData | undefined => {
if (invite.length < 3 || !invite.includes(".")) {
return
}
return (
tryCatch(() => {
const params = fromPairs(Array.from(new URL(invite).searchParams))
const {r: relay = "", c: claim = "", h = "", code = ""} = params
const url = normalizeRelayUrl(relay)
if (isRelayUrl(url)) {
return {url, claim, h: h || undefined, code: code || undefined}
}
}) ||
tryCatch(() => {
const url = normalizeRelayUrl(invite)
if (isRelayUrl(url)) {
return {url, claim: ""}
}
})
)
}
export const makeInviteLink = (data: InviteData) => {
const params = new URLSearchParams({
r: displayRelayUrl(data.url),
c: data.claim,
})
if (data.h) {
params.set("h", data.h)
}
if (data.code) {
params.set("code", data.code)
}
return PLATFORM_URL + "/join?" + params.toString()
}
export const shouldIgnoreError = (error: string) => {
const isIgnored = error.startsWith("mute: ")
const isAborted = error.includes("Signing was aborted")
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
return isIgnored || isAborted || isStrictNip29Relay
}
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)
}
if ($relaysMostlyRestricted[url]) {
return stripPrefix($relaysMostlyRestricted[url])
}
})
2026-08-21 15:31:53 +00:00
export const publishJoinRequest = (url: string, claim?: string) => {
2026-09-09 02:24:05 +00:00
const eventWriter = writer(RelayJoin).forceRoutes(relay(url))
2026-08-21 15:31:53 +00:00
if (claim) {
eventWriter.setClaim(claim)
}
return command(eventWriter).then(publish)
}
2026-07-28 16:16:26 +00:00
export const publishLeaveRequest = (url: string) =>
2026-09-09 02:24:05 +00:00
command(writer(RelayLeave).forceRoutes(relay(url))).then(publish)
2026-08-21 15:31:53 +00:00
// A relay answers a re-sent request with "duplicate:" and a membership it already has with
// "already a member" — both leave us where we wanted to be, so only anything else is a refusal.
const isMembershipRefusal = (error: string) =>
Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already")
// Joining a room takes two publishes: a NIP-29 request the relay can refuse, and the user's
// own room list, which is what puts the room in their sidebar. `code` is a room invite code.
export const joinRoom = async (url: string, h: string, code?: string) => {
2026-07-28 16:16:26 +00:00
const eventWriter = writer(RoomJoin).setRoom(url, h)
if (code) {
eventWriter.setClaim(code)
}
2026-08-21 15:31:53 +00:00
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (isMembershipRefusal(error)) {
return error
}
await roomLists.get().addRoom(h, url).then(publish)
}
export const leaveRoom = async (url: string, h: string) => {
const thunk = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
const error = await thunk.waitForError()
if (isMembershipRefusal(error)) {
return error
}
await roomLists.get().removeRoom(h, url).then(publish)
2026-07-28 16:16:26 +00:00
}
export const leaveSpace = async (url: string) => {
await roomLists.get().removeRelay(url).then(publish)
await publishLeaveRequest(url)
await removeTrustedRelay(url)
}
export const publishRoomInvite = async (url: string, h: string) => {
const code = randomId()
2026-07-28 16:16:26 +00:00
const event = makeEvent(ROOM_CREATE_INVITE, {
tags: [
["h", h],
["code", code],
],
})
const error = await thunks
.get()
.publish({event, relays: [url]})
.waitForError()
if (error) {
return {error, code: undefined}
}
return {error: undefined, code}
}
const authTerminalStatuses = [
AuthStatus.None,
AuthStatus.Ok,
AuthStatus.Forbidden,
AuthStatus.DeniedSignature,
]
2026-07-28 16:16:26 +00:00
const waitForAuth = async (socket: Socket) => {
await poll({
signal: AbortSignal.timeout(10_000),
condition: () => authTerminalStatuses.includes(socket.auth.status),
}).catch(() => {})
}
const formatAuthError = (status: AuthStatus, details?: string) => {
if (status === AuthStatus.DeniedSignature) {
return "Failed to authenticate — check your signer"
}
if (status === AuthStatus.PendingSignature) {
return "Failed to authenticate — approve the signing request from your extension or signer"
}
const message = details || last(status.split(":"))
return `Failed to authenticate (${message})`
}
export const attemptRelayAccess = async (url: string, claim = "") => {
2026-07-28 16:16:26 +00:00
const socket = app.get().pool.get(url)
socket.attemptToOpen()
await poll({
signal: AbortSignal.timeout(3000),
condition: () => socket.status === SocketStatus.Open,
})
if (socket.status !== SocketStatus.Open) {
return `Failed to connect`
}
await poll({
2026-07-27 23:20:27 +00:00
signal: AbortSignal.timeout(3000),
condition: () => socket.auth.status === AuthStatus.Requested,
})
for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) {
2026-07-28 16:16:26 +00:00
await socket.auth.retryAuth(User.require(app.get()).sign)
await waitForAuth(socket)
}
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
return formatAuthError(socket.auth.status, socket.auth.details)
}
2026-07-28 16:16:26 +00:00
const thunk = await publishJoinRequest(url, claim)
const error = await thunk.waitForError()
if (shouldIgnoreError(error)) return
2026-08-21 15:31:53 +00:00
if (error.includes("invite code")) {
return "join request rejected"
}
// A space that isn't open to the public refuses a join carrying no claim at all
if (error.includes("claim")) {
return "This space requires an invite code"
}
return stripPrefix(error)
}
export class Access {
url: string
authError: Readable<string | undefined>
loading = writable(true)
claim = writable("")
claimFailed = writable(false)
roomCode = writable<string | undefined>()
roomInviteError = writable<string | undefined>()
isExplicitAuthError: Readable<boolean>
isGenericError: Readable<boolean>
constructor(url: string) {
this.url = url
this.authError = deriveRelayAuthError(url)
this.isExplicitAuthError = derived([this.authError], ([$authError]) =>
Boolean($authError && !isNetworkAuthError($authError)),
)
this.isGenericError = derived([this.authError], ([$authError]) =>
Boolean($authError && isNetworkAuthError($authError)),
)
}
createInviteStatus(requireCode: boolean) {
return derived(
[
this.loading,
this.isGenericError,
this.isExplicitAuthError,
this.roomInviteError,
this.roomCode,
this.claimFailed,
],
([$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
},
)
}
clearRestricted() {
relaysMostlyRestricted.update(dissoc(this.url))
}
async attempt(claim = "") {
return attemptRelayAccess(this.url, claim)
}
async configureNotifications(enabled: boolean) {
if (enabled) {
if (!notificationSettings.get().push) {
await setSpaceNotifications(this.url, true)
} else {
const permissions = await Push.request()
if (permissions.startsWith("granted")) {
await setSpaceNotifications(this.url, true)
}
}
} else {
await setSpaceNotifications(this.url, false)
}
}
async completeJoin(notifications: boolean) {
await this.configureNotifications(notifications)
2026-07-28 16:16:26 +00:00
await roomLists.get().addRelay(this.url).then(publish)
this.clearRestricted()
2026-07-27 23:20:27 +00:00
syncApplicationData()
2026-07-28 16:16:26 +00:00
app
.get()
.use(Sync)
.push({
relays: [this.url],
filters: [
{
kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE],
authors: [User.require(app.get()).pubkey],
},
],
})
}
async joinSpace({
claim = "",
notifications,
alreadyJoined = false,
}: {
claim?: string
notifications: boolean
alreadyJoined?: boolean
}) {
if (!alreadyJoined) {
const error = await this.attempt(claim)
if (error) {
return error
}
await this.completeJoin(notifications)
}
}
async acceptInvite(data: InviteData, notifications: boolean) {
2026-07-28 16:16:26 +00:00
const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
const error = await this.joinSpace({
claim: data.claim,
notifications,
2026-07-28 16:16:26 +00:00
alreadyJoined: spaceUrls.includes(data.url),
})
if (error) {
return error
}
if (data.h && data.code) {
2026-08-21 15:31:53 +00:00
return joinRoom(this.url, data.h, data.code)
}
}
async prepareInvite(h?: string) {
this.loading.set(true)
2026-08-18 19:55:08 +00:00
try {
const management = relayManagement.get().forUrl(this.url)
const [{result: methods}, roomInviteResult] = await Promise.all([
management.supportedMethods().catch((error): ManagementResponse => {
console.error(error)
return {}
}),
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),
])
// A relay that reports methods relay-wide rather than per-user can still come back
// "blocked" for this particular user — treat that as having no claim.
2026-08-18 19:55:08 +00:00
if (methods?.includes("createclaim")) {
const {result: claims} = await management.listClaims()
if (claims?.[0]) {
this.claim.set(claims[0])
} else {
const claim = randomId()
const {error} = await management.createClaim(claim)
if (error) {
this.claimFailed.set(true)
} else {
this.claim.set(claim)
}
}
}
2026-08-18 19:55:08 +00:00
if (h) {
this.roomCode.set(roomInviteResult.code)
this.roomInviteError.set(roomInviteResult.error)
}
} finally {
this.loading.set(false)
}
}
}