Add domain package
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
build
|
||||||
|
__tests__
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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> = {}): 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DecryptedTags> => {
|
||||||
|
// 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<EventTemplate> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<ProfileValues> = {}, event?: TrustedEvent) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.values = makeProfileValues(values)
|
||||||
|
this.event = event
|
||||||
|
}
|
||||||
|
|
||||||
|
static make(values: Partial<ProfileValues> = {}) {
|
||||||
|
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<ProfileValues>) {
|
||||||
|
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<EventTemplate> {
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EventTemplate>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<HashedEvent> {
|
||||||
|
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<SignedEvent> {
|
||||||
|
const template = await this.getTemplate(signer)
|
||||||
|
|
||||||
|
return signer.sign(stamp(template))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./base.js"
|
||||||
|
export * from "./List.js"
|
||||||
|
export * from "./MuteList.js"
|
||||||
|
export * from "./Profile.js"
|
||||||
@@ -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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json"
|
||||||
|
}
|
||||||
Generated
+21
@@ -172,6 +172,27 @@ importers:
|
|||||||
specifier: ~5.8.0
|
specifier: ~5.8.0
|
||||||
version: 5.8.2
|
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:
|
packages/editor:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core':
|
'@tiptap/core':
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{"path": "./packages/app/tsconfig.build.json"},
|
{"path": "./packages/app/tsconfig.build.json"},
|
||||||
{"path": "./packages/content/tsconfig.build.json"},
|
{"path": "./packages/content/tsconfig.build.json"},
|
||||||
|
{"path": "./packages/domain/tsconfig.build.json"},
|
||||||
{"path": "./packages/dvm/tsconfig.build.json"},
|
{"path": "./packages/dvm/tsconfig.build.json"},
|
||||||
{"path": "./packages/editor/tsconfig.build.json"},
|
{"path": "./packages/editor/tsconfig.build.json"},
|
||||||
{"path": "./packages/feeds/tsconfig.build.json"},
|
{"path": "./packages/feeds/tsconfig.build.json"},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
"@welshman/app": resolve(__dirname, "packages/app/src"),
|
"@welshman/app": resolve(__dirname, "packages/app/src"),
|
||||||
"@welshman/content": resolve(__dirname, "packages/content/src"),
|
"@welshman/content": resolve(__dirname, "packages/content/src"),
|
||||||
|
"@welshman/domain": resolve(__dirname, "packages/domain/src"),
|
||||||
"@welshman/feeds": resolve(__dirname, "packages/feeds/src"),
|
"@welshman/feeds": resolve(__dirname, "packages/feeds/src"),
|
||||||
"@welshman/lib": resolve(__dirname, "packages/lib/src"),
|
"@welshman/lib": resolve(__dirname, "packages/lib/src"),
|
||||||
"@welshman/net": resolve(__dirname, "packages/net/src"),
|
"@welshman/net": resolve(__dirname, "packages/net/src"),
|
||||||
|
|||||||
Reference in New Issue
Block a user