diff --git a/packages/domain/__tests__/MuteList.test.ts b/packages/domain/__tests__/MuteList.test.ts index 43e99fd..093c0de 100644 --- a/packages/domain/__tests__/MuteList.test.ts +++ b/packages/domain/__tests__/MuteList.test.ts @@ -12,7 +12,7 @@ const c = "cc".repeat(32) describe("MuteList", () => { it("round-trips public and private mutes through encryption", async () => { - const list = MuteList.make().addPublicly(a).addPrivately(b) + const list = MuteList.init().addPublicly(a).addPrivately(b) expect(list.pubkeys.sort()).toEqual([a, b].sort()) expect(list.includes(a)).toBe(true) @@ -41,7 +41,7 @@ describe("MuteList", () => { }) it("removes from both public and private entries", async () => { - const list = MuteList.make().addPublicly(a).addPrivately(b) + const list = MuteList.init().addPublicly(a).addPrivately(b) list.remove(a) list.remove(b) @@ -50,7 +50,7 @@ describe("MuteList", () => { }) it("preserves undecrypted ciphertext on pass-through serialization", async () => { - const event = await MuteList.make().addPrivately(b).toEvent(signer) + const event = await MuteList.init().addPrivately(b).toEvent(signer) const undecrypted = await MuteList.parse(event) // We never decrypted, so the original ciphertext must survive untouched. @@ -60,14 +60,14 @@ describe("MuteList", () => { }) it("refuses private mutation when undecrypted", async () => { - const event = await MuteList.make().addPrivately(b).toEvent(signer) + const event = await MuteList.init().addPrivately(b).toEvent(signer) const undecrypted = await MuteList.parse(event) expect(() => undecrypted.addPrivately(c)).toThrow() }) it("toRumor encrypts but does not sign", async () => { - const rumor = await MuteList.make().addPrivately(b).toRumor(signer) + const rumor = await MuteList.init().addPrivately(b).toRumor(signer) expect(rumor.id).toBeTruthy() expect((rumor as TrustedEvent).sig).toBeUndefined() diff --git a/packages/domain/__tests__/Profile.test.ts b/packages/domain/__tests__/Profile.test.ts index ca7f4ee..6174221 100644 --- a/packages/domain/__tests__/Profile.test.ts +++ b/packages/domain/__tests__/Profile.test.ts @@ -26,7 +26,7 @@ describe("Profile", () => { tags: [["alt", "profile"]], }) - const profile = Profile.parse(event) + const profile = await Profile.parse(event) expect(profile.values.name).toBe("alice") expect(profile.hasName()).toBe(true) @@ -41,27 +41,27 @@ describe("Profile", () => { }) it("derives lnurl from a lud16 address", () => { - const profile = Profile.make({lud16: "alice@example.com"}) + const profile = Profile.init({lud16: "alice@example.com"}) expect(profile.values.lnurl).toBeTruthy() }) - it("set merges and re-derives values", () => { - const profile = Profile.make({name: "alice"}) + it("gets and sets values by key", () => { + const profile = Profile.init({name: "alice"}) - profile.set({about: "hello"}) + profile.set("about", "hello") - expect(profile.values.name).toBe("alice") - expect(profile.values.about).toBe("hello") + expect(profile.get("name")).toBe("alice") + expect(profile.get("about")).toBe("hello") }) - it("display falls back to a shortened npub", () => { - const profile = Profile.parse(makeEvent({content: "{}"})) + it("display falls back to a shortened npub", async () => { + const profile = await Profile.parse(makeEvent({content: "{}"})) expect(profile.display()).toBe(displayPubkey(pubkey)) }) - it("throws on the wrong kind", () => { - expect(() => Profile.parse(makeEvent({kind: NOTE}))).toThrow() + it("throws on the wrong kind", async () => { + await expect(Profile.parse(makeEvent({kind: NOTE}))).rejects.toThrow() }) }) diff --git a/packages/domain/src/List.ts b/packages/domain/src/List.ts index b7c1649..a42649f 100644 --- a/packages/domain/src/List.ts +++ b/packages/domain/src/List.ts @@ -10,12 +10,9 @@ const isValidTag = (tag: unknown): tag is string[] => export type ListValues = { publicTags: string[][] - // Private entries as plaintext. Empty when there are none or when we couldn't - // decrypt them (see `decrypted`). privateTags: string[][] - // True when `privateTags` reflects the real (decrypted) private content. False - // means we're holding ciphertext we couldn't read, so private entries are - // unknown and must not be mutated. + // True when `privateTags` reflects decrypted content; false when we hold + // ciphertext we couldn't read (so private entries are unknown). decrypted: boolean } @@ -26,20 +23,14 @@ export const makeListValues = (values: Partial = {}): ListValues => ...values, }) -/** - * Read and decrypt the private tags stored in an event's content. Returns - * `decrypted: false` (and leaves `privateTags` empty) when there is encrypted - * content but no signer, or when decryption fails — in that case the original - * ciphertext is preserved verbatim on serialization. - */ +// Decrypt the private tags in an event's content. Returns decrypted: false when +// there's content but no signer, or decryption fails. export const decryptListContent = async ( event: TrustedEvent, signer?: ISigner, ): Promise> => { - // No private content to read. if (!event.content) return {privateTags: [], decrypted: true} - // No signer to read it with — keep the ciphertext, mark it undecrypted. if (!signer) return {privateTags: [], decrypted: false} try { @@ -52,60 +43,42 @@ export const decryptListContent = async ( } } -/** - * Base class for replaceable lists that carry public entries in tags and - * private entries as an encrypted JSON array in content (NIP-51 style). The - * private entries are decrypted to plaintext on `parse` and re-encrypted on - * `toTemplate`, so all in-between reads and writes are synchronous. - * - * Subclasses fix the `kind` and add domain-specific accessors (see - * `MuteList`). The generic tag mechanics live here. - */ +// Base for NIP-51 lists: public entries in tags, private entries as an encrypted +// JSON array in content. Subclasses fix the kind and add domain accessors. export abstract class EncryptableList extends DomainObject { - constructor(values: Partial = {}, event?: TrustedEvent) { - super(makeListValues(values), event) + values = makeListValues() + + protected normalizeValues(values: Partial = {}) { + return makeListValues(values) } - /** - * Whether the private entries were successfully decrypted (or there were - * none). When false, only public entries are available and private mutations - * throw. - */ - get isDecrypted() { - return this.values.decrypted + protected async parseEvent(event: TrustedEvent, signer?: ISigner): Promise> { + const {privateTags, decrypted} = await decryptListContent(event, signer) + + return {publicTags: event.tags, privateTags, decrypted} } - /** All entries, merging public and (when decrypted) private tags. */ - getTags() { + tags() { return [...this.values.publicTags, ...this.values.privateTags] } - getPublicTags() { - return this.values.publicTags - } - - getPrivateTags() { - return this.values.privateTags - } - - /** Add one or more tags to the public (cleartext) entries. */ addPublicTags(...tags: string[][]) { this.values.publicTags = uniqTags([...this.values.publicTags, ...tags]) return this } - /** Add one or more tags to the private (encrypted) entries. */ addPrivateTags(...tags: string[][]) { - this.assertDecrypted() + if (!this.values.decrypted) { + throw new Error("Cannot modify the private entries of a list that has not been decrypted") + } this.values.privateTags = uniqTags([...this.values.privateTags, ...tags]) return this } - /** Remove every tag matching `pred` from both public and private entries. */ - removeTagsBy(pred: (tag: string[]) => boolean) { + keepTags(pred: (tag: string[]) => boolean) { this.values.publicTags = this.values.publicTags.filter(t => !pred(t)) if (this.values.decrypted) { @@ -115,22 +88,36 @@ export abstract class EncryptableList extends DomainObject { return this } - /** Remove every tag whose value (index 1) equals `value`, public or private. */ - removeTagsByValue(value: string) { - return this.removeTagsBy(nthEq(1, value)) + keepTagsWithKey(key: string) { + return this.keepTags(nthEq(0, key)) } - protected assertDecrypted() { - if (!this.values.decrypted) { - throw new Error("Cannot modify the private entries of a list that has not been decrypted") + keepTagsWithValue(value: string) { + return this.keepTags(nthEq(1, value)) + } + + removeTags(pred: (tag: string[]) => boolean) { + this.values.publicTags = this.values.publicTags.filter(t => !pred(t)) + + if (this.values.decrypted) { + this.values.privateTags = this.values.privateTags.filter(t => !pred(t)) } + + return this + } + + removeTagsWithKey(key: string) { + return this.removeTags(nthEq(0, key)) + } + + removeTagsWithValue(value: string) { + return this.removeTags(nthEq(1, value)) } async toTemplate(signer?: ISigner): Promise { const tags = this.values.publicTags - // Preserve the original ciphertext when we never decrypted it, so a - // pass-through round trip doesn't destroy private entries we can't read. + // Preserve the original ciphertext when we never decrypted it. let content = this.event?.content || "" if (this.values.decrypted) { diff --git a/packages/domain/src/MuteList.ts b/packages/domain/src/MuteList.ts index 64d35a7..24a663f 100644 --- a/packages/domain/src/MuteList.ts +++ b/packages/domain/src/MuteList.ts @@ -1,66 +1,29 @@ import {uniq} from "@welshman/lib" import {MUTES, getPubkeyTagValues} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import type {ISigner} from "@welshman/signer" -import {EncryptableList, decryptListContent} from "./List.js" +import {EncryptableList} from "./List.js" -/** - * A NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (in tags) or - * privately (in encrypted content); the convenience accessors here treat both - * as one merged set. - * - * @example - * const mutes = await MuteList.parse(event, signer) // decrypts if able - * mutes.addPrivately(pubkey1) - * mutes.remove(pubkey2) // public and private - * mutes.pubkeys // merged - * mutes.includes(pubkey) - * const signed = await mutes.toEvent(signer) // encrypts + signs - */ +// NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately +// (encrypted content); the accessors treat both as one merged set. export class MuteList extends EncryptableList { readonly kind = MUTES - /** Create an empty, decrypted mute list (e.g. for a user with none yet). */ - static make() { - return new MuteList() + pubkeys() { + return uniq(getPubkeyTagValues(this.tags)) } - /** - * Parse a kind-10000 event into a `MuteList`, decrypting its private entries - * when a capable signer is supplied. Throws on the wrong kind. - */ - static async parse(event: TrustedEvent, signer?: ISigner) { - if (event.kind !== MUTES) { - throw new Error(`Expected a kind ${MUTES} event, got kind ${event.kind}`) - } - - const {privateTags, decrypted} = await decryptListContent(event, signer) - - return new MuteList({publicTags: event.tags, privateTags, decrypted}, event) - } - - /** The muted pubkeys, merging public and (when decrypted) private entries. */ - get pubkeys() { - return uniq(getPubkeyTagValues(this.getTags())) - } - - /** Whether `pubkey` is muted, publicly or privately. */ includes(pubkey: string) { return this.pubkeys.includes(pubkey) } - /** Mute a pubkey publicly (visible to anyone who reads the event). */ - addPublicly(pubkey: string) { + mutePublicly(pubkey: string) { return this.addPublicTags(["p", pubkey]) } - /** Mute a pubkey privately (stored in encrypted content). */ - addPrivately(pubkey: string) { + mutePrivately(pubkey: string) { return this.addPrivateTags(["p", pubkey]) } - /** Unmute a pubkey, removing it from both public and private entries. */ - remove(pubkey: string) { + unmute(pubkey: string) { return this.removeTagsByValue(pubkey) } } diff --git a/packages/domain/src/Profile.ts b/packages/domain/src/Profile.ts index 68d6c4c..2fc3f6c 100644 --- a/packages/domain/src/Profile.ts +++ b/packages/domain/src/Profile.ts @@ -17,10 +17,7 @@ export type ProfileValues = { display_name?: string } -/** - * Normalize raw profile values, deriving `lnurl` from a `lud06` or `lud16` - * address when present. - */ +// Apply defaults, deriving `lnurl` from a `lud06` or `lud16` address. export const makeProfileValues = (values: Partial = {}): ProfileValues => { const result: ProfileValues = {...values} @@ -43,55 +40,50 @@ export const displayPubkey = (pubkey: string) => { return d.slice(0, 8) + "…" + d.slice(-5) } -/** - * A kind-0 profile. Profile data lives unencrypted in the event content as - * JSON, so this is the simplest kind of domain object — `toTemplate` ignores - * the signer and only `toEvent`/`toRumor` need one (to sign). - * - * @example - * const profile = Profile.parse(event) - * profile.set({about: "hello"}) - * const signed = await profile.toEvent(signer) - */ export class Profile extends DomainObject { readonly kind = PROFILE + values = makeProfileValues() - constructor(values: Partial = {}, event?: TrustedEvent) { - super(makeProfileValues(values), event) + protected normalizeValues(values: Partial = {}) { + return makeProfileValues(values) } - /** Create a profile from (optional) values. */ - static make(values: Partial = {}) { - return new Profile(values) + protected parseEvent(event: TrustedEvent): Partial { + return parseJson(event.content) || {} } - /** Parse a kind-0 event into a `Profile`. Throws on the wrong kind. */ - static parse(event: TrustedEvent) { - if (event.kind !== PROFILE) { - throw new Error(`Expected a kind ${PROFILE} event, got kind ${event.kind}`) - } - - return new Profile(parseJson(event.content) || {}, event) + name() { + return this.values.name || this.values.display_name } - /** Merge `updates` into the profile values, re-deriving `lnurl` as needed. */ - set(updates: Partial) { - this.values = makeProfileValues({...this.values, ...updates}) - - return this + nip05() { + return this.values. } - /** Whether the profile has a display-worthy name. */ - hasName() { - return Boolean(this.values.name || this.values.display_name) + lnurl() { + return this.values. + } + + about() { + return this.values. + } + + banner() { + return this.values. + } + + picture() { + return this.values. + } + + website() { + return this.values. } - /** A human-readable label, falling back to a shortened npub, then `fallback`. */ display(fallback = "") { - const {name, display_name} = this.values + const name = this.name() if (name) return ellipsize(name, 60).trim() - if (display_name) return ellipsize(display_name, 60).trim() if (this.event) return displayPubkey(this.event.pubkey).trim() return fallback.trim() @@ -101,7 +93,6 @@ export class Profile extends DomainObject { return { kind: this.kind, content: JSON.stringify(this.values), - // Preserve any tags from the source event (e.g. nip05/relay hints). tags: this.event?.tags || [], } } diff --git a/packages/domain/src/base.ts b/packages/domain/src/base.ts index ffbd58d..e0abd8d 100644 --- a/packages/domain/src/base.ts +++ b/packages/domain/src/base.ts @@ -8,61 +8,84 @@ import type {ISigner} from "@welshman/signer" * A domain object is an in-memory, mutable view of a single nostr event whose * state lives in a plain `values` property. The pattern is "decrypt on parse, * mutate in memory, encrypt on serialize": concrete subclasses decrypt private - * content up front (in their static `parse`), expose synchronous accessors and - * mutators over `values`, and only touch the signer again when building an - * event. Subclasses provide: + * content up front (in `parse`), expose synchronous accessors and mutators over + * `values`, and only touch the signer again when building an event. * - * - a static `parse(event, signer?)` that reads (and, when possible, decrypts) - * an event into a domain object - * - `toTemplate(signer?)` that builds (and, when needed, encrypts) the event - * template — the signer is optional for objects with no private content + * There are two construction entry points, both of which populate `values` and + * return `this`: * - * The base provides the signing/wrapping orchestration on top of `toTemplate`. + * - `init(values?)` builds a fresh object from raw input + * - `parse(event, signer?)` reads (and, when possible, decrypts) an event + * + * Subclasses also implement `toTemplate(signer?)` to build (and, when needed, + * encrypt) the event template; the base provides the signing/wrapping + * orchestration on top of it. */ -export abstract class DomainObject { - /** The nostr event kind this object maps to. */ +export abstract class DomainObject> { abstract readonly kind: number + abstract values: V + event?: TrustedEvent - /** - * The object's data. All accessors and mutators read and write through here. - */ - values: V - - /** - * The source event, present when this object was parsed from one and absent - * when it was made fresh. - */ - readonly event?: TrustedEvent - - constructor(values: V, event?: TrustedEvent) { - this.values = values - this.event = event + static init>>( + this: new () => T, + values?: Partial, + ): T { + return new this().init(values) } - /** - * Build the event template for this object, encrypting any private content - * with the signer. Subclasses that hold no private data may ignore the - * signer (which is why it is optional). - */ + static parse>>( + this: new () => T, + event: TrustedEvent, + signer?: ISigner, + ): Promise { + return new this().parse(event, signer) + } + + init(values: Partial = {}) { + this.values = this.normalizeValues(values) + + return this + } + + async parse(event: TrustedEvent, signer?: ISigner) { + if (event.kind !== this.kind) { + throw new Error(`Expected a kind ${this.kind} event, got kind ${event.kind}`) + } + + this.event = event + this.values = this.normalizeValues(await this.parseEvent(event, signer)) + + return this + } + + protected abstract normalizeValues(values?: Partial): V + + protected abstract parseEvent( + event: TrustedEvent, + signer?: ISigner, + ): Partial | Promise> + abstract toTemplate(signer?: ISigner): Promise - /** - * Build a hashed-but-unsigned rumor (the inner event of a NIP-59 gift wrap), - * encrypting private content as needed. A fresh `created_at` is stamped. - */ async toRumor(signer: ISigner): Promise { const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()]) return prep(template, pubkey) } - /** - * Build and sign a full event, encrypting private content as needed. A fresh - * `created_at` is stamped so the result supersedes any prior version. - */ async toEvent(signer: ISigner): Promise { const template = await this.toTemplate(signer) return signer.sign(stamp(template)) } + + get(key: K): V[K] { + return this.values[key] + } + + set(key: K, value: V[K]) { + this.values[key] = value + + return this + } }