flotilla/src/app/policies.ts

278 lines
7.6 KiB
TypeScript
Raw Normal View History

import {get, writable} from "svelte/store"
import {on, call, dissoc, assoc, noop, uniq} from "@welshman/lib"
2026-07-28 16:16:26 +00:00
import {isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util"
2025-10-21 15:27:30 +00:00
import type {Socket, RelayMessage, ClientMessage} from "@welshman/net"
import {
AuthStateEvent,
AuthStatus,
2025-10-21 15:27:30 +00:00
SocketEvent,
isRelayEvent,
isRelayOk,
isRelayClosed,
2025-11-04 23:36:20 +00:00
isRelayNegErr,
2025-10-21 15:27:30 +00:00
isClientReq,
isClientEvent,
isClientClose,
2025-11-04 23:36:20 +00:00
isClientNegOpen,
isClientNegClose,
matchReason,
RelayReasonPrefix,
2025-10-21 15:27:30 +00:00
} from "@welshman/net"
import {BlockedRelayLists, MessagingRelayLists, RelayLists, RoomLists, Thunks} from "@welshman/app"
2026-07-28 16:16:26 +00:00
import type {AppPolicy, IApp} from "@welshman/app"
import {merged} from "@welshman/store"
import {logger, appPolicies} from "@app/core"
import {BLOCKED_RELAYS} from "@app/env"
import {userSettingsValues, getSetting, RelayAuthMode} from "@app/settings"
// Relays sending events with empty signatures that the user has to choose to trust
export const relaysPendingTrust = writable<string[]>([])
// Relays that mostly send restricted responses to requests and events
export const relaysMostlyRestricted = writable<Record<string, string>>({})
2025-10-21 15:27:30 +00:00
// Relays the user has explicitly trusted may send events with an empty signature.
2026-07-28 16:16:26 +00:00
export const ingestPolicy: AppPolicy = app =>
app.pool.subscribe(socket => {
const onReceive = (message: RelayMessage) => {
if (isRelayEvent(message)) {
const event = message[2]
const trusted = getSetting("trusted_relays").includes(socket.url)
2026-09-21 16:48:35 +00:00
if (isDVMKind(event.kind) || isEphemeralKind(event.kind)) {
return
}
if (!trusted && !verifyEvent(event)) {
return
}
2026-07-28 16:16:26 +00:00
app.tracker.track(event.id, socket.url)
app.repository.publish(event)
}
}
socket.on(SocketEvent.Receive, onReceive)
return () => socket.off(SocketEvent.Receive, onReceive)
})
// Welshman's appPolicyAuthUnlessBlocked, plus the conservative mode's relationship check.
const shouldAuth = (socket: Socket, $app: IApp) => {
const $pubkey = $app.user?.pubkey
2026-07-28 16:16:26 +00:00
2026-09-21 16:48:35 +00:00
if (!$pubkey) {
return false
}
if ($app.use(BlockedRelayLists).urls($pubkey).get().includes(socket.url)) {
return false
}
if (getSetting("relay_auth") === RelayAuthMode.Aggressive) {
return true
}
if ($app.use(RoomLists).urls($pubkey).get().includes(socket.url)) {
return true
}
if ($app.use(RelayLists).urls($pubkey).get().includes(socket.url)) {
return true
}
if (get($app.use(Thunks).history).some(t => t.options.relays.includes(socket.url))) {
return true
}
if ($app.use(MessagingRelayLists).urls($pubkey).get().includes(socket.url)) {
return true
}
2026-07-28 16:16:26 +00:00
return false
}
// Everything `shouldAuth` reads, so a socket can ask it again when the answer changes.
const makeAuthInputs = ($app: IApp, pubkey: string) =>
merged([
$app.use(BlockedRelayLists).urls(pubkey).$,
$app.use(MessagingRelayLists).urls(pubkey).$,
$app.use(RelayLists).urls(pubkey).$,
$app.use(RoomLists).urls(pubkey).$,
$app.use(Thunks).history,
userSettingsValues,
])
// A first login answers "no relationship" before the user's lists load, so ask again when one changes.
export const authPolicy: AppPolicy = $app => {
const $user = $app.user
if (!$user) {
return noop
}
const authInputs = makeAuthInputs($app, $user.pubkey)
const policy = (socket: Socket) => {
const attemptAuth = () => {
if (socket.auth.status === AuthStatus.Requested && shouldAuth(socket, $app)) {
socket.auth.doAuth($user.sign)
}
}
const unsubscribers = [
on(socket.auth, AuthStateEvent.Status, attemptAuth),
authInputs.subscribe(attemptAuth),
]
return () => unsubscribers.forEach(call)
}
$app.pool.socketPolicies.push(policy)
return () => {
$app.pool.socketPolicies = $app.pool.socketPolicies.filter(p => p !== policy)
}
}
2025-10-21 15:27:30 +00:00
2026-07-28 16:16:26 +00:00
const makeBlockPolicy = ($app: IApp) => (socket: Socket) => {
2026-01-16 21:10:48 +00:00
const previousOpen = socket.open
socket.open = () => {
2026-07-28 16:16:26 +00:00
const $pubkey = $app.user?.pubkey
2026-01-16 21:10:48 +00:00
2026-09-21 16:48:35 +00:00
if (BLOCKED_RELAYS.includes(socket.url)) {
return
}
if ($pubkey && $app.use(BlockedRelayLists).urls($pubkey).get().includes(socket.url)) {
return
}
2026-01-20 18:40:33 +00:00
previousOpen()
2026-01-16 21:10:48 +00:00
}
return () => {
socket.open = previousOpen
}
}
2026-07-28 16:16:26 +00:00
const trustPolicy = (socket: Socket) => {
2025-10-21 15:27:30 +00:00
const buffer: RelayMessage[] = []
const unsubscribers = [
// When the socket goes from untrusted to trusted, receive all buffered messages
userSettingsValues.subscribe($settings => {
if ($settings.trusted_relays.includes(socket.url)) {
for (const message of buffer.splice(0)) {
socket._recvQueue.push(message)
}
}
}),
// An unsigned event from an untrusted relay is dropped, and one of undefined trust is buffered.
2025-10-21 15:27:30 +00:00
on(socket, SocketEvent.Receiving, (message: RelayMessage) => {
if (isRelayEvent(message) && !message[2]?.sig) {
2026-09-02 15:53:19 +00:00
logger.get().log("trustPolicy", {url: socket.url, message})
2026-07-28 16:16:26 +00:00
const isTrusted = getSetting("trusted_relays").includes(socket.url)
2025-10-21 15:27:30 +00:00
if (!isTrusted) {
buffer.push(message)
socket._recvQueue.remove(message)
relaysPendingTrust.update($r => uniq([...$r, socket.url]))
}
}
}),
]
return () => {
unsubscribers.forEach(call)
}
}
2026-07-28 16:16:26 +00:00
const mostlyRestrictedPolicy = (socket: Socket) => {
2025-10-21 15:27:30 +00:00
let total = 0
2026-08-21 15:31:53 +00:00
let refused = 0
2025-10-21 15:27:30 +00:00
const pending = new Set<string>()
const updateStatus = (error?: string) => {
2026-08-21 15:31:53 +00:00
if (total > 5 && refused > total / 2) {
if (error) {
return relaysMostlyRestricted.update(assoc(socket.url, error))
}
} else {
relaysMostlyRestricted.update(dissoc(socket.url))
}
}
2025-10-21 15:27:30 +00:00
2026-08-21 15:31:53 +00:00
// NIP-01 reserves "blocked: " for a ban and "restricted: " for lacking permission.
const countDetails = (details: string) => {
if (matchReason(RelayReasonPrefix.AuthRequired, details)) {
2026-08-21 15:31:53 +00:00
total--
updateStatus()
}
if (
matchReason(RelayReasonPrefix.Restricted, details) ||
matchReason(RelayReasonPrefix.Blocked, details)
) {
2026-08-21 15:31:53 +00:00
refused++
updateStatus(details)
}
}
2025-10-21 15:27:30 +00:00
const unsubscribers = [
on(socket, SocketEvent.Receive, (message: RelayMessage) => {
if (isRelayOk(message)) {
const [_, id, ok, details = ""] = message
if (pending.has(id)) {
pending.delete(id)
2025-11-04 23:36:20 +00:00
if (!ok) {
2026-08-21 15:31:53 +00:00
countDetails(details)
2025-10-21 15:27:30 +00:00
}
}
}
2025-11-04 23:36:20 +00:00
if (isRelayClosed(message) || isRelayNegErr(message)) {
2025-10-21 15:27:30 +00:00
const [_, id, details = ""] = message
if (pending.has(id)) {
pending.delete(id)
2026-08-21 15:31:53 +00:00
countDetails(details)
2025-10-21 15:27:30 +00:00
}
}
}),
on(socket, SocketEvent.Send, (message: ClientMessage) => {
2025-11-04 23:36:20 +00:00
if (isClientReq(message) || isClientNegOpen(message)) {
if (!pending.has(message[1])) {
total++
pending.add(message[1])
updateStatus()
}
2025-10-21 15:27:30 +00:00
}
if (isClientEvent(message)) {
total++
pending.add(message[1].id)
updateStatus()
}
2025-11-04 23:36:20 +00:00
if (isClientClose(message) || isClientNegClose(message)) {
2025-10-21 15:27:30 +00:00
pending.delete(message[1])
}
}),
]
return () => {
unsubscribers.forEach(call)
}
}
2026-07-28 16:16:26 +00:00
// Socket policies install on the pool, so this wraps them for the app's construction and cleanup.
2026-07-28 16:16:26 +00:00
export const socketPolicy: AppPolicy = $app => {
const policies = [makeBlockPolicy($app), trustPolicy, mostlyRestrictedPolicy]
$app.pool.socketPolicies.push(...policies)
return () => {
$app.pool.socketPolicies = $app.pool.socketPolicies.filter(p => !policies.includes(p))
}
}
appPolicies.push(ingestPolicy, authPolicy, socketPolicy)