diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 9ca48a2..97d6a00 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -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" diff --git a/packages/app/src/logging.ts b/packages/app/src/logging.ts deleted file mode 100644 index 6db2db8..0000000 --- a/packages/app/src/logging.ts +++ /dev/null @@ -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 - } - }) - } -} diff --git a/packages/app/src/plugins/logger.ts b/packages/app/src/plugins/logger.ts new file mode 100644 index 0000000..65fca2c --- /dev/null +++ b/packages/app/src/plugins/logger.ts @@ -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([]) + 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)) + } +} diff --git a/packages/app/src/plugins/mutes.ts b/packages/app/src/plugins/mutes.ts index cafe2e7..d079306 100644 --- a/packages/app/src/plugins/mutes.ts +++ b/packages/app/src/plugins/mutes.ts @@ -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) => - 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 { 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(), }) } diff --git a/packages/app/src/plugins/plaintext.ts b/packages/app/src/plugins/plaintext.ts index 5c992ab..04cf911 100644 --- a/packages/app/src/plugins/plaintext.ts +++ b/packages/app/src/plugins/plaintext.ts @@ -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 { - ensure = async (event: TrustedEvent): Promise> => { - if (this.app.user?.pubkey !== event.pubkey) return + ensure = async (ciphertext: string, decrypt: () => Promise): Promise => { + 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 diff --git a/packages/app/src/policy.ts b/packages/app/src/policy.ts index 39b0c08..0c8d815 100644 --- a/packages/app/src/policy.ts +++ b/packages/app/src/policy.ts @@ -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) 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, ] diff --git a/packages/app/src/user.ts b/packages/app/src/user.ts index ef3c429..4621f65 100644 --- a/packages/app/src/user.ts +++ b/packages/app/src/user.ts @@ -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 { @@ -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 + } + } } diff --git a/packages/domain/__tests__/Report.test.ts b/packages/domain/__tests__/Report.test.ts index ff75fb6..549a3c0 100644 --- a/packages/domain/__tests__/Report.test.ts +++ b/packages/domain/__tests__/Report.test.ts @@ -34,7 +34,7 @@ describe("Report", () => { const report = await Report.fromEvent(event) - expect(report.reportedPubkey()).toBe(reported) + expect(report.pubkey()).toBe(reported) expect(report.eventId()).toBe(eventId) expect(report.reason()).toBe("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] === "e").length).toBe(1) - expect(tmpl.tags).toContainEqual(["p", reported]) + expect(tmpl.tags).toContainEqual(["p", reported, "spam"]) expect(tmpl.tags).toContainEqual(["e", eventId, "spam"]) // Unknown passthrough tag survives. expect(tmpl.tags).toContainEqual(["alt", "x"]) @@ -63,14 +63,14 @@ describe("Report", () => { it("builds from a fresh builder", async () => { const tmpl = await new ReportBuilder() - .setReportedPubkey(reported) + .setPubkey(reported) .setEventId(eventId) .setReason("impersonation") .setContent("bad actor") .toTemplate(signer) 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.content).toBe("bad actor") }) diff --git a/packages/domain/src/kinds/Report.ts b/packages/domain/src/kinds/Report.ts index fbe0954..f0ea5ca 100644 --- a/packages/domain/src/kinds/Report.ts +++ b/packages/domain/src/kinds/Report.ts @@ -61,7 +61,7 @@ export class ReportBuilder extends EventBuilder { const tags: string[][] = [] if (this.pTag) { - if (this.pTag.length === 2) { + if (this.pTag.length === 2 && this.reason) { this.pTag.push(this.reason) } @@ -69,7 +69,7 @@ export class ReportBuilder extends EventBuilder { } if (this.eTag) { - if (this.eTag.length === 2) { + if (this.eTag.length === 2 && this.reason) { this.eTag.push(this.reason) } diff --git a/packages/signer/src/util.ts b/packages/signer/src/util.ts index f1115e8..d4d8ac4 100644 --- a/packages/signer/src/util.ts +++ b/packages/signer/src/util.ts @@ -53,7 +53,14 @@ export const decrypt = async (signer: ISigner, pubkey: string, message: string) ? signer.nip04.decrypt(pubkey, message) : signer.nip44.decrypt(pubkey, message) -export type SignerMethodWrapper = (method: string, thunk: () => Promise) => Promise +// `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 = ( + method: string, + thunk: () => Promise, + args: unknown[], +) => Promise export class WrappedSigner extends Emitter implements ISigner { constructor( @@ -64,25 +71,25 @@ export class WrappedSigner extends Emitter implements ISigner { } 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() { - return this.wrapMethod("getPubkey", () => this.signer.getPubkey()) + return this.wrapMethod("getPubkey", () => this.signer.getPubkey(), []) } nip04 = { 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) => - this.wrapMethod("nip04.decrypt", () => this.signer.nip04.decrypt(pubkey, message)), + this.wrapMethod("nip04.decrypt", () => this.signer.nip04.decrypt(pubkey, message), [pubkey, message]), } nip44 = { 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) => - this.wrapMethod("nip44.decrypt", () => this.signer.nip44.decrypt(pubkey, message)), + this.wrapMethod("nip44.decrypt", () => this.signer.nip44.decrypt(pubkey, message), [pubkey, message]), } async cleanup() {