Rework logger, improve plaintext caching

This commit is contained in:
Jon Staab
2026-06-20 10:12:39 -07:00
parent ed17dcc412
commit d2b57c559d
10 changed files with 124 additions and 137 deletions
+1 -1
View File
@@ -2,7 +2,6 @@ export * from "./app.js"
export * from "./policy.js"
export * from "./user.js"
export * from "./session.js"
export * from "./logging.js"
export * from "./createApp.js"
export * from "./plugins/base.js"
export * from "./plugins/network.js"
@@ -12,6 +11,7 @@ export * from "./plugins/relays.js"
export * from "./plugins/relayStats.js"
export * from "./plugins/relayLists.js"
export * from "./plugins/blockedRelayLists.js"
export * from "./plugins/logger.js"
export * from "./plugins/plaintext.js"
export * from "./plugins/profiles.js"
export * from "./plugins/follows.js"
-46
View File
@@ -1,46 +0,0 @@
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 `makeAppPolicyLogger`.
*/
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
}
})
}
}
+29
View File
@@ -0,0 +1,29 @@
import {writable} from "svelte/store"
import {randomId} from "@welshman/lib"
import {projection} from "./base.js"
import type {IApp} from "../app.js"
export type LogMessage = {
source: string
id: string
at: number
[key: string]: unknown
}
/**
* A logger which stores messages durably for inspection. Subscribe to `messages`
* (a projection) to read the log; append with `log(source, {...})`.
*/
export class Logger {
protected store = writable<LogMessage[]>([])
messages = projection(this.store)
constructor(protected readonly app: IApp) {}
log(
source: string,
{id = randomId(), at = Date.now(), ...message}: {id?: string; at?: number; [key: string]: unknown},
) {
this.store.update($messages => $messages.concat({source, id, at, ...message}).slice(-1000))
}
}
+2 -38
View File
@@ -1,56 +1,20 @@
import {nthEq} from "@welshman/lib"
import {MUTES} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {MuteList, MuteListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js"
import type {IApp} from "../app.js"
import {Network} from "./network.js"
import {Thunks} from "./thunk.js"
import {Plaintext} from "./plaintext.js"
import {User} from "../user.js"
/**
* A signer that decrypts via the app's plaintext cache (keyed by event), falling
* back to the real signer. Lets `MuteList.fromEvent(event, signer)` reuse cached
* decryptions instead of re-decrypting. Returns undefined when there's no user,
* so the reader falls back to public-only.
*/
const makeCachedSigner = (app: IApp, event: TrustedEvent): ISigner | undefined => {
const user = app.user
if (!user) return undefined
const {signer} = user
const decryptVia =
(fallback: (pubkey: string, message: string) => Promise<string>) =>
async (pubkey: string, message: string) =>
(await app.use(Plaintext).ensure(event)) ?? fallback(pubkey, message)
return {
sign: (event, options) => signer.sign(event, options),
getPubkey: () => signer.getPubkey(),
nip04: {
encrypt: (pubkey, message) => signer.nip04.encrypt(pubkey, message),
decrypt: decryptVia((pubkey, message) => signer.nip04.decrypt(pubkey, message)),
},
nip44: {
encrypt: (pubkey, message) => signer.nip44.encrypt(pubkey, message),
decrypt: decryptVia((pubkey, message) => signer.nip44.decrypt(pubkey, message)),
},
}
}
/**
* Kind-10000 mute lists, keyed by pubkey. Mute lists carry private entries in
* encrypted content, decoded through the plaintext cache (via a cache-backed
* signer passed to the reader).
* Kind-10000 mute lists, keyed by pubkey.
*/
export class MuteLists extends DerivedPlugin<MuteList> {
constructor(app: IApp) {
super(app, {
filters: [{kinds: [MUTES]}],
eventToItem: event => MuteList.fromEvent(event, makeCachedSigner(app, event)),
eventToItem: MuteList.factory(app.user?.signer),
getKey: mute => mute.author(),
})
}
+10 -16
View File
@@ -1,25 +1,19 @@
import {decrypt} from "@welshman/signer"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MapPlugin} from "./base.js"
/**
* A cache of decrypted event content, keyed by event id.
* A cache of decrypted content, keyed by the ciphertext. Decryption itself is
* supplied by the caller (the signer's underlying decrypt), so the cache stays
* independent of which signer produced the plaintext — and `appPolicyCacheDecrypt`
* can layer it onto `user.signer` without recursing back into the wrapped signer.
*/
export class Plaintext extends MapPlugin<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => {
if (this.app.user?.pubkey !== event.pubkey) return
ensure = async (ciphertext: string, decrypt: () => Promise<string>): Promise<string> => {
let result = this.get(ciphertext)
let result = this.get(event.id)
if (event.content && result === undefined) {
try {
result = await decrypt(this.app.user.signer, event.pubkey, event.content)
this.set(event.id, result)
} catch (e: any) {
if (!String(e).match(/invalid base64/)) {
throw e
}
}
if (result === undefined) {
result = await decrypt()
this.set(ciphertext, result)
}
return result
+43 -13
View File
@@ -8,8 +8,8 @@ import type {IApp} from "./app.js"
import {RelayStats} from "./plugins/relayStats.js"
import {Wraps} from "./plugins/wraps.js"
import {BlockedRelayLists} from "./plugins/blockedRelayLists.js"
import {LoggingSigner} from "./logging.js"
import type {LogMessage} from "./logging.js"
import {Plaintext} from "./plugins/plaintext.js"
import {Logger} from "./plugins/logger.js"
/**
* An app policy is a side effect applied once per app at construction,
@@ -121,25 +121,55 @@ export const appPolicyWraps: AppPolicy = app => {
}
/**
* Forwards "message" events from the user's signer to `onMessage`. Opt-in —
* add `makeAppPolicyLogger(handler)` to an app's `policies`.
* Wraps user.signer in a WrappedSigner which checks the plaintext cache before
* attempting to decrypt something.
*/
export const makeAppPolicyLogger =
(onMessage: (message: LogMessage) => void): AppPolicy =>
app => {
const unsubscribers: Unsubscriber[] = []
const signer = app.user?.signer
export const appPolicyCacheDecrypt: AppPolicy = app => {
if (!app.user) return noop
if (signer instanceof LoggingSigner) {
unsubscribers.push(on(signer, "message", onMessage))
return app.user.wrapSigner((method, thunk, args) => {
if (method === "nip04.decrypt" || method === "nip44.decrypt") {
const ciphertext = args[1] as string
return app.use(Plaintext).ensure(ciphertext, thunk as () => Promise<string>) as ReturnType<
typeof thunk
>
}
return () => unsubscribers.forEach(call)
}
return thunk()
})
}
/**
* Wraps user.signer in a WrappedSigner which logs sign requests to the app logger.
*/
export const appPolicyLogSignerMethods: AppPolicy = app => {
if (!app.user) return noop
const logger = app.use(Logger)
return app.user.wrapSigner(async (method, thunk) => {
logger.log("signer", {method, status: "pending"})
try {
const result = await thunk()
logger.log("signer", {method, status: "success"})
return result
} catch (error) {
logger.log("signer", {method, status: "failure", error})
throw error
}
})
}
export const defaultAppPolicies: AppPolicy[] = [
appPolicyIngest,
appPolicyRelayStats,
appPolicyWraps,
appPolicyCacheDecrypt,
appPolicyLogSignerMethods,
appPolicyAuthUnlessBlocked,
]
+19 -10
View File
@@ -1,6 +1,6 @@
import type {StampedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {LoggingSigner} from "./logging.js"
import {WrappedSigner} from "@welshman/signer"
import type {ISigner, SignerMethodWrapper} from "@welshman/signer"
import {getSignerFromSession} from "./session.js"
import type {Session} from "./session.js"
import type {IApp} from "./app.js"
@@ -9,18 +9,18 @@ import type {IApp} from "./app.js"
* A single identity: a pubkey plus the signer that proves it. An `App` is
* centered on (at most) one `User`, since the data a user can access depends
* entirely on who they are.
*
* `signer` is mutable so app policies can layer behavior onto it after
* construction via `wrapSigner` — e.g. `appPolicyCacheDecrypt` and
* `appPolicyLogSignerMethods`.
*/
export class User {
constructor(
readonly pubkey: string,
readonly signer: ISigner,
public signer: ISigner,
) {}
static async fromSigner(signer: ISigner) {
if (!(signer instanceof LoggingSigner)) {
signer = new LoggingSigner(signer)
}
const pubkey = await signer.getPubkey()
return new User(pubkey, signer)
@@ -28,9 +28,8 @@ export class User {
/**
* 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 `makeAppPolicyLogger`) and the
* pubkey is derived from it. Returns undefined when no handler is registered
* session handlers to find the one for the session's method. The pubkey is
* derived from the signer. Returns undefined when no handler is registered
* for the session's method.
*/
static async fromSession(session: Session): Promise<User | undefined> {
@@ -54,4 +53,14 @@ export class User {
sign = (event: StampedEvent) => this.signer.sign(event)
nip44EncryptToSelf = (payload: string) => this.signer.nip44.encrypt(this.pubkey, payload)
wrapSigner = (wrap: SignerMethodWrapper) => {
const original = this.signer
this.signer = new WrappedSigner(original, wrap)
return () => {
this.signer = original
}
}
}