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 "./policy.js"
export * from "./user.js" export * from "./user.js"
export * from "./session.js" export * from "./session.js"
export * from "./logging.js"
export * from "./createApp.js" export * from "./createApp.js"
export * from "./plugins/base.js" export * from "./plugins/base.js"
export * from "./plugins/network.js" export * from "./plugins/network.js"
@@ -12,6 +11,7 @@ export * from "./plugins/relays.js"
export * from "./plugins/relayStats.js" export * from "./plugins/relayStats.js"
export * from "./plugins/relayLists.js" export * from "./plugins/relayLists.js"
export * from "./plugins/blockedRelayLists.js" export * from "./plugins/blockedRelayLists.js"
export * from "./plugins/logger.js"
export * from "./plugins/plaintext.js" export * from "./plugins/plaintext.js"
export * from "./plugins/profiles.js" export * from "./plugins/profiles.js"
export * from "./plugins/follows.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 {nthEq} from "@welshman/lib"
import {MUTES} from "@welshman/util" import {MUTES} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {MuteList, MuteListBuilder} from "@welshman/domain" import {MuteList, MuteListBuilder} from "@welshman/domain"
import {DerivedPlugin} from "./base.js" import {DerivedPlugin} from "./base.js"
import type {IApp} from "../app.js" import type {IApp} from "../app.js"
import {Network} from "./network.js" import {Network} from "./network.js"
import {Thunks} from "./thunk.js" import {Thunks} from "./thunk.js"
import {Plaintext} from "./plaintext.js"
import {User} from "../user.js" import {User} from "../user.js"
/** /**
* A signer that decrypts via the app's plaintext cache (keyed by event), falling * Kind-10000 mute lists, keyed by pubkey.
* 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).
*/ */
export class MuteLists extends DerivedPlugin<MuteList> { export class MuteLists extends DerivedPlugin<MuteList> {
constructor(app: IApp) { constructor(app: IApp) {
super(app, { super(app, {
filters: [{kinds: [MUTES]}], filters: [{kinds: [MUTES]}],
eventToItem: event => MuteList.fromEvent(event, makeCachedSigner(app, event)), eventToItem: MuteList.factory(app.user?.signer),
getKey: mute => mute.author(), 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" 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> { export class Plaintext extends MapPlugin<string> {
ensure = async (event: TrustedEvent): Promise<Maybe<string>> => { ensure = async (ciphertext: string, decrypt: () => Promise<string>): Promise<string> => {
if (this.app.user?.pubkey !== event.pubkey) return let result = this.get(ciphertext)
let result = this.get(event.id) if (result === undefined) {
if (event.content && result === undefined) { result = await decrypt()
try {
result = await decrypt(this.app.user.signer, event.pubkey, event.content) this.set(ciphertext, result)
this.set(event.id, result)
} catch (e: any) {
if (!String(e).match(/invalid base64/)) {
throw e
}
}
} }
return result return result
+43 -13
View File
@@ -8,8 +8,8 @@ import type {IApp} from "./app.js"
import {RelayStats} from "./plugins/relayStats.js" import {RelayStats} from "./plugins/relayStats.js"
import {Wraps} from "./plugins/wraps.js" import {Wraps} from "./plugins/wraps.js"
import {BlockedRelayLists} from "./plugins/blockedRelayLists.js" import {BlockedRelayLists} from "./plugins/blockedRelayLists.js"
import {LoggingSigner} from "./logging.js" import {Plaintext} from "./plugins/plaintext.js"
import type {LogMessage} from "./logging.js" import {Logger} from "./plugins/logger.js"
/** /**
* An app policy is a side effect applied once per app at construction, * 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 — * Wraps user.signer in a WrappedSigner which checks the plaintext cache before
* add `makeAppPolicyLogger(handler)` to an app's `policies`. * attempting to decrypt something.
*/ */
export const makeAppPolicyLogger = export const appPolicyCacheDecrypt: AppPolicy = app => {
(onMessage: (message: LogMessage) => void): AppPolicy => if (!app.user) return noop
app => {
const unsubscribers: Unsubscriber[] = []
const signer = app.user?.signer
if (signer instanceof LoggingSigner) { return app.user.wrapSigner((method, thunk, args) => {
unsubscribers.push(on(signer, "message", onMessage)) 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[] = [ export const defaultAppPolicies: AppPolicy[] = [
appPolicyIngest, appPolicyIngest,
appPolicyRelayStats, appPolicyRelayStats,
appPolicyWraps, appPolicyWraps,
appPolicyCacheDecrypt,
appPolicyLogSignerMethods,
appPolicyAuthUnlessBlocked, appPolicyAuthUnlessBlocked,
] ]
+19 -10
View File
@@ -1,6 +1,6 @@
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer" import {WrappedSigner} from "@welshman/signer"
import {LoggingSigner} from "./logging.js" import type {ISigner, SignerMethodWrapper} from "@welshman/signer"
import {getSignerFromSession} from "./session.js" import {getSignerFromSession} from "./session.js"
import type {Session} from "./session.js" import type {Session} from "./session.js"
import type {IApp} from "./app.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 * 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 * centered on (at most) one `User`, since the data a user can access depends
* entirely on who they are. * 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 { export class User {
constructor( constructor(
readonly pubkey: string, readonly pubkey: string,
readonly signer: ISigner, public signer: ISigner,
) {} ) {}
static async fromSigner(signer: ISigner) { static async fromSigner(signer: ISigner) {
if (!(signer instanceof LoggingSigner)) {
signer = new LoggingSigner(signer)
}
const pubkey = await signer.getPubkey() const pubkey = await signer.getPubkey()
return new User(pubkey, signer) return new User(pubkey, signer)
@@ -28,9 +28,8 @@ export class User {
/** /**
* Reconstruct a signing user from a persisted session, using the registered * 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 * session handlers to find the one for the session's method. The pubkey is
* wrapped in a `LoggingSigner` (observe it with `makeAppPolicyLogger`) and the * derived from the signer. Returns undefined when no handler is registered
* pubkey is derived from it. Returns undefined when no handler is registered
* for the session's method. * for the session's method.
*/ */
static async fromSession(session: Session): Promise<User | undefined> { static async fromSession(session: Session): Promise<User | undefined> {
@@ -54,4 +53,14 @@ export class User {
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)
wrapSigner = (wrap: SignerMethodWrapper) => {
const original = this.signer
this.signer = new WrappedSigner(original, wrap)
return () => {
this.signer = original
}
}
} }
+4 -4
View File
@@ -34,7 +34,7 @@ describe("Report", () => {
const report = await Report.fromEvent(event) const report = await Report.fromEvent(event)
expect(report.reportedPubkey()).toBe(reported) expect(report.pubkey()).toBe(reported)
expect(report.eventId()).toBe(eventId) expect(report.eventId()).toBe(eventId)
expect(report.reason()).toBe("spam") expect(report.reason()).toBe("spam")
expect(report.content()).toBe("this is spam") expect(report.content()).toBe("this is spam")
@@ -54,7 +54,7 @@ describe("Report", () => {
expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1) expect(tmpl.tags.filter(t => t[0] === "p").length).toBe(1)
expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1) expect(tmpl.tags.filter(t => t[0] === "e").length).toBe(1)
expect(tmpl.tags).toContainEqual(["p", reported]) expect(tmpl.tags).toContainEqual(["p", reported, "spam"])
expect(tmpl.tags).toContainEqual(["e", eventId, "spam"]) expect(tmpl.tags).toContainEqual(["e", eventId, "spam"])
// Unknown passthrough tag survives. // Unknown passthrough tag survives.
expect(tmpl.tags).toContainEqual(["alt", "x"]) expect(tmpl.tags).toContainEqual(["alt", "x"])
@@ -63,14 +63,14 @@ describe("Report", () => {
it("builds from a fresh builder", async () => { it("builds from a fresh builder", async () => {
const tmpl = await new ReportBuilder() const tmpl = await new ReportBuilder()
.setReportedPubkey(reported) .setPubkey(reported)
.setEventId(eventId) .setEventId(eventId)
.setReason("impersonation") .setReason("impersonation")
.setContent("bad actor") .setContent("bad actor")
.toTemplate(signer) .toTemplate(signer)
expect(tmpl.kind).toBe(REPORT) expect(tmpl.kind).toBe(REPORT)
expect(tmpl.tags).toContainEqual(["p", reported]) expect(tmpl.tags).toContainEqual(["p", reported, "impersonation"])
expect(tmpl.tags).toContainEqual(["e", eventId, "impersonation"]) expect(tmpl.tags).toContainEqual(["e", eventId, "impersonation"])
expect(tmpl.content).toBe("bad actor") expect(tmpl.content).toBe("bad actor")
}) })
+2 -2
View File
@@ -61,7 +61,7 @@ export class ReportBuilder extends EventBuilder<Report> {
const tags: string[][] = [] const tags: string[][] = []
if (this.pTag) { if (this.pTag) {
if (this.pTag.length === 2) { if (this.pTag.length === 2 && this.reason) {
this.pTag.push(this.reason) this.pTag.push(this.reason)
} }
@@ -69,7 +69,7 @@ export class ReportBuilder extends EventBuilder<Report> {
} }
if (this.eTag) { if (this.eTag) {
if (this.eTag.length === 2) { if (this.eTag.length === 2 && this.reason) {
this.eTag.push(this.reason) this.eTag.push(this.reason)
} }
+14 -7
View File
@@ -53,7 +53,14 @@ export const decrypt = async (signer: ISigner, pubkey: string, message: string)
? signer.nip04.decrypt(pubkey, message) ? signer.nip04.decrypt(pubkey, message)
: signer.nip44.decrypt(pubkey, message) : signer.nip44.decrypt(pubkey, message)
export type SignerMethodWrapper = <T>(method: string, thunk: () => Promise<T>) => Promise<T> // `args` carries the wrapped method's arguments (e.g. [pubkey, message] for
// encrypt/decrypt), so wrappers can key off them — a decrypt cache, say. A
// wrapper that ignores them can take just (method, thunk).
export type SignerMethodWrapper = <T>(
method: string,
thunk: () => Promise<T>,
args: unknown[],
) => Promise<T>
export class WrappedSigner extends Emitter implements ISigner { export class WrappedSigner extends Emitter implements ISigner {
constructor( constructor(
@@ -64,25 +71,25 @@ export class WrappedSigner extends Emitter implements ISigner {
} }
sign(event: StampedEvent, options: SignOptions = {}) { sign(event: StampedEvent, options: SignOptions = {}) {
return this.wrapMethod("sign", () => this.signer.sign(event, options)) return this.wrapMethod("sign", () => this.signer.sign(event, options), [event, options])
} }
getPubkey() { getPubkey() {
return this.wrapMethod("getPubkey", () => this.signer.getPubkey()) return this.wrapMethod("getPubkey", () => this.signer.getPubkey(), [])
} }
nip04 = { nip04 = {
encrypt: async (pubkey: string, message: string) => encrypt: async (pubkey: string, message: string) =>
this.wrapMethod("nip04.encrypt", () => this.signer.nip04.encrypt(pubkey, message)), this.wrapMethod("nip04.encrypt", () => this.signer.nip04.encrypt(pubkey, message), [pubkey, message]),
decrypt: async (pubkey: string, message: string) => decrypt: async (pubkey: string, message: string) =>
this.wrapMethod("nip04.decrypt", () => this.signer.nip04.decrypt(pubkey, message)), this.wrapMethod("nip04.decrypt", () => this.signer.nip04.decrypt(pubkey, message), [pubkey, message]),
} }
nip44 = { nip44 = {
encrypt: async (pubkey: string, message: string) => encrypt: async (pubkey: string, message: string) =>
this.wrapMethod("nip44.encrypt", () => this.signer.nip44.encrypt(pubkey, message)), this.wrapMethod("nip44.encrypt", () => this.signer.nip44.encrypt(pubkey, message), [pubkey, message]),
decrypt: async (pubkey: string, message: string) => decrypt: async (pubkey: string, message: string) =>
this.wrapMethod("nip44.decrypt", () => this.signer.nip44.decrypt(pubkey, message)), this.wrapMethod("nip44.decrypt", () => this.signer.nip44.decrypt(pubkey, message), [pubkey, message]),
} }
async cleanup() { async cleanup() {