diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3ef9bd5..e9292e4 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -23,6 +23,7 @@ export * from "./zappers.js" export * from "./topics.js" export * from "./tags.js" export * from "./session.js" +export * from "./logging.js" export * from "./wot.js" export * from "./feeds.js" export * from "./search.js" diff --git a/packages/client/src/logging.ts b/packages/client/src/logging.ts new file mode 100644 index 0000000..5bcc5fe --- /dev/null +++ b/packages/client/src/logging.ts @@ -0,0 +1,46 @@ +import {randomId} from "@welshman/lib" +import {WrappedSigner} from "@welshman/signer" +import type {ISigner} from "@welshman/signer" + +/** + * A structured, extensible log event. The built-in `signer` variant tracks each + * signer operation (sign/encrypt/decrypt/getPubkey); the open variant lets + * callers emit their own event types — it's not just a string. + */ +export type LogMessage = + | { + type: "signer" + id: string + method: string + status: "pending" | "success" | "failure" + error?: unknown + at: number + } + | {type: string; at: number; [key: string]: unknown} + +/** + * An `ISigner` wrapper that emits a structured `LogMessage` (as a "message" + * event on itself) for every operation it performs. `User.fromSigner` wraps + * signers in this so they're observable; subscribe via `makeClientPolicyLogger`. + */ +export class LoggingSigner extends WrappedSigner { + constructor(signer: ISigner) { + super(signer, async (method, thunk) => { + const id = randomId() + + this.emit("message", {type: "signer", id, method, status: "pending", at: Date.now()}) + + try { + const result = await thunk() + + this.emit("message", {type: "signer", id, method, status: "success", at: Date.now()}) + + return result + } catch (error) { + this.emit("message", {type: "signer", id, method, status: "failure", error, at: Date.now()}) + + throw error + } + }) + } +} diff --git a/packages/client/src/policies.ts b/packages/client/src/policies.ts index 1db473e..3e1776c 100644 --- a/packages/client/src/policies.ts +++ b/packages/client/src/policies.ts @@ -1,5 +1,5 @@ import type {Unsubscriber} from "svelte/store" -import {on, noop, always} from "@welshman/lib" +import {on, noop, always, call} from "@welshman/lib" import {WRAP, isDVMKind, isEphemeralKind, verifyEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" import {SocketEvent, isRelayEvent, makeSocketPolicyAuth} from "@welshman/net" @@ -7,6 +7,8 @@ import type {RelayMessage, Socket} from "@welshman/net" import type {IClient} from "./client.js" import {RelayStats} from "./relayStats.js" import {GiftWraps} from "./giftWraps.js" +import {LoggingSigner} from "./logging.js" +import type {LogMessage} from "./logging.js" /** * A client policy is a side effect applied once per client at construction, @@ -115,6 +117,23 @@ export const clientPolicyGiftWraps: ClientPolicy = client => { }) } +/** + * Forwards "message" events from the user's signer to `onMessage`. Opt-in — + * add `clientPolicyLogger(handler)` to a client's `policies`. + */ +export const makeClientPolicyLogger = + (onMessage: (message: LogMessage) => void): ClientPolicy => + client => { + const unsubscribers: Unsubscriber[] = [] + const signer = client.user?.signer + + if (signer instanceof LoggingSigner) { + unsubscribers.push(on(signer, "message", onMessage)) + } + + return () => unsubscribers.forEach(call) + } + export const defaultClientPolicies: ClientPolicy[] = [ clientPolicyIngest, clientPolicyRelayStats, diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts index b16620d..2685886 100644 --- a/packages/client/src/session.ts +++ b/packages/client/src/session.ts @@ -1,244 +1,82 @@ import {Client as PomadeClient, PomadeSigner} from "@pomade/core" import type {ClientOptions as PomadeClientOptions} from "@pomade/core" -import {writable} from "svelte/store" -import {randomId, append} from "@welshman/lib" -import {getPubkey} from "@welshman/util" -import { - WrappedSigner, - Nip46Broker, - Nip46Signer, - Nip07Signer, - Nip01Signer, - Nip55Signer, -} from "@welshman/signer" +import type {MaybeAsync} from "@welshman/lib" +import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer, Nip55Signer} from "@welshman/signer" import type {ISigner} from "@welshman/signer" -import {User} from "./user.js" -/** - * Session descriptors and the signer construction that turns them into a - * `User`. In the old global package these fed a multi-account registry (a single - * `sessions` map + `pubkey` pointer over one shared repository — the root of the - * merged-DM bug). In the per-client model each session becomes its own `User` - * (and thus its own `Client` with its own repository), so "multi-account" is - * just "multiple clients" and lives above this module. - */ +// ── Sessions: serializable {method, data} descriptors ── -export enum SessionMethod { - Nip01 = "nip01", - Nip07 = "nip07", - Nip46 = "nip46", - Nip55 = "nip55", - Pomade = "pomade", - Pubkey = "pubkey", - Anonymous = "anonymous", +export type Session = { + method: M + data: D } -export type SessionNip01 = { - method: SessionMethod.Nip01 - pubkey: string - secret: string -} +// ── Session handlers: a method string, its data shape, and how to build a signer ── -export type SessionNip07 = { - method: SessionMethod.Nip07 - pubkey: string -} - -export type SessionNip46 = { - method: SessionMethod.Nip46 - pubkey: string - secret: string - handler: { - pubkey: string - relays: string[] - } -} - -export type SessionNip55 = { - method: SessionMethod.Nip55 - pubkey: string - signer: string -} - -export type SessionPomade = { - method: SessionMethod.Pomade - pubkey: string - clientOptions: PomadeClientOptions - email: string -} - -export type SessionPubkey = { - method: SessionMethod.Pubkey - pubkey: string -} - -export type SessionAnonymous = { - method: SessionMethod.Anonymous -} - -export type SessionAnyMethod = - | SessionNip01 - | SessionNip07 - | SessionNip46 - | SessionNip55 - | SessionPomade - | SessionPubkey - | SessionAnonymous - -export type Session = SessionAnyMethod & Record - -// Session factories - -export const makeNip01Session = (secret: string): SessionNip01 => ({ - method: SessionMethod.Nip01, - secret, - pubkey: getPubkey(secret), -}) - -export const makeNip07Session = (pubkey: string): SessionNip07 => ({ - method: SessionMethod.Nip07, - pubkey, -}) - -export const makeNip46Session = ( - pubkey: string, - clientSecret: string, - signerPubkey: string, - relays: string[], -): SessionNip46 => ({ - method: SessionMethod.Nip46, - pubkey, - secret: clientSecret, - handler: {pubkey: signerPubkey, relays}, -}) - -export const makeNip55Session = (pubkey: string, signer: string): SessionNip55 => ({ - method: SessionMethod.Nip55, - pubkey, - signer, -}) - -export const makePomadeSession = ( - pubkey: string, - email: string, - clientOptions: PomadeClientOptions, -): SessionPomade => ({ - method: SessionMethod.Pomade, - pubkey, - clientOptions, - email, -}) - -export const makePubkeySession = (pubkey: string): SessionPubkey => ({ - method: SessionMethod.Pubkey, - pubkey, -}) - -// Type guards - -export const isNip01Session = (session?: Session): session is SessionNip01 => - session?.method === SessionMethod.Nip01 - -export const isNip07Session = (session?: Session): session is SessionNip07 => - session?.method === SessionMethod.Nip07 - -export const isNip46Session = (session?: Session): session is SessionNip46 => - session?.method === SessionMethod.Nip46 - -export const isNip55Session = (session?: Session): session is SessionNip55 => - session?.method === SessionMethod.Nip55 - -export const isPomadeSession = (session?: Session): session is SessionPomade => - session?.method === SessionMethod.Pomade - -export const isPubkeySession = (session?: Session): session is SessionPubkey => - session?.method === SessionMethod.Pubkey - -// Signer construction - -export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt" - -export type SignerLogEntry = { - id: string - method: string - started_at: number - finished_at?: number - ok?: boolean -} - -export const signerLog = writable([]) - -export const wrapSigner = (signer: ISigner) => - new WrappedSigner(signer, async (method: string, thunk: () => Promise) => { - const id = randomId() - - signerLog.update(log => append({id, method, started_at: Date.now()}, log)) - - try { - const result = await thunk() - - signerLog.update(log => - log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: true} : x)), - ) - - return result - } catch (error: any) { - signerLog.update(log => - log.map(x => (x.id === id ? {...x, finished_at: Date.now(), ok: false} : x)), - ) - - throw error - } - }) - -export const getSigner = (session?: Session): ISigner | undefined => { - if (isNip07Session(session)) return wrapSigner(new Nip07Signer()) - if (isNip01Session(session)) return wrapSigner(new Nip01Signer(session.secret)) - if (isNip55Session(session)) return wrapSigner(new Nip55Signer(session.signer, session.pubkey)) - if (isPomadeSession(session)) - return wrapSigner(new PomadeSigner(new PomadeClient(session.clientOptions))) - if (isNip46Session(session)) { - const { - secret: clientSecret, - handler: {relays, pubkey: signerPubkey}, - } = session - const broker = new Nip46Broker({clientSecret, signerPubkey, relays}) - - return wrapSigner(new Nip46Signer(broker)) - } +export type SessionHandler = { + method: M + getSigner: (data: D) => MaybeAsync } /** - * Build a `User` (pubkey + signer) from a session descriptor. Returns undefined - * for sessions that can't sign (e.g. read-only Pubkey or Anonymous). Pass the - * result to `new Client({user})` / `createApp({user})`. + * Define a session handler. `M` and `D` are inferred from the arguments, so + * `getSigner` is type-checked against the data shape — and the same handler is + * used to build typed sessions (`toSession`) and to reconstruct signers. */ -export const userFromSession = (session: Session): User | undefined => { - const signer = getSigner(session) +export const defineSessionHandler = (handler: SessionHandler) => handler - return signer && typeof session.pubkey === "string" - ? new User(session.pubkey, signer) - : undefined +/** Build a typed, serializable session from a handler and its data. */ +export const toSession = ( + handler: SessionHandler, + data: D, +): Session => ({method: handler.method, data}) + +// ── Built-in handlers ── + +export const nip01 = defineSessionHandler({ + method: "nip01", + getSigner: (data: {secret: string}) => new Nip01Signer(data.secret), +}) + +export const nip07 = defineSessionHandler({ + method: "nip07", + getSigner: (_data: Record) => new Nip07Signer(), +}) + +export const nip46 = defineSessionHandler({ + method: "nip46", + getSigner: (data: {clientSecret: string; signerPubkey: string; relays: string[]}) => + new Nip46Signer(new Nip46Broker(data)), +}) + +export const nip55 = defineSessionHandler({ + method: "nip55", + getSigner: (data: {pubkey: string; signer: string}) => new Nip55Signer(data.signer, data.pubkey), +}) + +export const pomade = defineSessionHandler({ + method: "pomade", + getSigner: (data: {clientOptions: PomadeClientOptions; email: string}) => + new PomadeSigner(new PomadeClient(data.clientOptions)), +}) + +// ── Registry: deserialize a stored session back into a signer ── + +export const sessionHandlers = new Map>() + +export const registerSessionHandler = (handler: SessionHandler) => { + sessionHandlers.set(handler.method, handler) } -// Login helpers — each returns a User to build a client/app with +export const unregisterSessionHandler = (handler: SessionHandler) => { + sessionHandlers.delete(handler.method) +} -export const loginWithNip01 = (secret: string) => userFromSession(makeNip01Session(secret)) +export const getSignerFromSession = (session: Session): MaybeAsync | undefined => + sessionHandlers.get(session.method)?.getSigner(session.data) -export const loginWithNip07 = (pubkey: string) => userFromSession(makeNip07Session(pubkey)) +// ── Initialize default session handlers ── -export const loginWithNip46 = ( - pubkey: string, - clientSecret: string, - signerPubkey: string, - relays: string[], -) => userFromSession(makeNip46Session(pubkey, clientSecret, signerPubkey, relays)) - -export const loginWithNip55 = (pubkey: string, signer: string) => - userFromSession(makeNip55Session(pubkey, signer)) - -export const loginWithPomade = ( - pubkey: string, - email: string, - clientOptions: PomadeClientOptions, -) => userFromSession(makePomadeSession(pubkey, email, clientOptions)) +for (const sessionHandler of [nip01, nip07, nip46, nip55, pomade]) { + registerSessionHandler(sessionHandler) +} diff --git a/packages/client/src/user.ts b/packages/client/src/user.ts index 9ecac93..74295ef 100644 --- a/packages/client/src/user.ts +++ b/packages/client/src/user.ts @@ -1,5 +1,8 @@ import type {StampedEvent} from "@welshman/util" import type {ISigner} from "@welshman/signer" +import {LoggingSigner} from "./logging.js" +import {getSignerFromSession} from "./session.js" +import type {Session} from "./session.js" /** * A single identity: a pubkey plus the signer that proves it. A `Client` is @@ -13,11 +16,28 @@ export class User { ) {} static async fromSigner(signer: ISigner) { + if (!(signer instanceof LoggingSigner)) { + signer = new LoggingSigner(signer) + } + const pubkey = await signer.getPubkey() return new User(pubkey, signer) } + /** + * Reconstruct a signing user from a persisted session, using the registered + * session handlers to find the one for the session's method. The signer is + * wrapped in a `LoggingSigner` (observe it with `clientPolicyLogger`) and the + * pubkey is derived from it. Returns undefined when no handler is registered + * for the session's method. + */ + static async fromSession(session: Session): Promise { + const signer = await getSignerFromSession(session) + + return signer ? User.fromSigner(signer) : undefined + } + sign = (event: StampedEvent) => this.signer.sign(event) nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)