15 KiB
name, description
| name | description |
|---|---|
| welshman-domain | Use this skill when working with @welshman/domain: reading and building typed nostr events by kind — the Reader/Builder split (EventReader/EventBuilder, ListReader/ListBuilder) that sits between @welshman/util's raw event types and @welshman/app's data plugins. Covers Profile, NIP-51 lists (follows, mutes, pins, relays, bookmarks, topics, emoji, feeds, blossom), NIP-29 rooms, Flotilla relay/space membership, NIP-89 handlers, NIP-57/75 zaps, and content kinds (comment, thread, classified, poll, calendar, report). Use it to parse an event into domain getters, build/edit an event template, handle NIP-44 private-list encryption, or migrate from the old makeProfile/readProfile/makeList/readHandlers/Encryptable/makeRoom*Event helpers. |
welshman/domain — Typed Readers & Builders for Nostr Kinds
Overview
@welshman/domain translates nostr events to and from typed domain objects. For each event kind it ships a matched pair:
- a Reader — a read-only view over a
TrustedEventwith synchronous getters (profile.name(),followList.pubkeys()), and - a Builder — a mutable, chainable producer of an
EventTemplate(new ProfileBuilder().setName("alice")).
It sits one layer above @welshman/util (raw TrustedEvent/EventTemplate types, tag getters, kind constants) and one layer below @welshman/app (whose data plugins like Profiles/FollowLists use these readers as their eventToItem). The package is stateless: no stores, no network, no globals — just event → object and object → template.
This replaces the old free-function helpers that used to live in @welshman/util (makeProfile/readProfile, makeList/readList, readHandlers, Encryptable, makeRoom*Event). See the migration table below.
Installation
npm install @welshman/domain
# or
pnpm add @welshman/domain
Peer deps: @welshman/lib, @welshman/util, @welshman/signer, @welshman/feeds, and nostr-tools.
Core mental model
- Readers wrap an event; Builders produce a template. A Reader is
new Profile(event)(validated + parsed via the static factories); a Builder isnew ProfileBuilder()(or seeded from a reader). Each Reader knows itsbuilder(), each Builder optionally takes areader. - Reading is async; getters are sync. You enter through
await X.fromEvent(event, signer?)(which runsparse), then call plain synchronous getters. - Building is chainable; output is async. Setters return
this; you finish withawait b.toTemplate(signer?)/toRumor(signer)/toEvent(signer). - The signer is optional and lazy. Most kinds ignore it. Only NIP-51 lists need it — to decrypt private tags on read, and to encrypt them (NIP-44, self-encrypted) on build. Callers can always pass a signer; the kind decides whether it matters.
- Round-trips preserve unknown tags. Build a Builder from a Reader and any tags the class doesn't model are carried through verbatim into the rebuilt template.
Reading an event
EventReader exposes two static entry points. Both validate event.kind === reader.kind (throwing Expected a kind X event, got kind Y) and run the kind's parse.
import {Profile, FollowList} from "@welshman/domain"
// One-shot read (the usual entry point):
const profile = await Profile.fromEvent(event) // no signer needed
const follows = await FollowList.fromEvent(event, signer) // signer decrypts private tags
// Reusable, class-bound factory over a fixed signer — point-free friendly:
const toProfile = Profile.factory(signer) // (event) => Promise<Profile>
const profile2 = await toProfile(someEvent)
factory(signer?) returns an async (event) => Promise<Reader>. It's the shape @welshman/app plugins want for their eventToItem.
Common base getters available on every reader (all synchronous): id(), author(), content(), tags(), createdAt(), identifier() (d-tag), address() (kind:pubkey:d), group() (NIP-29 h-tag), protect() (has ["-"]), expiration(). Each kind adds its own — e.g. profile.name(), profile.display(), followList.pubkeys(), followList.includes(pk).
Building / editing an event
import {ProfileBuilder, FollowListBuilder} from "@welshman/domain"
// Build from scratch:
const template = await new ProfileBuilder()
.setName("alice")
.setAbout("hi")
.toTemplate() // EventTemplate {kind, content, tags}
// Edit an existing event (seed the builder from a reader):
const reader = await FollowList.fromEvent(event, signer)
const signed = await reader.builder() // === new FollowListBuilder(reader)
.addFollow(["p", pubkey])
.toEvent(signer) // SignedEvent
- Construct empty (
new XBuilder()) or from a reader (new XBuilder(reader), orreader.builder()). - Base behavior setters (chainable):
setContent,setGroup/clearGroup(h-tag),setProtected(bool),setExpiration/clearExpiration,setIdentifier/clearIdentifier(d-tag, defaults to a random id). Each kind adds its own setters. - Output methods (all async):
toTemplate(signer?)→EventTemplate;toRumor(signer)→HashedEvent(needs signer);toEvent(signer)→SignedEvent(signs + stamps, needs signer).
Round-trip / extra-tag passthrough. When a builder is seeded from a reader, every tag in event.tags starts in extraTags. The base constructor lifts out h/-/expiration/d; each subclass lifts the tags it models (via consumeTags). Whatever is left is re-emitted unchanged by toTemplate ([...implTags, ...behaviorTags, ...extraTags]), so unmodeled tags survive an edit.
Async & signer notes
- Async surface:
fromEvent, the function returned byfactory, and all oftoTemplate/toRumor/toEventare async. Every getter and every setter is synchronous. - Reading private list tags: a
ListReaderonly surfacesprivateTagswhen you pass the author's own signer tofromEvent/factory(it decrypts NIP-44 content only whensigner.getPubkey() === event.pubkey). Decryption failures are swallowed —decryptedstaysfalseand private tags stay empty. - Writing private list tags:
ListBuilder.buildContentis where encryption happens. If there are private tags it requires a signer and NIP-44-encrypts them to the author's own pubkey (A signer is required to encrypt private tags). If the source was never decrypted, the original ciphertext is preserved untouched (so you don't clobber tags you couldn't see). toTemplate(signer?)needs a signer only for list kinds with non-empty private tags.toRumor/toEventalways need one. All other kinds ignore the signer entirely.d-tag required (basevalidatethrows otherwise) for parameterized-replaceable kinds:RelaySet(30002),Classified(30402),Feed(31890),TimeEvent(31923),HandlerRecommendation(31989),Handler(31990),RoomMeta(39000),RoomAdmins(39001),RoomMembers(39002). CallsetIdentifier().h-group required (subclassvalidatethrows):RoomDelete(9008),RoomJoin(9021),RoomLeave(9022). CallsetGroup(groupId).
Kind classes
Each row: kind# — NIP — Reader / Builder.
Profile
| Kind | NIP | Reader / Builder |
|---|---|---|
| 0 | NIP-01 | Profile / ProfileBuilder |
Profile: name, nip05, lnurl, about, banner, picture, website, display(fallback?). Builder: update, setName, setNip05, setAbout, setBanner, setPicture, setWebsite. (Also exports parseLnUrl, displayPubkey.)
Lists (ListReader/ListBuilder — public/private split, NIP-44 encryption)
| Kind | NIP | Reader / Builder |
|---|---|---|
| 3 | NIP-02 | FollowList / FollowListBuilder |
| 10000 | NIP-51 | MuteList / MuteListBuilder |
| 10001 | NIP-51 | PinList / PinListBuilder |
| 10002 | NIP-65 | RelayList / RelayListBuilder |
| 10003 | NIP-51 | BookmarkList / BookmarkListBuilder |
| 10004 | NIP-51 | GroupList / GroupListBuilder |
| 10006 | NIP-51 | BlockedRelayList / BlockedRelayListBuilder |
| 10007 | NIP-51 | SearchRelayList / SearchRelayListBuilder |
| 10009 | NIP-51 | RoomList / RoomListBuilder |
| 10014 | NIP-51 | FeedList / FeedListBuilder |
| 10015 | NIP-51 | TopicList / TopicListBuilder |
| 10030 | NIP-51 | EmojiList / EmojiListBuilder |
| 10050 | NIP-17 | MessagingRelayList / MessagingRelayListBuilder |
| 10063 | Blossom BUD-03 | BlossomServerList / BlossomServerListBuilder |
| 30002 | NIP-51 | RelaySet / RelaySetBuilder |
List builders share the ListBuilder mutators: addPublic/addPrivate, keepPublic/keepPrivate/keep, dropPublic/dropPrivate/drop, clearPublic/clearPrivate/clear. Each kind also exposes intent-named helpers, e.g. FollowListBuilder.addFollow/removeFollow, MuteListBuilder.mutePublicly/mutePrivately/unmute, RelayListBuilder.addUrl(url, mode)/setReadUrls/setWriteUrls.
Rooms (NIP-29)
Room ops are scoped by the h group tag (base setGroup); metadata kinds 39000–39002 are addressable per room.
| Kind | NIP | Reader / Builder |
|---|---|---|
| 9000 | NIP-29 | RoomAddMember / RoomAddMemberBuilder |
| 9001 | NIP-29 | RoomRemoveMember / RoomRemoveMemberBuilder |
| 9002 | NIP-29 | RoomEdit / RoomEditBuilder |
| 9007 | NIP-29 | RoomCreate / RoomCreateBuilder |
| 9008 | NIP-29 | RoomDelete / RoomDeleteBuilder |
| 9021 | NIP-29 | RoomJoin / RoomJoinBuilder |
| 9022 | NIP-29 | RoomLeave / RoomLeaveBuilder |
| 19004 | NIP-29 / Flotilla | RoomCreatePermission / RoomCreatePermissionBuilder |
| 39000 | NIP-29 | RoomMeta / RoomMetaBuilder |
| 39001 | NIP-29 | RoomAdmins / RoomAdminsBuilder |
| 39002 | NIP-29 | RoomMembers / RoomMembersBuilder |
RoomMeta: name, about, picture, pictureMeta, isClosed/isHidden/isPrivate/isRestricted/hasLivekit. RoomEdit mirrors it but its livekit getter is named livekit(). RoomJoin: code() (reads the claim tag), reason(); builder setCode/setReason.
Relay membership (Flotilla "spaces" — relay-level, NIP-29-adjacent)
| Kind | NIP | Reader / Builder |
|---|---|---|
| 8000 | Flotilla | RelayAddMember / RelayAddMemberBuilder |
| 8001 | Flotilla | RelayRemoveMember / RelayRemoveMemberBuilder |
| 13534 | Flotilla | RelayMembers / RelayMembersBuilder |
| 28934 | Flotilla | RelayJoin / RelayJoinBuilder |
| 28935 | NIP-29 | RelayInvite / RelayInviteBuilder |
| 28936 | Flotilla | RelayLeave / RelayLeaveBuilder |
Handlers (NIP-89)
| Kind | NIP | Reader / Builder |
|---|---|---|
| 31989 | NIP-89 | HandlerRecommendation / HandlerRecommendationBuilder |
| 31990 | NIP-89 | Handler / HandlerBuilder |
Handler: JSON content → values: HandlerMeta; getters name, about, picture, website, lud16, nip05, kinds(); builder setName/…/setKinds(number[]). Exports type HandlerMeta.
Zaps (NIP-57 / NIP-75)
| Kind | NIP | Reader / Builder |
|---|---|---|
| 9041 | NIP-75 | ZapGoal / ZapGoalBuilder |
| 9734 | NIP-57 | ZapRequest / ZapRequestBuilder |
| 9735 | NIP-57 | ZapReceipt / ZapReceiptBuilder |
ZapRequest: amount, lnurl, recipient, eventId, urls, comment. ZapReceipt: bolt11, invoiceAmount, request, sender, recipient, eventId, comment, preimage, plus verify(zapper).
Content
| Kind | NIP | Reader / Builder |
|---|---|---|
| 11 | NIP-7D | Thread / ThreadBuilder |
| 1018 | NIP-88 | PollResponse / PollResponseBuilder |
| 1068 | NIP-88 | Poll / PollBuilder |
| 1111 | NIP-22 | Comment / CommentBuilder |
| 1984 | NIP-56 | Report / ReportBuilder |
| 30402 | NIP-99 | Classified / ClassifiedBuilder |
| 31890 | NIP-51 | Feed / FeedBuilder |
| 31923 | NIP-52 | TimeEvent / TimeEventBuilder |
Comment: root()/parent(); builder setRoot/setParent/setRootFromEvent/setParentFromEvent. Poll: title, options, pollType, endsAt, isClosed, urls, plus results(responses); builder addOption, setPollType, setEndsAt. Exported types: CommentRef, ClassifiedPrice, PollType, PollOption, PollResult.
Gotchas
- Private list tags need the author's signer.
await FollowList.fromEvent(event)with no signer (or someone else's) yields only public tags;decryptedstaysfalse. Pass the author's own signer to see private entries. - Don't clobber undecryptable lists. If you edit a list you couldn't decrypt and try to write private tags,
validate()throwsUnable to modify list when decryption was not performed. Editing only public tags is fine — the original ciphertext is preserved. toRumor/toEventalways need a signer, even for kinds whosetoTemplatedoesn't (they callgetPubkey()/sign).- Parameterized-replaceable kinds throw without a
dtag — callsetIdentifier()(or let it default to a random id). Room ops scoped byhthrow withoutsetGroup. RoomJoin.code()reads theclaimtag (accessor name ≠ tag key);RelayJoin/RelayInviteuseclaim().- No top-level free functions beyond
parseLnUrlanddisplayPubkey. The h/group tag is handled bysetGroup/group(), not a helper. Don't inventmakeX/readXhelpers — those were removed (see below).
Moved from @welshman/util
These helpers used to live in @welshman/util and have been replaced by Reader/Builder classes here:
Old (@welshman/util) |
New (@welshman/domain) |
|---|---|
readProfile(event) |
await Profile.fromEvent(event) |
makeProfile({...}) / editing |
new ProfileBuilder().setName(...)….toTemplate() |
readList(event, signer) |
await FollowList.fromEvent(event, signer) (or the kind-specific list reader) |
makeList({...}) |
new FollowListBuilder().addFollow(...)….toTemplate(signer) |
readHandlers(event) |
await Handler.fromEvent(event) |
Encryptable (manual private-tag encrypt) |
ListBuilder.buildContent — addPrivate(...) then toTemplate(signer) (NIP-44, self) |
makeRoomMetaEvent / makeRoomEditEvent / makeRoomJoinEvent / … |
RoomMetaBuilder / RoomEditBuilder / RoomJoinBuilder / … .toTemplate() |
The shape of the change: free functions that returned/consumed raw events became classes with sync getters and chainable setters, and private-list encryption moved from an explicit Encryptable wrapper into the builder's async buildContent.
Related skills
welshman-util— the rawTrustedEvent/EventTemplatetypes, kind constants, and tag getters these classes are built on.welshman-app— the instance-based app layer whose data plugins (Profiles,FollowLists,MuteLists, …) use these readers aseventToItem.welshman-signer— theISignerinterface and NIP-44decrypt/encryptused for private list tags and fortoRumor/toEvent.