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 {call} from "@welshman/lib"
import {Pool, Socket, Tracker, Repository, WrapManager, defaultSocketPolicies} from "@welshman/net"
import type {NetContext, AdapterFactory, SocketPolicy} from "@welshman/net"
import {Pool, Tracker, Repository, WrapManager} from "@welshman/net"
import type {NetContext, AdapterFactory} from "@welshman/net"
import type {User} from "./user.js"
import type {ClientPolicy} from "./policies.js"
@@ -16,7 +16,6 @@ export type ClientOptions = {
user?: User
config?: ClientConfig
getAdapter?: AdapterFactory
socketPolicies?: SocketPolicy[]
policies?: ClientPolicy[]
}
@@ -46,26 +45,13 @@ export class Client implements IClient {
repository: Repository
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 policyCleanups: Unsubscriber[] = []
private unsubscribers: Unsubscriber[] = []
constructor(options: ClientOptions = {}) {
this.user = options.user
this.config = options.config ?? {}
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.pool = new Pool()
this.tracker = new Tracker()
this.repository = new Repository()
this.wrapManager = new WrapManager({
@@ -78,9 +64,9 @@ export class Client implements IClient {
getAdapter: options.getAdapter,
}
// Apply policies last, once the primitives and `use` registry exist. They
// own all side effects; their cleanups run on `cleanup()`.
this.policyCleanups = (options.policies ?? []).map(policy => policy(this))
for (const policy of options.policies ?? []) {
this.unsubscribers.push(policy(this))
}
}
// Resolve the per-client singleton of a data module, constructing it on first
@@ -97,7 +83,7 @@ export class Client implements IClient {
}
cleanup() {
this.policyCleanups.forEach(call)
this.unsubscribers.forEach(call)
this.pool.clear()
this.tracker.clear()
this.repository.clear()
+38 -3
View File
@@ -1,9 +1,9 @@
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 type {TrustedEvent} from "@welshman/util"
import {SocketEvent, isRelayEvent} from "@welshman/net"
import type {RelayMessage} from "@welshman/net"
import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net"
import type {RelayMessage, Socket} from "@welshman/net"
import type {IClient} from "./client.js"
import {RelayStats} from "./relayStats.js"
import {GiftWraps} from "./giftWraps.js"
@@ -17,6 +17,41 @@ import {GiftWraps} from "./giftWraps.js"
*/
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
* 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"
import type {ISigner} from "@welshman/signer"
import {User} from "./user.js"
import type {UserOptions} from "./user.js"
/**
* 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
* 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)
return signer && typeof session.pubkey === "string"
? new User(session.pubkey, signer, options)
? new User(session.pubkey, signer)
: undefined
}
// Login helpers — each returns a User to build a client/app with
export const loginWithNip01 = (secret: string, options?: UserOptions) =>
userFromSession(makeNip01Session(secret), options)
export const loginWithNip01 = (secret: string) => userFromSession(makeNip01Session(secret))
export const loginWithNip07 = (pubkey: string, options?: UserOptions) =>
userFromSession(makeNip07Session(pubkey), options)
export const loginWithNip07 = (pubkey: string) => userFromSession(makeNip07Session(pubkey))
export const loginWithNip46 = (
pubkey: string,
clientSecret: string,
signerPubkey: string,
relays: string[],
options?: UserOptions,
) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays), options)
) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays))
export const loginWithNip55 = (pubkey: string, signer: string, options?: UserOptions) =>
userFromSession(makeNip55Session(pubkey, signer), options)
export const loginWithNip55 = (pubkey: string, signer: string) =>
userFromSession(makeNip55Session(pubkey, signer))
export const loginWithPomade = (
pubkey: string,
email: string,
clientOptions: PomadeClientOptions,
options?: UserOptions,
) => userFromSession(makePomadeSession(pubkey, email, clientOptions), options)
) => userFromSession(makePomadeSession(pubkey, email, clientOptions))
+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 {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
* centered on (at most) one `User`, since the data a user can access depends
@@ -16,21 +10,14 @@ export class User {
constructor(
readonly pubkey: string,
readonly signer: ISigner,
readonly options: UserOptions = {},
) {}
static async fromSigner(signer: ISigner, options: UserOptions = {}) {
static async fromSigner(signer: ISigner) {
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)
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)