rework client auth

This commit is contained in:
Jon Staab
2026-06-16 13:06:29 -07:00
parent 87d8a0832d
commit 2e12010e26
5 changed files with 58 additions and 68 deletions
+8 -22
View File
@@ -1,7 +1,7 @@
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {call} from "@welshman/lib" import {call} from "@welshman/lib"
import {Pool, Socket, Tracker, Repository, WrapManager, defaultSocketPolicies} from "@welshman/net" import {Pool, Tracker, Repository, WrapManager} from "@welshman/net"
import type {NetContext, AdapterFactory, SocketPolicy} from "@welshman/net" import type {NetContext, AdapterFactory} from "@welshman/net"
import type {User} from "./user.js" import type {User} from "./user.js"
import type {ClientPolicy} from "./policies.js" import type {ClientPolicy} from "./policies.js"
@@ -16,7 +16,6 @@ export type ClientOptions = {
user?: User user?: User
config?: ClientConfig config?: ClientConfig
getAdapter?: AdapterFactory getAdapter?: AdapterFactory
socketPolicies?: SocketPolicy[]
policies?: ClientPolicy[] policies?: ClientPolicy[]
} }
@@ -46,26 +45,13 @@ export class Client implements IClient {
repository: Repository repository: Repository
wrapManager: WrapManager wrapManager: WrapManager
// Per-client singletons of data modules, keyed by constructor. Owned by the
// client (so it's GC'd with the client — no WeakMap needed), this is what
// `use` memoizes against.
private singletons = new Map<Function, unknown>() private singletons = new Map<Function, unknown>()
private policyCleanups: Unsubscriber[] = [] private unsubscribers: Unsubscriber[] = []
constructor(options: ClientOptions = {}) { constructor(options: ClientOptions = {}) {
this.user = options.user this.user = options.user
this.config = options.config ?? {} this.config = options.config ?? {}
this.pool = new Pool({ this.pool = new Pool()
makeSocket: (url: string) => {
let socketPolicies = options.socketPolicies ?? defaultSocketPolicies
if (this.user) {
socketPolicies = [...socketPolicies, this.user.makeSocketPolicyAuth()]
}
return new Socket(url, socketPolicies)
},
})
this.tracker = new Tracker() this.tracker = new Tracker()
this.repository = new Repository() this.repository = new Repository()
this.wrapManager = new WrapManager({ this.wrapManager = new WrapManager({
@@ -78,9 +64,9 @@ export class Client implements IClient {
getAdapter: options.getAdapter, getAdapter: options.getAdapter,
} }
// Apply policies last, once the primitives and `use` registry exist. They for (const policy of options.policies ?? []) {
// own all side effects; their cleanups run on `cleanup()`. this.unsubscribers.push(policy(this))
this.policyCleanups = (options.policies ?? []).map(policy => policy(this)) }
} }
// Resolve the per-client singleton of a data module, constructing it on first // Resolve the per-client singleton of a data module, constructing it on first
@@ -97,7 +83,7 @@ export class Client implements IClient {
} }
cleanup() { cleanup() {
this.policyCleanups.forEach(call) this.unsubscribers.forEach(call)
this.pool.clear() this.pool.clear()
this.tracker.clear() this.tracker.clear()
this.repository.clear() this.repository.clear()
+38 -3
View File
@@ -1,9 +1,9 @@
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {on} from "@welshman/lib" import {on, noop, always} from "@welshman/lib"
import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util" import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {SocketEvent, isRelayEvent} from "@welshman/net" import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net"
import type {RelayMessage} from "@welshman/net" import type {RelayMessage, Socket} from "@welshman/net"
import type {IClient} from "./client.js" import type {IClient} from "./client.js"
import {RelayStats} from "./relayStats.js" import {RelayStats} from "./relayStats.js"
import {GiftWraps} from "./giftWraps.js" import {GiftWraps} from "./giftWraps.js"
@@ -17,6 +17,41 @@ import {GiftWraps} from "./giftWraps.js"
*/ */
export type ClientPolicy = (client: IClient) => Unsubscriber export type ClientPolicy = (client: IClient) => Unsubscriber
/**
* Builds a client policy that authenticates the client's sockets (NIP-42) with
* the user's signer. It appends an auth socket policy to the pool's
* `socketPolicies`, so every socket the pool creates answers AUTH challenges
* according to `shouldAuth`; the policy is spliced back out on cleanup. No-op
* when the client has no user.
*
* Use the `clientPolicyAuthAlways` / `clientPolicyAuthNever` presets below, or
* call this with a custom predicate.
*/
export const makeClientPolicyAuth =
(shouldAuth: (socket: Socket) => boolean): ClientPolicy =>
client => {
if (!client.user) {
return noop
}
const {sign} = client.user.signer
const policy = makeSocketPolicyAuth({sign, shouldAuth})
client.pool.socketPolicies.push(policy)
return () => {
const index = client.pool.socketPolicies.indexOf(policy)
if (index !== -1) {
client.pool.socketPolicies.splice(index, 1)
}
}
}
export const clientPolicyAuthNever = makeClientPolicyAuth(always(false))
export const clientPolicyAuthAlways = makeClientPolicyAuth(always(true))
/** /**
* Ingests every event received on any socket into the client's repository. The * Ingests every event received on any socket into the client's repository. The
* net layer doesn't do this for us, and it's how all the repository-backed * net layer doesn't do this for us, and it's how all the repository-backed
+8 -13
View File
@@ -13,7 +13,6 @@ import {
} from "@welshman/signer" } from "@welshman/signer"
import type {ISigner} from "@welshman/signer" import type {ISigner} from "@welshman/signer"
import {User} from "./user.js" import {User} from "./user.js"
import type {UserOptions} from "./user.js"
/** /**
* Session descriptors and the signer construction that turns them into a * Session descriptors and the signer construction that turns them into a
@@ -214,36 +213,32 @@ export const getSigner = (session?: Session): ISigner | undefined => {
* for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the * for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the
* result to `new Client({user})` / `createApp({user})`. * result to `new Client({user})` / `createApp({user})`.
*/ */
export const userFromSession = (session: Session, options: UserOptions = {}): User | undefined => { export const userFromSession = (session: Session): User | undefined => {
const signer = getSigner(session) const signer = getSigner(session)
return signer && typeof session.pubkey === "string" return signer && typeof session.pubkey === "string"
? new User(session.pubkey, signer, options) ? new User(session.pubkey, signer)
: undefined : undefined
} }
// Login helpers — each returns a User to build a client/app with // Login helpers — each returns a User to build a client/app with
export const loginWithNip01 = (secret: string, options?: UserOptions) => export const loginWithNip01 = (secret: string) => userFromSession(makeNip01Session(secret))
userFromSession(makeNip01Session(secret), options)
export const loginWithNip07 = (pubkey: string, options?: UserOptions) => export const loginWithNip07 = (pubkey: string) => userFromSession(makeNip07Session(pubkey))
userFromSession(makeNip07Session(pubkey), options)
export const loginWithNip46 = ( export const loginWithNip46 = (
pubkey: string, pubkey: string,
clientSecret: string, clientSecret: string,
signerPubkey: string, signerPubkey: string,
relays: string[], relays: string[],
options?: UserOptions, ) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays), options)
export const loginWithNip55 = (pubkey: string, signer: string, options?: UserOptions) => export const loginWithNip55 = (pubkey: string, signer: string) =>
userFromSession(makeNip55Session(pubkey, signer), options) userFromSession(makeNip55Session(pubkey, signer))
export const loginWithPomade = ( export const loginWithPomade = (
pubkey: string, pubkey: string,
email: string, email: string,
clientOptions: PomadeClientOptions, clientOptions: PomadeClientOptions,
options?: UserOptions, ) => userFromSession(makePomadeSession(pubkey, email, clientOptions))
) => userFromSession(makePomadeSession(pubkey, email, clientOptions), options)
+2 -15
View File
@@ -1,12 +1,6 @@
import {makeSocketPolicyAuth} from "@welshman/net"
import type {Socket} from "@welshman/net"
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer" import type {ISigner} from "@welshman/signer"
export type UserOptions = {
shouldAuth?: (socket: Socket) => boolean
}
/** /**
* A single identity: a pubkey plus the signer that proves it. A `Client` is * A single identity: a pubkey plus the signer that proves it. A `Client` is
* centered on (at most) one `User`, since the data a user can access depends * centered on (at most) one `User`, since the data a user can access depends
@@ -16,21 +10,14 @@ export class User {
constructor( constructor(
readonly pubkey: string, readonly pubkey: string,
readonly signer: ISigner, readonly signer: ISigner,
readonly options: UserOptions = {},
) {} ) {}
static async fromSigner(signer: ISigner, options: UserOptions = {}) { static async fromSigner(signer: ISigner) {
const pubkey = await signer.getPubkey() const pubkey = await signer.getPubkey()
return new User(pubkey, signer, options) return new User(pubkey, signer)
} }
makeSocketPolicyAuth = () =>
makeSocketPolicyAuth({
sign: this.signer.sign,
shouldAuth: this.options.shouldAuth,
})
sign = (event: StampedEvent) => this.signer.sign(event) sign = (event: StampedEvent) => this.signer.sign(event)
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload) nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
+2 -15
View File
@@ -5,28 +5,15 @@ import {defaultSocketPolicies} from "./policy.js"
export type PoolSubscription = (socket: Socket) => void export type PoolSubscription = (socket: Socket) => void
export type PoolOptions = {
makeSocket?: (url: string) => Socket
}
export class Pool { export class Pool {
socketPolicies = [...defaultSocketPolicies]
_data = new Map<string, Socket>() _data = new Map<string, Socket>()
_subs: PoolSubscription[] = [] _subs: PoolSubscription[] = []
constructor(readonly options: PoolOptions = {}) {}
has(url: string) { has(url: string) {
return this._data.has(normalizeRelayUrl(url)) return this._data.has(normalizeRelayUrl(url))
} }
makeSocket(url: string) {
if (this.options.makeSocket) {
return this.options.makeSocket(url)
}
return new Socket(url, defaultSocketPolicies)
}
get(_url: string): Socket { get(_url: string): Socket {
const url = normalizeRelayUrl(_url) const url = normalizeRelayUrl(_url)
const socket = this._data.get(url) const socket = this._data.get(url)
@@ -35,7 +22,7 @@ export class Pool {
return socket return socket
} }
const newSocket = this.makeSocket(url) const newSocket = new Socket(url, this.socketPolicies)
this._data.set(url, newSocket) this._data.set(url, newSocket)