@@ -12,7 +12,7 @@ const c = "cc".repeat(32)
|
|||||||
|
|
||||||
describe("MuteList", () => {
|
describe("MuteList", () => {
|
||||||
it("round-trips public and private mutes through encryption", async () => {
|
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.pubkeys.sort()).toEqual([a, b].sort())
|
||||||
expect(list.includes(a)).toBe(true)
|
expect(list.includes(a)).toBe(true)
|
||||||
@@ -41,7 +41,7 @@ describe("MuteList", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("removes from both public and private entries", async () => {
|
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(a)
|
||||||
list.remove(b)
|
list.remove(b)
|
||||||
@@ -50,7 +50,7 @@ describe("MuteList", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("preserves undecrypted ciphertext on pass-through serialization", async () => {
|
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)
|
const undecrypted = await MuteList.parse(event)
|
||||||
|
|
||||||
// We never decrypted, so the original ciphertext must survive untouched.
|
// We never decrypted, so the original ciphertext must survive untouched.
|
||||||
@@ -60,14 +60,14 @@ describe("MuteList", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("refuses private mutation when undecrypted", async () => {
|
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)
|
const undecrypted = await MuteList.parse(event)
|
||||||
|
|
||||||
expect(() => undecrypted.addPrivately(c)).toThrow()
|
expect(() => undecrypted.addPrivately(c)).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("toRumor encrypts but does not sign", async () => {
|
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.id).toBeTruthy()
|
||||||
expect((rumor as TrustedEvent).sig).toBeUndefined()
|
expect((rumor as TrustedEvent).sig).toBeUndefined()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("Profile", () => {
|
|||||||
tags: [["alt", "profile"]],
|
tags: [["alt", "profile"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const profile = Profile.parse(event)
|
const profile = await Profile.parse(event)
|
||||||
|
|
||||||
expect(profile.values.name).toBe("alice")
|
expect(profile.values.name).toBe("alice")
|
||||||
expect(profile.hasName()).toBe(true)
|
expect(profile.hasName()).toBe(true)
|
||||||
@@ -41,27 +41,27 @@ describe("Profile", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("derives lnurl from a lud16 address", () => {
|
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()
|
expect(profile.values.lnurl).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("set merges and re-derives values", () => {
|
it("gets and sets values by key", () => {
|
||||||
const profile = Profile.make({name: "alice"})
|
const profile = Profile.init({name: "alice"})
|
||||||
|
|
||||||
profile.set({about: "hello"})
|
profile.set("about", "hello")
|
||||||
|
|
||||||
expect(profile.values.name).toBe("alice")
|
expect(profile.get("name")).toBe("alice")
|
||||||
expect(profile.values.about).toBe("hello")
|
expect(profile.get("about")).toBe("hello")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("display falls back to a shortened npub", () => {
|
it("display falls back to a shortened npub", async () => {
|
||||||
const profile = Profile.parse(makeEvent({content: "{}"}))
|
const profile = await Profile.parse(makeEvent({content: "{}"}))
|
||||||
|
|
||||||
expect(profile.display()).toBe(displayPubkey(pubkey))
|
expect(profile.display()).toBe(displayPubkey(pubkey))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("throws on the wrong kind", () => {
|
it("throws on the wrong kind", async () => {
|
||||||
expect(() => Profile.parse(makeEvent({kind: NOTE}))).toThrow()
|
await expect(Profile.parse(makeEvent({kind: NOTE}))).rejects.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+41
-54
@@ -10,12 +10,9 @@ const isValidTag = (tag: unknown): tag is string[] =>
|
|||||||
|
|
||||||
export type ListValues = {
|
export type ListValues = {
|
||||||
publicTags: string[][]
|
publicTags: string[][]
|
||||||
// Private entries as plaintext. Empty when there are none or when we couldn't
|
|
||||||
// decrypt them (see `decrypted`).
|
|
||||||
privateTags: string[][]
|
privateTags: string[][]
|
||||||
// True when `privateTags` reflects the real (decrypted) private content. False
|
// True when `privateTags` reflects decrypted content; false when we hold
|
||||||
// means we're holding ciphertext we couldn't read, so private entries are
|
// ciphertext we couldn't read (so private entries are unknown).
|
||||||
// unknown and must not be mutated.
|
|
||||||
decrypted: boolean
|
decrypted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,20 +23,14 @@ export const makeListValues = (values: Partial<ListValues> = {}): ListValues =>
|
|||||||
...values,
|
...values,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// Decrypt the private tags in an event's content. Returns decrypted: false when
|
||||||
* Read and decrypt the private tags stored in an event's content. Returns
|
// there's content but no signer, or decryption fails.
|
||||||
* `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 (
|
export const decryptListContent = async (
|
||||||
event: TrustedEvent,
|
event: TrustedEvent,
|
||||||
signer?: ISigner,
|
signer?: ISigner,
|
||||||
): Promise<Pick<ListValues, "privateTags" | "decrypted">> => {
|
): Promise<Pick<ListValues, "privateTags" | "decrypted">> => {
|
||||||
// No private content to read.
|
|
||||||
if (!event.content) return {privateTags: [], decrypted: true}
|
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}
|
if (!signer) return {privateTags: [], decrypted: false}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -52,60 +43,42 @@ export const decryptListContent = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Base for NIP-51 lists: public entries in tags, private entries as an encrypted
|
||||||
* Base class for replaceable lists that carry public entries in tags and
|
// JSON array in content. Subclasses fix the kind and add domain accessors.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export abstract class EncryptableList extends DomainObject<ListValues> {
|
export abstract class EncryptableList extends DomainObject<ListValues> {
|
||||||
constructor(values: Partial<ListValues> = {}, event?: TrustedEvent) {
|
values = makeListValues()
|
||||||
super(makeListValues(values), event)
|
|
||||||
|
protected normalizeValues(values: Partial<ListValues> = {}) {
|
||||||
|
return makeListValues(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected async parseEvent(event: TrustedEvent, signer?: ISigner): Promise<Partial<ListValues>> {
|
||||||
* Whether the private entries were successfully decrypted (or there were
|
const {privateTags, decrypted} = await decryptListContent(event, signer)
|
||||||
* none). When false, only public entries are available and private mutations
|
|
||||||
* throw.
|
return {publicTags: event.tags, privateTags, decrypted}
|
||||||
*/
|
|
||||||
get isDecrypted() {
|
|
||||||
return this.values.decrypted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All entries, merging public and (when decrypted) private tags. */
|
tags() {
|
||||||
getTags() {
|
|
||||||
return [...this.values.publicTags, ...this.values.privateTags]
|
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[][]) {
|
addPublicTags(...tags: string[][]) {
|
||||||
this.values.publicTags = uniqTags([...this.values.publicTags, ...tags])
|
this.values.publicTags = uniqTags([...this.values.publicTags, ...tags])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add one or more tags to the private (encrypted) entries. */
|
|
||||||
addPrivateTags(...tags: string[][]) {
|
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])
|
this.values.privateTags = uniqTags([...this.values.privateTags, ...tags])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove every tag matching `pred` from both public and private entries. */
|
keepTags(pred: (tag: string[]) => boolean) {
|
||||||
removeTagsBy(pred: (tag: string[]) => boolean) {
|
|
||||||
this.values.publicTags = this.values.publicTags.filter(t => !pred(t))
|
this.values.publicTags = this.values.publicTags.filter(t => !pred(t))
|
||||||
|
|
||||||
if (this.values.decrypted) {
|
if (this.values.decrypted) {
|
||||||
@@ -115,22 +88,36 @@ export abstract class EncryptableList extends DomainObject<ListValues> {
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove every tag whose value (index 1) equals `value`, public or private. */
|
keepTagsWithKey(key: string) {
|
||||||
removeTagsByValue(value: string) {
|
return this.keepTags(nthEq(0, key))
|
||||||
return this.removeTagsBy(nthEq(1, value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected assertDecrypted() {
|
keepTagsWithValue(value: string) {
|
||||||
if (!this.values.decrypted) {
|
return this.keepTags(nthEq(1, value))
|
||||||
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
}
|
||||||
|
|
||||||
|
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<EventTemplate> {
|
async toTemplate(signer?: ISigner): Promise<EventTemplate> {
|
||||||
const tags = this.values.publicTags
|
const tags = this.values.publicTags
|
||||||
|
|
||||||
// Preserve the original ciphertext when we never decrypted it, so a
|
// Preserve the original ciphertext when we never decrypted it.
|
||||||
// pass-through round trip doesn't destroy private entries we can't read.
|
|
||||||
let content = this.event?.content || ""
|
let content = this.event?.content || ""
|
||||||
|
|
||||||
if (this.values.decrypted) {
|
if (this.values.decrypted) {
|
||||||
|
|||||||
@@ -1,66 +1,29 @@
|
|||||||
import {uniq} from "@welshman/lib"
|
import {uniq} from "@welshman/lib"
|
||||||
import {MUTES, getPubkeyTagValues} from "@welshman/util"
|
import {MUTES, getPubkeyTagValues} from "@welshman/util"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import {EncryptableList} from "./List.js"
|
||||||
import type {ISigner} from "@welshman/signer"
|
|
||||||
import {EncryptableList, decryptListContent} from "./List.js"
|
|
||||||
|
|
||||||
/**
|
// NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (tags) or privately
|
||||||
* A NIP-51 kind-10000 mute list. Pubkeys can be muted publicly (in tags) or
|
// (encrypted content); the accessors treat both as one merged set.
|
||||||
* 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 {
|
export class MuteList extends EncryptableList {
|
||||||
readonly kind = MUTES
|
readonly kind = MUTES
|
||||||
|
|
||||||
/** Create an empty, decrypted mute list (e.g. for a user with none yet). */
|
pubkeys() {
|
||||||
static make() {
|
return uniq(getPubkeyTagValues(this.tags))
|
||||||
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({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) {
|
includes(pubkey: string) {
|
||||||
return this.pubkeys.includes(pubkey)
|
return this.pubkeys.includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mute a pubkey publicly (visible to anyone who reads the event). */
|
mutePublicly(pubkey: string) {
|
||||||
addPublicly(pubkey: string) {
|
|
||||||
return this.addPublicTags(["p", pubkey])
|
return this.addPublicTags(["p", pubkey])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mute a pubkey privately (stored in encrypted content). */
|
mutePrivately(pubkey: string) {
|
||||||
addPrivately(pubkey: string) {
|
|
||||||
return this.addPrivateTags(["p", pubkey])
|
return this.addPrivateTags(["p", pubkey])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unmute a pubkey, removing it from both public and private entries. */
|
unmute(pubkey: string) {
|
||||||
remove(pubkey: string) {
|
|
||||||
return this.removeTagsByValue(pubkey)
|
return this.removeTagsByValue(pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ export type ProfileValues = {
|
|||||||
display_name?: string
|
display_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Apply defaults, deriving `lnurl` from a `lud06` or `lud16` address.
|
||||||
* Normalize raw profile values, deriving `lnurl` from a `lud06` or `lud16`
|
|
||||||
* address when present.
|
|
||||||
*/
|
|
||||||
export const makeProfileValues = (values: Partial<ProfileValues> = {}): ProfileValues => {
|
export const makeProfileValues = (values: Partial<ProfileValues> = {}): ProfileValues => {
|
||||||
const result: ProfileValues = {...values}
|
const result: ProfileValues = {...values}
|
||||||
|
|
||||||
@@ -43,55 +40,50 @@ export const displayPubkey = (pubkey: string) => {
|
|||||||
return d.slice(0, 8) + "…" + d.slice(-5)
|
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<ProfileValues> {
|
export class Profile extends DomainObject<ProfileValues> {
|
||||||
readonly kind = PROFILE
|
readonly kind = PROFILE
|
||||||
|
values = makeProfileValues()
|
||||||
|
|
||||||
constructor(values: Partial<ProfileValues> = {}, event?: TrustedEvent) {
|
protected normalizeValues(values: Partial<ProfileValues> = {}) {
|
||||||
super(makeProfileValues(values), event)
|
return makeProfileValues(values)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a profile from (optional) values. */
|
protected parseEvent(event: TrustedEvent): Partial<ProfileValues> {
|
||||||
static make(values: Partial<ProfileValues> = {}) {
|
return parseJson(event.content) || {}
|
||||||
return new Profile(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a kind-0 event into a `Profile`. Throws on the wrong kind. */
|
name() {
|
||||||
static parse(event: TrustedEvent) {
|
return this.values.name || this.values.display_name
|
||||||
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. */
|
nip05() {
|
||||||
set(updates: Partial<ProfileValues>) {
|
return this.values.
|
||||||
this.values = makeProfileValues({...this.values, ...updates})
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the profile has a display-worthy name. */
|
lnurl() {
|
||||||
hasName() {
|
return this.values.
|
||||||
return Boolean(this.values.name || this.values.display_name)
|
}
|
||||||
|
|
||||||
|
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 = "") {
|
display(fallback = "") {
|
||||||
const {name, display_name} = this.values
|
const name = this.name()
|
||||||
|
|
||||||
if (name) return ellipsize(name, 60).trim()
|
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()
|
if (this.event) return displayPubkey(this.event.pubkey).trim()
|
||||||
|
|
||||||
return fallback.trim()
|
return fallback.trim()
|
||||||
@@ -101,7 +93,6 @@ export class Profile extends DomainObject<ProfileValues> {
|
|||||||
return {
|
return {
|
||||||
kind: this.kind,
|
kind: this.kind,
|
||||||
content: JSON.stringify(this.values),
|
content: JSON.stringify(this.values),
|
||||||
// Preserve any tags from the source event (e.g. nip05/relay hints).
|
|
||||||
tags: this.event?.tags || [],
|
tags: this.event?.tags || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-37
@@ -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
|
* 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,
|
* state lives in a plain `values` property. The pattern is "decrypt on parse,
|
||||||
* mutate in memory, encrypt on serialize": concrete subclasses decrypt private
|
* mutate in memory, encrypt on serialize": concrete subclasses decrypt private
|
||||||
* content up front (in their static `parse`), expose synchronous accessors and
|
* content up front (in `parse`), expose synchronous accessors and mutators over
|
||||||
* mutators over `values`, and only touch the signer again when building an
|
* `values`, and only touch the signer again when building an event.
|
||||||
* event. Subclasses provide:
|
|
||||||
*
|
*
|
||||||
* - a static `parse(event, signer?)` that reads (and, when possible, decrypts)
|
* There are two construction entry points, both of which populate `values` and
|
||||||
* an event into a domain object
|
* return `this`:
|
||||||
* - `toTemplate(signer?)` that builds (and, when needed, encrypts) the event
|
|
||||||
* template — the signer is optional for objects with no private content
|
|
||||||
*
|
*
|
||||||
* 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<V> {
|
export abstract class DomainObject<V extends Record<string, unknown>> {
|
||||||
/** The nostr event kind this object maps to. */
|
|
||||||
abstract readonly kind: number
|
abstract readonly kind: number
|
||||||
|
abstract values: V
|
||||||
|
event?: TrustedEvent
|
||||||
|
|
||||||
/**
|
static init<T extends DomainObject<Record<string, unknown>>>(
|
||||||
* The object's data. All accessors and mutators read and write through here.
|
this: new () => T,
|
||||||
*/
|
values?: Partial<T["values"]>,
|
||||||
values: V
|
): T {
|
||||||
|
return new this().init(values)
|
||||||
/**
|
|
||||||
* 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 parse<T extends DomainObject<Record<string, unknown>>>(
|
||||||
* Build the event template for this object, encrypting any private content
|
this: new () => T,
|
||||||
* with the signer. Subclasses that hold no private data may ignore the
|
event: TrustedEvent,
|
||||||
* signer (which is why it is optional).
|
signer?: ISigner,
|
||||||
*/
|
): Promise<T> {
|
||||||
|
return new this().parse(event, signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(values: Partial<V> = {}) {
|
||||||
|
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>): V
|
||||||
|
|
||||||
|
protected abstract parseEvent(
|
||||||
|
event: TrustedEvent,
|
||||||
|
signer?: ISigner,
|
||||||
|
): Partial<V> | Promise<Partial<V>>
|
||||||
|
|
||||||
abstract toTemplate(signer?: ISigner): Promise<EventTemplate>
|
abstract toTemplate(signer?: ISigner): Promise<EventTemplate>
|
||||||
|
|
||||||
/**
|
|
||||||
* 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> {
|
async toRumor(signer: ISigner): Promise<HashedEvent> {
|
||||||
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
|
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
|
||||||
|
|
||||||
return prep(template, pubkey)
|
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> {
|
async toEvent(signer: ISigner): Promise<SignedEvent> {
|
||||||
const template = await this.toTemplate(signer)
|
const template = await this.toTemplate(signer)
|
||||||
|
|
||||||
return signer.sign(stamp(template))
|
return signer.sign(stamp(template))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get<K extends keyof V>(key: K): V[K] {
|
||||||
|
return this.values[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
set<K extends keyof V>(key: K, value: V[K]) {
|
||||||
|
this.values[key] = value
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user