diff --git a/packages/domain/.eslintignore b/packages/domain/.eslintignore new file mode 100644 index 0000000..e3a7fb4 --- /dev/null +++ b/packages/domain/.eslintignore @@ -0,0 +1,2 @@ +build +__tests__ diff --git a/packages/domain/__tests__/MuteList.test.ts b/packages/domain/__tests__/MuteList.test.ts new file mode 100644 index 0000000..2bdeb18 --- /dev/null +++ b/packages/domain/__tests__/MuteList.test.ts @@ -0,0 +1,91 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, MUTES, FOLLOWS, getPubkeyTagValues} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {MuteList} from "../src/MuteList" + +const signer = new Nip01Signer(makeSecret()) + +const a = "aa".repeat(32) +const b = "bb".repeat(32) +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) + + expect(list.pubkeys.sort()).toEqual([a, b].sort()) + expect(list.includes(a)).toBe(true) + expect(list.includes(b)).toBe(true) + expect(list.includes(c)).toBe(false) + + const event = await list.toEvent(signer) + + expect(event.kind).toBe(MUTES) + expect(event.sig).toBeTruthy() + // Public entry is visible in tags; private entry is encrypted in content. + expect(getPubkeyTagValues(event.tags)).toEqual([a]) + expect(event.content).not.toBe("") + + // Re-parsing with a capable signer recovers the private entries. + const decrypted = await MuteList.parse(event, signer) + + expect(decrypted.isDecrypted).toBe(true) + expect(decrypted.pubkeys.sort()).toEqual([a, b].sort()) + + // Parsing without a signer exposes only the public entries. + const publicOnly = await MuteList.parse(event) + + expect(publicOnly.isDecrypted).toBe(false) + expect(publicOnly.pubkeys).toEqual([a]) + }) + + it("removes from both public and private entries", async () => { + const list = MuteList.make().addPublicly(a).addPrivately(b) + + list.remove(a) + list.remove(b) + + expect(list.pubkeys).toEqual([]) + }) + + it("preserves undecrypted ciphertext on pass-through serialization", async () => { + const event = await MuteList.make().addPrivately(b).toEvent(signer) + const undecrypted = await MuteList.parse(event) + + // We never decrypted, so the original ciphertext must survive untouched. + const template = await undecrypted.getTemplate(signer) + + expect(template.content).toBe(event.content) + }) + + it("refuses private mutation when undecrypted", async () => { + const event = await MuteList.make().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) + + expect(rumor.id).toBeTruthy() + expect((rumor as TrustedEvent).sig).toBeUndefined() + expect(rumor.content).not.toBe("") + }) + + it("serializes to JSON", async () => { + const list = MuteList.make().addPublicly(a).addPrivately(b) + const json = JSON.parse(JSON.stringify(list)) + + expect(json.kind).toBe(MUTES) + expect(json.publicTags).toEqual([["p", a]]) + expect(json.privateTags).toEqual([["p", b]]) + }) + + it("throws on the wrong kind", async () => { + const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent + + await expect(MuteList.parse(event)).rejects.toThrow() + }) +}) diff --git a/packages/domain/__tests__/Profile.test.ts b/packages/domain/__tests__/Profile.test.ts new file mode 100644 index 0000000..0863d46 --- /dev/null +++ b/packages/domain/__tests__/Profile.test.ts @@ -0,0 +1,73 @@ +import {describe, it, expect} from "vitest" +import {makeSecret, PROFILE, NOTE} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" +import {Profile, displayPubkey} from "../src/Profile" + +const signer = new Nip01Signer(makeSecret()) +const pubkey = "ee".repeat(32) + +const makeEvent = (overrides: Partial = {}): TrustedEvent => + ({ + id: "ff".repeat(32), + pubkey, + created_at: 0, + kind: PROFILE, + tags: [], + content: "", + sig: "00".repeat(64), + ...overrides, + }) as TrustedEvent + +describe("Profile", () => { + it("parses and re-signs profile content", async () => { + const event = makeEvent({ + content: JSON.stringify({name: "alice", about: "hi"}), + tags: [["alt", "profile"]], + }) + + const profile = Profile.parse(event) + + expect(profile.values.name).toBe("alice") + expect(profile.hasName()).toBe(true) + expect(profile.display()).toBe("alice") + + const signed = await profile.toEvent(signer) + + expect(signed.kind).toBe(PROFILE) + expect(JSON.parse(signed.content).name).toBe("alice") + // Source tags are preserved. + expect(signed.tags).toEqual([["alt", "profile"]]) + }) + + it("derives lnurl from a lud16 address", () => { + const profile = Profile.make({lud16: "alice@example.com"}) + + expect(profile.values.lnurl).toBeTruthy() + }) + + it("set merges and re-derives values", () => { + const profile = Profile.make({name: "alice"}) + + profile.set({about: "hello"}) + + expect(profile.values.name).toBe("alice") + expect(profile.values.about).toBe("hello") + }) + + it("display falls back to a shortened npub", () => { + const profile = Profile.parse(makeEvent({content: "{}"})) + + expect(profile.display()).toBe(displayPubkey(pubkey)) + }) + + it("serializes to JSON", () => { + const profile = Profile.make({name: "alice"}) + + expect(JSON.parse(JSON.stringify(profile))).toEqual({name: "alice"}) + }) + + it("throws on the wrong kind", () => { + expect(() => Profile.parse(makeEvent({kind: NOTE}))).toThrow() + }) +}) diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 0000000..9a4fc5a --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,36 @@ +{ + "name": "@welshman/domain", + "version": "0.8.16", + "author": "hodlbod", + "license": "MIT", + "description": "Stateless utilities for translating nostr events to and from domain objects.", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "dist/domain/src/index.js", + "types": "dist/domain/src/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run clean && pnpm run compile --force", + "clean": "rimraf ./dist", + "compile": "tsc -b tsconfig.build.json", + "prepublishOnly": "pnpm run build" + }, + "peerDependencies": { + "@welshman/lib": "workspace:*", + "@welshman/signer": "workspace:*", + "@welshman/util": "workspace:*", + "nostr-tools": "^2.19.4" + }, + "devDependencies": { + "@welshman/lib": "workspace:*", + "@welshman/signer": "workspace:*", + "@welshman/util": "workspace:*", + "nostr-tools": "^2.19.4", + "rimraf": "~6.0.0", + "typescript": "~5.8.0" + } +} diff --git a/packages/domain/src/List.ts b/packages/domain/src/List.ts new file mode 100644 index 0000000..a9ff54e --- /dev/null +++ b/packages/domain/src/List.ts @@ -0,0 +1,179 @@ +import {nthEq, parseJson} from "@welshman/lib" +import {uniqTags} from "@welshman/util" +import type {EventTemplate, TrustedEvent} from "@welshman/util" +import {decrypt} from "@welshman/signer" +import type {ISigner} from "@welshman/signer" +import {DomainObject} from "./base.js" + +const isValidTag = (tag: unknown): tag is string[] => + Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string") + +export type DecryptedTags = { + privateTags: string[][] + // True when the private content was read (or there was none), false when we + // hold ciphertext we couldn't decrypt. See `EncryptableList.isDecrypted`. + decrypted: boolean +} + +/** + * 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. + */ +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 { + const plaintext = await decrypt(signer, event.pubkey, event.content) + const privateTags = (parseJson(plaintext) || []).filter(isValidTag) + + return {privateTags, decrypted: true} + } catch { + return {privateTags: [], decrypted: false} + } +} + +export type EncryptableListParams = { + publicTags?: string[][] + privateTags?: string[][] + decrypted?: boolean + event?: TrustedEvent +} + +/** + * 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 + * `getTemplate`, 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. + */ +export abstract class EncryptableList extends DomainObject { + abstract readonly kind: number + + publicTags: string[][] + privateTags: string[][] + readonly event?: TrustedEvent + + // Whether `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. + protected decrypted: boolean + + constructor({ + publicTags = [], + privateTags = [], + decrypted = true, + event, + }: EncryptableListParams = {}) { + super() + + this.publicTags = publicTags + this.privateTags = privateTags + this.decrypted = decrypted + this.event = event + } + + /** + * 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.decrypted + } + + /** All entries, merging public and (when decrypted) private tags. */ + getTags() { + return [...this.publicTags, ...this.privateTags] + } + + getPublicTags() { + return this.publicTags + } + + getPrivateTags() { + return this.privateTags + } + + /** Add one or more tags to the public (cleartext) entries. */ + addPublicTags(...tags: string[][]) { + this.publicTags = uniqTags([...this.publicTags, ...tags]) + + return this + } + + /** Add one or more tags to the private (encrypted) entries. */ + addPrivateTags(...tags: string[][]) { + this.assertDecrypted() + + this.privateTags = uniqTags([...this.privateTags, ...tags]) + + return this + } + + /** Remove every tag matching `pred` from both public and private entries. */ + removeTagsBy(pred: (tag: string[]) => boolean) { + this.publicTags = this.publicTags.filter(t => !pred(t)) + + if (this.decrypted) { + this.privateTags = this.privateTags.filter(t => !pred(t)) + } + + return this + } + + /** Remove every tag whose value (index 1) equals `value`, public or private. */ + removeTagsByValue(value: string) { + return this.removeTagsBy(nthEq(1, value)) + } + + protected assertDecrypted() { + if (!this.decrypted) { + throw new Error("Cannot modify the private entries of a list that has not been decrypted") + } + } + + async getTemplate(signer?: ISigner): Promise { + const tags = this.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. + let content = this.event?.content || "" + + if (this.decrypted) { + if (this.privateTags.length === 0) { + content = "" + } else { + if (!signer) { + throw new Error("A signer is required to encrypt the private entries of a list") + } + + const pubkey = await signer.getPubkey() + + content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags)) + } + } + + return {kind: this.kind, tags, content} + } + + toJSON() { + return { + kind: this.kind, + publicTags: this.publicTags, + privateTags: this.privateTags, + decrypted: this.decrypted, + event: this.event, + } + } +} diff --git a/packages/domain/src/MuteList.ts b/packages/domain/src/MuteList.ts new file mode 100644 index 0000000..1fd5b6a --- /dev/null +++ b/packages/domain/src/MuteList.ts @@ -0,0 +1,66 @@ +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" + +/** + * 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 + */ +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() + } + + /** + * 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({event, publicTags: event.tags, privateTags, decrypted}) + } + + /** 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) { + return this.addPublicTags(["p", pubkey]) + } + + /** Mute a pubkey privately (stored in encrypted content). */ + addPrivately(pubkey: string) { + return this.addPrivateTags(["p", pubkey]) + } + + /** Unmute a pubkey, removing it from both public and private entries. */ + remove(pubkey: string) { + return this.removeTagsByValue(pubkey) + } +} diff --git a/packages/domain/src/Profile.ts b/packages/domain/src/Profile.ts new file mode 100644 index 0000000..b245743 --- /dev/null +++ b/packages/domain/src/Profile.ts @@ -0,0 +1,117 @@ +import {npubEncode} from "nostr-tools/nip19" +import {ellipsize, parseJson} from "@welshman/lib" +import {PROFILE, getLnUrl} from "@welshman/util" +import type {EventTemplate, TrustedEvent} from "@welshman/util" +import {DomainObject} from "./base.js" + +export type ProfileValues = { + name?: string + nip05?: string + lud06?: string + lud16?: string + lnurl?: string + about?: string + banner?: string + picture?: string + website?: string + display_name?: string +} + +/** + * Normalize raw profile values, deriving `lnurl` from a `lud06` or `lud16` + * address when present. + */ +export const makeProfileValues = (values: Partial = {}): ProfileValues => { + const result: ProfileValues = {...values} + + for (const key of ["lud06", "lud16"] as const) { + if (typeof result[key] === "string") { + const lnurl = getLnUrl(result[key]!) + + if (lnurl) { + result.lnurl = lnurl + } + } + } + + return result +} + +export const displayPubkey = (pubkey: string) => { + const d = npubEncode(pubkey) + + 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 — `getTemplate` 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 + readonly event?: TrustedEvent + + values: ProfileValues + + constructor(values: Partial = {}, event?: TrustedEvent) { + super() + + this.values = makeProfileValues(values) + this.event = event + } + + static make(values: Partial = {}) { + return new Profile(values) + } + + /** 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) + } + + /** Merge `updates` into the profile values, re-deriving `lnurl` as needed. */ + set(updates: Partial) { + this.values = makeProfileValues({...this.values, ...updates}) + + return this + } + + /** Whether the profile has a display-worthy name. */ + hasName() { + return Boolean(this.values.name || this.values.display_name) + } + + /** A human-readable label, falling back to a shortened npub, then `fallback`. */ + display(fallback = "") { + const {name, display_name} = this.values + + 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() + } + + async getTemplate(): Promise { + return { + kind: PROFILE, + content: JSON.stringify(this.values), + // Preserve any tags from the source event (e.g. nip05/relay hints). + tags: this.event?.tags || [], + } + } + + toJSON() { + return {...this.values} + } +} diff --git a/packages/domain/src/base.ts b/packages/domain/src/base.ts new file mode 100644 index 0000000..2193e8c --- /dev/null +++ b/packages/domain/src/base.ts @@ -0,0 +1,64 @@ +import {stamp, prep} from "@welshman/util" +import type {EventTemplate, SignedEvent, HashedEvent, TrustedEvent} from "@welshman/util" +import type {ISigner} from "@welshman/signer" + +/** + * The base class for domain objects. + * + * A domain object is an in-memory, mutable, JSON-serializable view of a single + * nostr event. 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 the + * plaintext, and only touch the signer again when building an event. + * + * Subclasses provide: + * + * - a static `parse(event, signer?)` that reads (and, when possible, decrypts) + * an event into a domain object + * - `getTemplate(signer?)` that builds (and, when needed, encrypts) the event + * template — the signer is optional for objects with no private content + * - `toJSON()` for plain-object serialization + * + * The base provides the shared signing/wrapping orchestration on top of + * `getTemplate`. + */ +export abstract class DomainObject { + /** + * The source event, present when this object was parsed from one and absent + * when it was freshly constructed. + */ + abstract readonly event?: TrustedEvent + + /** + * 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). + */ + abstract getTemplate(signer?: ISigner): Promise + + /** + * A plain-JSON snapshot of this object — safe for storage, `structuredClone`, + * or `postMessage`. Also lets `JSON.stringify` work transparently. + */ + abstract toJSON(): object + + /** + * 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.getTemplate(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.getTemplate(signer) + + return signer.sign(stamp(template)) + } +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 0000000..b6c9eef --- /dev/null +++ b/packages/domain/src/index.ts @@ -0,0 +1,4 @@ +export * from "./base.js" +export * from "./List.js" +export * from "./MuteList.js" +export * from "./Profile.js" diff --git a/packages/domain/tsconfig.build.json b/packages/domain/tsconfig.build.json new file mode 100644 index 0000000..1167450 --- /dev/null +++ b/packages/domain/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "outDir": "./dist", + "paths": { + "@welshman/lib": ["../lib/src/index.js"], + "@welshman/util": ["../util/src/index.js"], + "@welshman/net": ["../net/src/index.js"], + "@welshman/signer": ["../signer/src/index.js"] + } + }, + + "include": [ + "src/**/*" + ] +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1504fd..960efd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,27 @@ importers: specifier: ~5.8.0 version: 5.8.2 + packages/domain: + devDependencies: + '@welshman/lib': + specifier: workspace:* + version: link:../lib + '@welshman/signer': + specifier: workspace:* + version: link:../signer + '@welshman/util': + specifier: workspace:* + version: link:../util + nostr-tools: + specifier: ^2.19.4 + version: 2.19.4(typescript@5.8.2) + rimraf: + specifier: ~6.0.0 + version: 6.0.1 + typescript: + specifier: ~5.8.0 + version: 5.8.2 + packages/editor: dependencies: '@tiptap/core': diff --git a/tsconfig.build.json b/tsconfig.build.json index 7ba3dd5..6971308 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -21,6 +21,7 @@ "references": [ {"path": "./packages/app/tsconfig.build.json"}, {"path": "./packages/content/tsconfig.build.json"}, + {"path": "./packages/domain/tsconfig.build.json"}, {"path": "./packages/dvm/tsconfig.build.json"}, {"path": "./packages/editor/tsconfig.build.json"}, {"path": "./packages/feeds/tsconfig.build.json"}, diff --git a/vitest.config.ts b/vitest.config.ts index 843323d..c65d8e1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ alias: { "@welshman/app": resolve(__dirname, "packages/app/src"), "@welshman/content": resolve(__dirname, "packages/content/src"), + "@welshman/domain": resolve(__dirname, "packages/domain/src"), "@welshman/feeds": resolve(__dirname, "packages/feeds/src"), "@welshman/lib": resolve(__dirname, "packages/lib/src"), "@welshman/net": resolve(__dirname, "packages/net/src"),