Remove serialization from domain
This commit is contained in:
@@ -54,7 +54,7 @@ describe("MuteList", () => {
|
|||||||
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.
|
||||||
const template = await undecrypted.getTemplate(signer)
|
const template = await undecrypted.toTemplate(signer)
|
||||||
|
|
||||||
expect(template.content).toBe(event.content)
|
expect(template.content).toBe(event.content)
|
||||||
})
|
})
|
||||||
@@ -74,15 +74,6 @@ describe("MuteList", () => {
|
|||||||
expect(rumor.content).not.toBe("")
|
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 () => {
|
it("throws on the wrong kind", async () => {
|
||||||
const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent
|
const event = {kind: FOLLOWS, tags: [], content: "", pubkey: a} as TrustedEvent
|
||||||
|
|
||||||
|
|||||||
@@ -61,12 +61,6 @@ describe("Profile", () => {
|
|||||||
expect(profile.display()).toBe(displayPubkey(pubkey))
|
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", () => {
|
it("throws on the wrong kind", () => {
|
||||||
expect(() => Profile.parse(makeEvent({kind: NOTE}))).toThrow()
|
expect(() => Profile.parse(makeEvent({kind: NOTE}))).toThrow()
|
||||||
})
|
})
|
||||||
|
|||||||
+34
-61
@@ -8,13 +8,24 @@ import {DomainObject} from "./base.js"
|
|||||||
const isValidTag = (tag: unknown): tag is string[] =>
|
const isValidTag = (tag: unknown): tag is string[] =>
|
||||||
Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string")
|
Array.isArray(tag) && tag.length > 0 && tag.every(v => typeof v === "string")
|
||||||
|
|
||||||
export type DecryptedTags = {
|
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[][]
|
privateTags: string[][]
|
||||||
// True when the private content was read (or there was none), false when we
|
// True when `privateTags` reflects the real (decrypted) private content. False
|
||||||
// hold ciphertext we couldn't decrypt. See `EncryptableList.isDecrypted`.
|
// means we're holding ciphertext we couldn't read, so private entries are
|
||||||
|
// unknown and must not be mutated.
|
||||||
decrypted: boolean
|
decrypted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const makeListValues = (values: Partial<ListValues> = {}): ListValues => ({
|
||||||
|
publicTags: [],
|
||||||
|
privateTags: [],
|
||||||
|
decrypted: true,
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read and decrypt the private tags stored in an event's content. Returns
|
* Read and decrypt the private tags stored in an event's content. Returns
|
||||||
* `decrypted: false` (and leaves `privateTags` empty) when there is encrypted
|
* `decrypted: false` (and leaves `privateTags` empty) when there is encrypted
|
||||||
@@ -24,7 +35,7 @@ export type DecryptedTags = {
|
|||||||
export const decryptListContent = async (
|
export const decryptListContent = async (
|
||||||
event: TrustedEvent,
|
event: TrustedEvent,
|
||||||
signer?: ISigner,
|
signer?: ISigner,
|
||||||
): Promise<DecryptedTags> => {
|
): Promise<Pick<ListValues, "privateTags" | "decrypted">> => {
|
||||||
// No private content to read.
|
// No private content to read.
|
||||||
if (!event.content) return {privateTags: [], decrypted: true}
|
if (!event.content) return {privateTags: [], decrypted: true}
|
||||||
|
|
||||||
@@ -41,46 +52,18 @@ export const decryptListContent = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EncryptableListParams = {
|
|
||||||
publicTags?: string[][]
|
|
||||||
privateTags?: string[][]
|
|
||||||
decrypted?: boolean
|
|
||||||
event?: TrustedEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for replaceable lists that carry public entries in tags and
|
* 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 as an encrypted JSON array in content (NIP-51 style). The
|
||||||
* private entries are decrypted to plaintext on `parse` and re-encrypted on
|
* private entries are decrypted to plaintext on `parse` and re-encrypted on
|
||||||
* `getTemplate`, so all in-between reads and writes are synchronous.
|
* `toTemplate`, so all in-between reads and writes are synchronous.
|
||||||
*
|
*
|
||||||
* Subclasses fix the `kind` and add domain-specific accessors (see
|
* Subclasses fix the `kind` and add domain-specific accessors (see
|
||||||
* `MuteList`). The generic tag mechanics live here.
|
* `MuteList`). The generic tag mechanics live here.
|
||||||
*/
|
*/
|
||||||
export abstract class EncryptableList extends DomainObject {
|
export abstract class EncryptableList extends DomainObject<ListValues> {
|
||||||
abstract readonly kind: number
|
constructor(values: Partial<ListValues> = {}, event?: TrustedEvent) {
|
||||||
|
super(makeListValues(values), event)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,25 +72,25 @@ export abstract class EncryptableList extends DomainObject {
|
|||||||
* throw.
|
* throw.
|
||||||
*/
|
*/
|
||||||
get isDecrypted() {
|
get isDecrypted() {
|
||||||
return this.decrypted
|
return this.values.decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All entries, merging public and (when decrypted) private tags. */
|
/** All entries, merging public and (when decrypted) private tags. */
|
||||||
getTags() {
|
getTags() {
|
||||||
return [...this.publicTags, ...this.privateTags]
|
return [...this.values.publicTags, ...this.values.privateTags]
|
||||||
}
|
}
|
||||||
|
|
||||||
getPublicTags() {
|
getPublicTags() {
|
||||||
return this.publicTags
|
return this.values.publicTags
|
||||||
}
|
}
|
||||||
|
|
||||||
getPrivateTags() {
|
getPrivateTags() {
|
||||||
return this.privateTags
|
return this.values.privateTags
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add one or more tags to the public (cleartext) entries. */
|
/** Add one or more tags to the public (cleartext) entries. */
|
||||||
addPublicTags(...tags: string[][]) {
|
addPublicTags(...tags: string[][]) {
|
||||||
this.publicTags = uniqTags([...this.publicTags, ...tags])
|
this.values.publicTags = uniqTags([...this.values.publicTags, ...tags])
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -116,17 +99,17 @@ export abstract class EncryptableList extends DomainObject {
|
|||||||
addPrivateTags(...tags: string[][]) {
|
addPrivateTags(...tags: string[][]) {
|
||||||
this.assertDecrypted()
|
this.assertDecrypted()
|
||||||
|
|
||||||
this.privateTags = uniqTags([...this.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. */
|
/** Remove every tag matching `pred` from both public and private entries. */
|
||||||
removeTagsBy(pred: (tag: string[]) => boolean) {
|
removeTagsBy(pred: (tag: string[]) => boolean) {
|
||||||
this.publicTags = this.publicTags.filter(t => !pred(t))
|
this.values.publicTags = this.values.publicTags.filter(t => !pred(t))
|
||||||
|
|
||||||
if (this.decrypted) {
|
if (this.values.decrypted) {
|
||||||
this.privateTags = this.privateTags.filter(t => !pred(t))
|
this.values.privateTags = this.values.privateTags.filter(t => !pred(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@@ -138,20 +121,20 @@ export abstract class EncryptableList extends DomainObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected assertDecrypted() {
|
protected assertDecrypted() {
|
||||||
if (!this.decrypted) {
|
if (!this.values.decrypted) {
|
||||||
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
throw new Error("Cannot modify the private entries of a list that has not been decrypted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplate(signer?: ISigner): Promise<EventTemplate> {
|
async toTemplate(signer?: ISigner): Promise<EventTemplate> {
|
||||||
const tags = this.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, so a
|
||||||
// pass-through round trip doesn't destroy private entries we can't read.
|
// 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.decrypted) {
|
if (this.values.decrypted) {
|
||||||
if (this.privateTags.length === 0) {
|
if (this.values.privateTags.length === 0) {
|
||||||
content = ""
|
content = ""
|
||||||
} else {
|
} else {
|
||||||
if (!signer) {
|
if (!signer) {
|
||||||
@@ -160,20 +143,10 @@ export abstract class EncryptableList extends DomainObject {
|
|||||||
|
|
||||||
const pubkey = await signer.getPubkey()
|
const pubkey = await signer.getPubkey()
|
||||||
|
|
||||||
content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags))
|
content = await signer.nip44.encrypt(pubkey, JSON.stringify(this.values.privateTags))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {kind: this.kind, tags, content}
|
return {kind: this.kind, tags, content}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {
|
|
||||||
kind: this.kind,
|
|
||||||
publicTags: this.publicTags,
|
|
||||||
privateTags: this.privateTags,
|
|
||||||
decrypted: this.decrypted,
|
|
||||||
event: this.event,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class MuteList extends EncryptableList {
|
|||||||
|
|
||||||
const {privateTags, decrypted} = await decryptListContent(event, signer)
|
const {privateTags, decrypted} = await decryptListContent(event, signer)
|
||||||
|
|
||||||
return new MuteList({event, publicTags: event.tags, privateTags, decrypted})
|
return new MuteList({publicTags: event.tags, privateTags, decrypted}, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The muted pubkeys, merging public and (when decrypted) private entries. */
|
/** The muted pubkeys, merging public and (when decrypted) private entries. */
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const displayPubkey = (pubkey: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A kind-0 profile. Profile data lives unencrypted in the event content as
|
* 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
|
* JSON, so this is the simplest kind of domain object — `toTemplate` ignores
|
||||||
* the signer and only `toEvent`/`toRumor` need one (to sign).
|
* the signer and only `toEvent`/`toRumor` need one (to sign).
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -53,19 +53,14 @@ export const displayPubkey = (pubkey: string) => {
|
|||||||
* profile.set({about: "hello"})
|
* profile.set({about: "hello"})
|
||||||
* const signed = await profile.toEvent(signer)
|
* const signed = await profile.toEvent(signer)
|
||||||
*/
|
*/
|
||||||
export class Profile extends DomainObject {
|
export class Profile extends DomainObject<ProfileValues> {
|
||||||
readonly kind = PROFILE
|
readonly kind = PROFILE
|
||||||
readonly event?: TrustedEvent
|
|
||||||
|
|
||||||
values: ProfileValues
|
|
||||||
|
|
||||||
constructor(values: Partial<ProfileValues> = {}, event?: TrustedEvent) {
|
constructor(values: Partial<ProfileValues> = {}, event?: TrustedEvent) {
|
||||||
super()
|
super(makeProfileValues(values), event)
|
||||||
|
|
||||||
this.values = makeProfileValues(values)
|
|
||||||
this.event = event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a profile from (optional) values. */
|
||||||
static make(values: Partial<ProfileValues> = {}) {
|
static make(values: Partial<ProfileValues> = {}) {
|
||||||
return new Profile(values)
|
return new Profile(values)
|
||||||
}
|
}
|
||||||
@@ -102,16 +97,12 @@ export class Profile extends DomainObject {
|
|||||||
return fallback.trim()
|
return fallback.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTemplate(): Promise<EventTemplate> {
|
async toTemplate(): Promise<EventTemplate> {
|
||||||
return {
|
return {
|
||||||
kind: PROFILE,
|
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).
|
// Preserve any tags from the source event (e.g. nip05/relay hints).
|
||||||
tags: this.event?.tags || [],
|
tags: this.event?.tags || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
return {...this.values}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-23
@@ -5,49 +5,53 @@ import type {ISigner} from "@welshman/signer"
|
|||||||
/**
|
/**
|
||||||
* The base class for domain objects.
|
* The base class for domain objects.
|
||||||
*
|
*
|
||||||
* A domain object is an in-memory, mutable, JSON-serializable view of a single
|
* A domain object is an in-memory, mutable view of a single nostr event whose
|
||||||
* nostr event. The pattern is "decrypt on parse, mutate in memory, encrypt on
|
* state lives in a plain `values` property. The pattern is "decrypt on parse,
|
||||||
* serialize": concrete subclasses decrypt private content up front (in their
|
* mutate in memory, encrypt on serialize": concrete subclasses decrypt private
|
||||||
* static `parse`), expose synchronous accessors and mutators over the
|
* content up front (in their static `parse`), expose synchronous accessors and
|
||||||
* plaintext, and only touch the signer again when building an event.
|
* mutators over `values`, and only touch the signer again when building an
|
||||||
*
|
* event. Subclasses provide:
|
||||||
* Subclasses provide:
|
|
||||||
*
|
*
|
||||||
* - a static `parse(event, signer?)` that reads (and, when possible, decrypts)
|
* - a static `parse(event, signer?)` that reads (and, when possible, decrypts)
|
||||||
* an event into a domain object
|
* an event into a domain object
|
||||||
* - `getTemplate(signer?)` that builds (and, when needed, encrypts) the event
|
* - `toTemplate(signer?)` that builds (and, when needed, encrypts) the event
|
||||||
* template — the signer is optional for objects with no private content
|
* 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
|
* The base provides the signing/wrapping orchestration on top of `toTemplate`.
|
||||||
* `getTemplate`.
|
|
||||||
*/
|
*/
|
||||||
export abstract class DomainObject {
|
export abstract class DomainObject<V> {
|
||||||
|
/** The nostr event kind this object maps to. */
|
||||||
|
abstract readonly kind: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* The source event, present when this object was parsed from one and absent
|
||||||
* when it was freshly constructed.
|
* when it was made fresh.
|
||||||
*/
|
*/
|
||||||
abstract readonly event?: TrustedEvent
|
readonly event?: TrustedEvent
|
||||||
|
|
||||||
|
constructor(values: V, event?: TrustedEvent) {
|
||||||
|
this.values = values
|
||||||
|
this.event = event
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the event template for this object, encrypting any private content
|
* Build the event template for this object, encrypting any private content
|
||||||
* with the signer. Subclasses that hold no private data may ignore the
|
* with the signer. Subclasses that hold no private data may ignore the
|
||||||
* signer (which is why it is optional).
|
* signer (which is why it is optional).
|
||||||
*/
|
*/
|
||||||
abstract getTemplate(signer?: ISigner): Promise<EventTemplate>
|
abstract toTemplate(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),
|
* 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.
|
* 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.getTemplate(signer), signer.getPubkey()])
|
const [template, pubkey] = await Promise.all([this.toTemplate(signer), signer.getPubkey()])
|
||||||
|
|
||||||
return prep(template, pubkey)
|
return prep(template, pubkey)
|
||||||
}
|
}
|
||||||
@@ -57,7 +61,7 @@ export abstract class DomainObject {
|
|||||||
* `created_at` is stamped so the result supersedes any prior version.
|
* `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.getTemplate(signer)
|
const template = await this.toTemplate(signer)
|
||||||
|
|
||||||
return signer.sign(stamp(template))
|
return signer.sign(stamp(template))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user