Fix NIP conformance in domain kinds; add domain docs/skill
tests / tests (push) Failing after 5m8s

NIP fixes:
- RelayMembers (13534): use NIP-43 `member` tags (not `p`) and set the required
  NIP-70 `-` protected tag.
- Profile (kind 0): remove display-name support entirely (getter, setter, display()
  fallback, and the search weight).
- Comment (1111): A/a tags now carry a real address, not the event id.
- BlossomServerList (10063): normalize server URLs with normalizeUrl (HTTP), not
  normalizeRelayUrl (which forced wss://).
- HandlerRecommendation (31989): fix inverted removeRecommendation filter; add
  setSupportedKind()/supportedKind() for the NIP-89 d-tag.
- Report (1984): place the report-type string on the e tag (note reports) or p tag
  (profile reports); always emit the p tag.

Docs/skills:
- Add @welshman/domain docs (docs/domain/) and the welshman-domain skill.
- Prune @welshman/util docs/skill of the moved Profile/List/Handler/Encryptable
  helpers; register domain in the sidebar, index, and skills README.
- Apply accuracy fixes to the @welshman/app docs/skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsMjvv7krpZeHK1Njeneru
This commit is contained in:
2026-06-20 14:55:21 +00:00
parent e2a6ef21cd
commit 5b8fef5b23
29 changed files with 1382 additions and 606 deletions
+14 -4
View File
@@ -35,6 +35,20 @@ export default defineConfig({
{text: "Feeds & Search", link: "/app/feeds-and-search"},
],
},
{
text: "@welshman/domain",
link: "/domain/",
items: [
{text: "The App Reader/Builder model", link: "/domain/readers-and-builders"},
{text: "Profile", link: "/domain/profile"},
{text: "Lists", link: "/domain/lists"},
{text: "Rooms", link: "/domain/rooms"},
{text: "Relay Membership", link: "/domain/relay-membership"},
{text: "Handlers", link: "/domain/handlers"},
{text: "Zaps", link: "/domain/zaps"},
{text: "Content", link: "/domain/content"},
],
},
{
text: "@welshman/util",
link: "/util/",
@@ -45,11 +59,7 @@ export default defineConfig({
{text: "Events", link: "/util/events"},
{text: "Filters", link: "/util/filters"},
{text: "Tags", link: "/util/tags"},
{text: "Encryptable", link: "/util/encryptable"},
{text: "Relays", link: "/util/relay"},
{text: "Profiles", link: "/util/profile"},
{text: "Handlers", link: "/util/handlers"},
{text: "Lists", link: "/util/list"},
{text: "Zaps", link: "/util/zaps"},
{text: "Relay Auth", link: "/util/nip42"},
{text: "HTTP Auth", link: "/util/nip98"},
+5 -5
View File
@@ -15,7 +15,7 @@ profiles.one(pubkey) // Readable<Maybe<Profile>> — lazily loads
profiles.get(pubkey) // Maybe<Profile> — sync snapshot, no load
await profiles.load(pubkey) // explicit load (cached)
profiles.display(pubkey) // Projection<string> — display name (falls back to npub)
await profiles.publish(profile) // build & publish a profile event (kind 0)
await profiles.publish(values) // merge a partial values record over the current profile and publish (kind 0)
```
`profiles.display(pubkey).$` is the right thing to bind in a component for a user's name.
@@ -27,7 +27,7 @@ Kind-3 follow lists keyed by pubkey.
```typescript
const follows = app.use(FollowLists)
follows.one(pubkey) // Readable<Maybe<List>>
follows.one(pubkey) // Readable<Maybe<FollowList>>
await follows.follow(["p", otherPubkey]) // add a tag and publish to outbox
await follows.unfollow(otherPubkey) // remove and publish
```
@@ -39,7 +39,7 @@ Kind-10000 mute lists keyed by pubkey. Private entries are NIP-44 encrypted, so
```typescript
const mutes = app.use(MuteLists)
mutes.one(pubkey) // Readable<Maybe<PublishedList>>
mutes.one(pubkey) // Readable<Maybe<MuteList>>
await mutes.mutePublicly(["p", pubkey]) // public mute
await mutes.mutePrivately(["p", pubkey]) // encrypted mute
await mutes.unmute(pubkey)
@@ -79,7 +79,7 @@ await relayLists.setRelays(tags)
### Specialized relay lists
Each of these is a separate kind with the same shape (`urls(pubkey)`, `addRelay`, `removeRelay`, `setRelays`):
Each of these is a separate kind with the same shape (`urls(pubkey)`, `addUrl`, `removeUrl`, `setUrls`):
| Plugin | Kind | Purpose |
|---|---|---|
@@ -122,7 +122,7 @@ NIP-05 identifiers verified over HTTP, keyed by `name@domain`. Lookups are batch
const handles = app.use(Handles)
handles.forPubkey(pubkey) // Projection<Maybe<Handle>> — resolves via the profile's nip05
handles.display(nip05) // Projection<string>
handles.display(nip05) // string — displayable nip05
await handles.loadForPubkey(pubkey)
```
+2 -2
View File
@@ -51,8 +51,8 @@ const relaySearch = get(searches.relaySearch)
// A Search exposes both option objects and their values
profileSearch.searchValues("alice") // string[] — pubkeys; also fires a NIP-50 network search
profileSearch.searchOptions("alice") // PublishedProfile[]
profileSearch.getOption(pubkey) // PublishedProfile | undefined
profileSearch.searchOptions("alice") // Profile[]
profileSearch.getOption(pubkey) // Profile | undefined
```
Profile results are ranked by blending the Fuse score with the WoT score, so well-trusted matches surface first. An empty search term returns all options.
+178
View File
@@ -0,0 +1,178 @@
# Content
A grab-bag of content kinds: NIP-22 comments, NIP-7D forum threads, NIP-99 classifieds, NIP-52 calendar events, NIP-88 polls, and NIP-56 reports. Each is a plain `EventReader` / `EventBuilder` pair — see [Readers & Builders](./readers-and-builders) for the base pattern. The parameterized-replaceable kinds (`Classified`, `TimeEvent`) need a `d` tag (`setIdentifier()`).
## Comment (kind 1111)
NIP-22 comments distinguish the **thread root** (uppercase `E`/`A`/`K`/`P` tags) from the **immediate parent** (lowercase `e`/`a`/`k`/`p`). Both are read as a `CommentRef` (`{id?, address?, kind?, pubkey?}`).
```typescript
import {Comment, CommentBuilder} from "@welshman/domain"
import type {CommentRef} from "@welshman/domain"
const comment = await Comment.fromEvent(event)
comment.root() // CommentRef — uppercase tags (thread root)
comment.parent() // CommentRef — lowercase tags (immediate parent)
// Set refs explicitly...
const template = await new CommentBuilder()
.setContent("nice thread")
.setRoot(rootKind, rootId, rootPubkey)
.setParent(parentKind, parentId, parentPubkey)
.toTemplate()
// ...or derive them from events (uses the event's d tag as identifier)
await new CommentBuilder()
.setContent("reply")
.setRootFromEvent(rootEvent)
.setParentFromEvent(parentEvent)
.toEvent(signer)
```
::: warning Identifier quirk
When you pass an `identifier` to `setRoot`/`setParent`, the builder pushes an `A`/`a` tag whose value is the `id` you passed — not a constructed `kind:pubkey:identifier` address. Be aware of this if you read those tags back expecting a full address.
:::
## Thread (kind 11)
A NIP-7D forum thread root. Just a title plus the body content.
```typescript
import {Thread, ThreadBuilder} from "@welshman/domain"
const thread = await Thread.fromEvent(event)
thread.title() // "title" tag value
await new ThreadBuilder()
.setTitle("Welcome")
.setContent("Read the rules first.")
.toEvent(signer)
```
## Classified (kind 30402)
A NIP-99 marketplace listing. The price parses into a `ClassifiedPrice` (`{amount, currency, frequency}`), defaulting currency to `SAT`.
```typescript
import {Classified, ClassifiedBuilder} from "@welshman/domain"
import type {ClassifiedPrice} from "@welshman/domain"
const listing = await Classified.fromEvent(event)
listing.title() // "title" tag value
listing.summary() // "summary" tag value
listing.price() // ClassifiedPrice | undefined
listing.status() // "status" tag value
listing.images() // "image" tag values
listing.topics() // t-tag values
await new ClassifiedBuilder()
.setIdentifier() // required d tag for kind 30402
.setTitle("Bike for sale")
.setSummary("lightly used")
.setPrice(150, "USD", "") // amount, currency = "SAT", frequency = ""
.setStatus("active")
.setImages(["https://example.com/bike.jpg"])
.setTopics(["bikes", "forsale"])
.toEvent(signer)
```
## TimeEvent (kind 31923)
A NIP-52 time-based calendar event.
```typescript
import {TimeEvent, TimeEventBuilder} from "@welshman/domain"
const evt = await TimeEvent.fromEvent(event)
evt.title() // "title" tag value
evt.location() // "location" tag value
evt.start() // unix seconds as int, or undefined
evt.end() // unix seconds as int, or undefined
await new TimeEventBuilder()
.setIdentifier() // required d tag for kind 31923
.setTitle("Nostrica")
.setLocation("Costa Rica")
.setStart(startTs)
.setEnd(endTs)
.toEvent(signer)
```
When both `start` and `end` are set, `buildTags` auto-generates one `["D", dayIndex]` tag per day in `[start, end)` (day-bucket index tags), so the event is discoverable by day.
## Poll (kind 1068) and PollResponse (kind 1018)
NIP-88 polls. A `Poll` has a title (content), options, a type, and an optional close time. Types are exported as `PollType` (`"singlechoice" | "multiplechoice"`), options as `PollOption`, and tallies as `PollResult`.
```typescript
import {Poll, PollBuilder} from "@welshman/domain"
import type {PollType, PollOption, PollResult} from "@welshman/domain"
const poll = await Poll.fromEvent(event)
poll.title() // event.content (or "")
poll.options() // PollOption[] — {id, label}
poll.pollType() // PollType, default "singlechoice"
poll.endsAt() // unix seconds | undefined
poll.isClosed() // boolean (endsAt <= now)
poll.urls() // "relay" tag values
await new PollBuilder()
.setTitle("Favorite client?")
.addOption("Coracle") // id defaults to a random id
.addOption("Flotilla")
.setPollType("singlechoice")
.setEndsAt(closeTs)
.toEvent(signer)
```
`validate()` requires at least one option. To tally votes, pass the response events to `results`:
```typescript
const result: PollResult = poll.results(responseEvents)
result.options // [{id, label, votes}, …]
result.voters // number of distinct voters
```
`results` keeps only each pubkey's latest response, takes the first selection for single-choice polls, and the unique selections for multiple-choice.
A `PollResponse` is one voter's answer:
```typescript
import {PollResponse, PollResponseBuilder} from "@welshman/domain"
const response = await PollResponse.fromEvent(event)
response.pollId() // e-tag value
response.selections() // unique "response" tag values
await new PollResponseBuilder()
.setPollId(pollId)
.addSelection(optionId) // deduped
.toEvent(signer)
```
`PollResponse.validate()` requires a pollId.
## Report (kind 1984)
A NIP-56 report flags a pubkey and/or an event with a reason.
```typescript
import {Report, ReportBuilder} from "@welshman/domain"
const report = await Report.fromEvent(event)
report.reportedPubkey() // p-tag value
report.eventId() // e-tag value (tag[1])
report.reason() // e-tag reason (tag[2])
await new ReportBuilder()
.setReportedPubkey(pubkey)
.setEventId(noteId)
.setReason("spam")
.toEvent(signer)
```
`buildTags` emits `["p", pubkey]` and `["e", id, reason?]`.
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern, including `d`-tag validation for `Classified` and `TimeEvent`.
+59
View File
@@ -0,0 +1,59 @@
# Handlers
NIP-89 lets clients advertise which event kinds they can handle, and lets users recommend handlers to each other. `@welshman/domain` models both sides: `Handler` (the handler's own information) and `HandlerRecommendation` (a user pointing at a handler). Both are parameterized-replaceable, so their builders need a `d` tag (`setIdentifier()`). See [Readers & Builders](./readers-and-builders) for the base pattern.
## Handler information (kind 31990)
`Handler` carries a JSON metadata blob (name, about, picture, …) in its content plus the list of kinds it handles as `k` tags. The metadata shape is exported as `HandlerMeta`.
```typescript
import {Handler, HandlerBuilder} from "@welshman/domain"
import type {HandlerMeta} from "@welshman/domain"
const handler = await Handler.fromEvent(event)
handler.name() // string | undefined
handler.about() // string | undefined
handler.picture() // string | undefined
handler.website() // string | undefined
handler.lud16() // string | undefined
handler.nip05() // string | undefined
handler.kinds() // number[] — the k tags, as numbers
handler.values // the raw decoded HandlerMeta object
```
The builder seeds metadata from the reader and lifts the `k` tags into its own field. Setters mirror the getters; `setKinds` takes an array of kind numbers.
```typescript
const template = await new HandlerBuilder()
.setIdentifier() // required d tag for kind 31990
.setName("My Client")
.setAbout("a great nostr app")
.setKinds([1, 30023]) // writes ["k", "1"], ["k", "30023"]
.toTemplate()
```
Available setters: `setName`, `setAbout`, `setPicture`, `setWebsite`, `setLud16`, `setNip05`, `setKinds(kinds)`. `buildContent` re-serializes `values` to JSON; `buildTags` emits the kind tags.
## Handler recommendation (kind 31989)
`HandlerRecommendation` is a list of `a` tags pointing at handler events, optionally annotated with a relay hint and a platform marker (e.g. `"web"`).
```typescript
import {HandlerRecommendation, HandlerRecommendationBuilder} from "@welshman/domain"
const rec = await HandlerRecommendation.fromEvent(event)
rec.addressTags() // raw a-tags, e.g. [["a", "31990:pk:d", "wss://…", "web"]]
rec.addresses() // just the address values
rec.handlerAddress() // prefers the a-tag whose last element is "web", else the first → tag[1]
const template = await new HandlerRecommendationBuilder()
.setIdentifier() // required d tag for kind 31989
.addRecommendation("31990:pubkey:d", "wss://relay.example", "web")
.toTemplate()
```
`addRecommendation(address, relay?, platform?)` writes `["a", address, relay || "", platform || ""]` and is deduped by address. `removeRecommendation(address)` filters the address tags.
## See also
- [Readers & Builders](./readers-and-builders) — the base pattern, including `d`-tag validation for these parameterized-replaceable kinds.
+88
View File
@@ -0,0 +1,88 @@
# @welshman/domain
[![version](https://badgen.net/npm/v/@welshman/domain)](https://npmjs.com/package/@welshman/domain)
Stateless utilities for translating nostr events to and from domain objects. Where `@welshman/util` gives you the raw building blocks — events, tags, kind constants, tag getters — `@welshman/domain` gives you a typed, ergonomic object per kind: a `Profile` you can ask `.name()`, a `FollowList` you can ask `.pubkeys()`, a `ZapReceipt` you can `.verify()`. Each of those comes with a matching builder that turns edits back into a signable event template.
## The core idea: Readers and Builders
Every supported kind is modeled by a pair of classes:
- A **Reader** — a read-only view over a single `TrustedEvent`. You construct it from an event, and it decodes the content/tags into convenient getters (`profile.name()`, `list.pubkeys()`, `zap.amount()`). Readers are stateless: they hold the event and answer questions about it.
- A **Builder** — a mutable, chainable producer of an `EventTemplate`. You construct it empty (to author a new event) or from a Reader (to edit an existing one), apply setters, and finish with `toTemplate()` / `toRumor()` / `toEvent()`.
```typescript
import {Profile, ProfileBuilder} from "@welshman/domain"
// Read an event into a domain object
const profile = await Profile.fromEvent(event)
profile.name() // string | undefined
profile.display() // best-effort display name, falls back to a short npub
// Build a new event template
const template = await new ProfileBuilder()
.setName("alice")
.setAbout("hello nostr")
.toTemplate() // EventTemplate {kind, content, tags}
```
Readers and Builders are two halves of a round-trip. `reader.builder()` returns the matching builder pre-populated from the reader, so editing is just "read, mutate, rebuild":
```typescript
const next = await profile.builder().setName("alice2").toTemplate()
```
## Where it sits
`@welshman/domain` lives between `@welshman/util` and `@welshman/app`.
- It depends on `@welshman/util` for the primitives it wraps — kind constants (`PROFILE`, `FOLLOWS`, `RELAYS`, …), tag getters (`getTagValue`, `getPubkeyTagValues`, …), `Address`, `stamp`/`prep`, and the `TrustedEvent`/`EventTemplate` types. It depends on `@welshman/signer` for the `ISigner` interface (used to sign, and to decrypt/encrypt private list tags).
- `@welshman/app` is built on top of it. The reactive data plugins (`Profiles`, `FollowLists`, `MuteLists`, `RelayLists`, …) decode repository events into exactly these Reader objects and expose the Builders' setters as collection methods. If you have used `app.use(Profiles).one(pk)` and gotten a `Profile` back, that `Profile` is this package's `Profile`.
This means `@welshman/domain` is the right layer to reach for when you are working with events directly — parsing or constructing them — without the app's reactivity, networking, or repository. It has no module-level state and no side effects.
## Installation
```bash
npm install @welshman/domain
# or
pnpm add @welshman/domain
yarn add @welshman/domain
```
Peer dependencies: the welshman workspace packages it builds on (`@welshman/lib`, `@welshman/util`, `@welshman/signer`, and `@welshman/feeds` for the saved-feed kind), plus `nostr-tools`.
## A larger example
```typescript
import {FollowList, FollowListBuilder} from "@welshman/domain"
// Read — pass a signer to unlock private (encrypted) tags on lists.
// Without the author's own signer, only public tags are visible.
const list = await FollowList.fromEvent(event, signer)
list.pubkeys() // string[] of followed pubkeys
list.includes(somePubkey) // boolean
// Edit and sign in one chain. buildContent encrypts private tags
// (NIP-44, self-encrypted to the author) when there are any.
const signed = await list.builder()
.addFollow(["p", newPubkey])
.toEvent(signer) // SignedEvent
// Or start fresh
const template = await new FollowListBuilder()
.addFollow(["p", pubkeyA])
.addFollow(["p", pubkeyB])
.toTemplate()
```
## Pages
- [Readers & Builders](./readers-and-builders) — the `EventReader`/`EventBuilder` and `ListReader`/`ListBuilder` base classes in depth: construction, async parsing, getters/setters, the build pipeline, validation, extra-tag passthrough, and how list encryption works.
- [Profile](./profile) — kind-0 metadata (`Profile` / `ProfileBuilder`).
- [Lists](./lists) — NIP-51 public/private lists: follows, mutes, pins, bookmarks, relay sets, and friends.
- [Rooms](./rooms) — NIP-29 group rooms: metadata, membership, and the join/leave/create/delete ops.
- [Relay membership](./relay-membership) — Flotilla relay/space membership ops and snapshots.
- [Handlers](./handlers) — NIP-89 handler information and recommendations.
- [Zaps](./zaps) — NIP-57/NIP-75 zap requests, receipts, and goals.
- [Content](./content) — comments, threads, classifieds, calendar events, polls, and reports.
+115
View File
@@ -0,0 +1,115 @@
# Lists
NIP-51 lists — follows, mutes, pins, bookmarks, relay lists, and friends — all share one shape: a set of **public** tags and an optional set of **private** tags that are NIP-44-encrypted into the event's content, self-encrypted to the author. Every list kind is a thin subclass of `ListReader` / `ListBuilder`, so once you know the shared pattern you know all of them. The base classes are documented in depth in [Readers & Builders](./readers-and-builders); this page covers the per-kind getters and setters.
## The shared pattern
A `ListReader` exposes its tags as a merged view (`publicTags` + `privateTags`), and every getter reads through that merged view. **Private tags only decrypt when you pass the list author's own signer** to `fromEvent`/`factory`; reading someone else's list, or your own without a signer, yields public tags only.
```typescript
import {MuteList, MuteListBuilder} from "@welshman/domain"
// Read. Pass the author's signer to surface private (encrypted) entries.
const mutes = await MuteList.fromEvent(event, signer)
mutes.pubkeys() // both public and private mutes, when decrypted
mutes.includes(somePk) // boolean
// Build. Public vs private goes to different setters; encryption happens
// in buildContent when there are private tags.
const signed = await new MuteListBuilder()
.mutePublicly(pubkeyA)
.mutePrivately(pubkeyB)
.toEvent(signer) // toEvent/toRumor always pass the signer through
```
Every list builder inherits the base tag mutators from `ListBuilder`:
```typescript
builder
.addPublic(...tags) // append to the public set
.addPrivate(...tags) // append to the private (encrypted) set
.keepPublic(pred) // filter; also keepPrivate, keep (both sets)
.dropPublic(pred) // filter out; also dropPrivate, drop (both sets)
.clearPublic() // empty; also clearPrivate, clear (both sets)
```
The per-kind methods below (`addFollow`, `mutePrivately`, `bookmarkPublicly`, …) are just named wrappers over these. Anything ending in `*Privately` is `addPrivate`; everything else is `addPublic`; `remove*`/`un*` is a `drop`.
::: tip toTemplate and the signer
Because encryption lives in `ListBuilder.buildContent`, `toTemplate(signer)` only needs a signer when you have actually written private tags. With private mutes/follows you must pass one (`A signer is required to encrypt private tags`); a purely public edit needs none. `toEvent`/`toRumor` always pass the signer through for you.
:::
## The kinds
| Class | Kind | NIP | Reader getters | Builder methods |
|---|---|---|---|---|
| `FollowList` | 3 | NIP-02 | `pubkeys()`, `includes(pk)` | `addFollow(tag)`, `removeFollow(value)` |
| `MuteList` | 10000 | NIP-51 | `pubkeys()`, `includes(pk)` | `mutePublicly(pk)`, `mutePrivately(pk)`, `unmute(pk)` |
| `PinList` | 10001 | NIP-51 | `ids()`, `addresses()` | `pinPublicly(tag)`, `pinPrivately(tag)`, `unpin(value)` |
| `RelayList` | 10002 | NIP-65 | `urls()`, `readUrls()`, `writeUrls()` | `addUrl(url, mode)`, `removeUrl(url, mode)`, `setReadUrls(urls)`, `setWriteUrls(urls)`, `setTags(tags)` |
| `BookmarkList` | 10003 | NIP-51 | `ids()`, `addresses()`, `topics()`, `urls()` | `bookmarkPublicly(tag)`, `bookmarkPrivately(tag)`, `removeBookmark(value)` |
| `GroupList` | 10004 | NIP-51 | `addresses()` | `addGroup(address, relayHint?)`, `removeGroup(address)` |
| `BlockedRelayList` | 10006 | NIP-51 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `SearchRelayList` | 10007 | NIP-51 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `RoomList` | 10009 | NIP-51 | `groups()`, `groupTags()` | `join(groupId, url)`, `leave(groupId)` |
| `FeedList` | 10014 | NIP-51 | `addresses()`, `includes(address)` | `addFeed(address, relayHint?)`, `addFeedPrivately(address, relayHint?)`, `removeFeed(address)` |
| `TopicList` | 10015 | NIP-51 | `topics()`, `addresses()`, `includes(topic)` | `followPublicly(topic)`, `followPrivately(topic)`, `follow(topic)`, `unfollow(topic)` |
| `EmojiList` | 10030 | NIP-51 | `emojis()`, `emojiSets()` | `addEmoji(shortcode, url)`, `removeEmoji(value)`, `addEmojiSet(address)`, `removeEmojiSet(value)` |
| `MessagingRelayList` | 10050 | NIP-17 | `urls()` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `BlossomServerList` | 10063 | Blossom BUD-03 | `urls()`, `includes(url)` | `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
| `RelaySet` | 30002 | NIP-51 | `title()`, `description()`, `image()`, `urls()` | `setTitle`, `setDescription`, `setImage`, `addUrl(url)`, `removeUrl(url)`, `setUrls(urls)` |
Each comes with a matching `*Builder` (`FollowListBuilder`, `MuteListBuilder`, …).
## Public vs private (encrypted) tags
A few of these kinds expose a deliberate public/private choice, mapping to the encrypted-content split:
```typescript
import {FollowListBuilder, BookmarkListBuilder, TopicListBuilder} from "@welshman/domain"
// Follows are public-only in practice (addFollow → addPublic)
new FollowListBuilder().addFollow(["p", pubkey])
// Bookmarks, pins, mutes, feeds, and topics offer both:
new BookmarkListBuilder().bookmarkPublicly(["e", noteId]) // visible to everyone
new BookmarkListBuilder().bookmarkPrivately(["e", noteId]) // encrypted, author-only
new TopicListBuilder().followPrivately("nostr") // encrypted interest
```
The relay-config lists (`RelayList`, `BlockedRelayList`, `SearchRelayList`, `MessagingRelayList`, `BlossomServerList`, `RelaySet`) keep everything in public tags — there is no private variant of `addUrl`.
## Notes per family
**`RelayList` (NIP-65).** Read/write are encoded by the `r`-tag's third element; a bare `["r", url]` counts as both. `urls()` returns all, `readUrls()`/`writeUrls()` filter by `RelayMode`. On the builder, `addUrl(url, mode)`/`removeUrl(url, mode)` preserve the *other* mode if it was present, so flipping write on a read-only relay produces a bare both-mode tag rather than clobbering it. URLs are normalized via `normalizeRelayUrl`.
```typescript
import {RelayListBuilder} from "@welshman/domain"
import {RelayMode} from "@welshman/util"
await new RelayListBuilder()
.addUrl("wss://relay.example", RelayMode.Write)
.addUrl("wss://read.example", RelayMode.Read)
.toEvent(signer)
```
**Relay/server set lists.** `BlockedRelayList`, `SearchRelayList`, and `MessagingRelayList` store URLs under the `relay` tag key; `BlossomServerList` uses the `server` key instead. All four share `addUrl`/`removeUrl`/`setUrls` (where `setUrls` clears then re-adds), with `normalizeRelayUrl` applied.
**`RelaySet` (kind 30002).** A named, addressable relay set — it is parameterized-replaceable, so the builder needs a `d` tag (`setIdentifier()`). Its constructor lifts `title`/`description`/`image` out of the tags into dedicated fields, and `buildTags` prepends them ahead of the `relay` tags.
```typescript
import {RelaySetBuilder} from "@welshman/domain"
await new RelaySetBuilder()
.setIdentifier() // required d tag for kind 30002
.setTitle("My relays")
.addUrl("wss://relay.example")
.toEvent(signer)
```
**`RoomList` (kind 10009).** A simple-groups membership list. `join(groupId, url)` writes `["group", groupId, url]`; `leave(groupId)` drops the matching tag. (NIP-29 room *operations* themselves live in [Rooms](./rooms).)
## See also
- [Readers & Builders](./readers-and-builders) — the `ListReader`/`ListBuilder` base, including exactly how private-tag encryption and decryption work.
- [Rooms](./rooms) — NIP-29 room ops, referenced by `RoomList`.
+81
View File
@@ -0,0 +1,81 @@
# Profile
`Profile` / `ProfileBuilder` model NIP-01 kind-0 metadata — the JSON blob that carries a user's name, picture, NIP-05, lightning address, and so on. Like every kind in `@welshman/domain`, it is a thin pair of classes over the [base Reader/Builder machinery](./readers-and-builders): a read-only view plus a chainable producer of an event template.
The content of a kind-0 event is a JSON object, so `Profile.parse` decodes it into a `values` record and the getters read fields off that.
## Reading
```typescript
import {Profile} from "@welshman/domain"
const profile = await Profile.fromEvent(event) // no signer needed — kind 0 is not encrypted
profile.name() // string | undefined
profile.displayName() // values.display_name
profile.about() // string | undefined
profile.picture() // string | undefined
profile.banner() // string | undefined
profile.website() // string | undefined
profile.nip05() // string | undefined
profile.lnurl() // lud16/lud06 → lnurl, via parseLnUrl
profile.values // the raw decoded JSON object
```
`display(fallback = "")` is the best-effort label you usually want in UI. It prefers `name`, then `display_name` (each truncated to 60 chars via `ellipsize`), and finally falls back to a shortened npub:
```typescript
profile.display() // "alice" · "npub1abc…wxyz" · fallback
profile.display("anonymous") // fallback used only when there is nothing else
```
## Building
Construct empty to author a new profile, or from a reader to edit one. Setters are chainable; finish with `toTemplate()` / `toEvent(signer)`.
```typescript
import {ProfileBuilder} from "@welshman/domain"
const template = await new ProfileBuilder()
.setName("alice")
.setAbout("hello nostr")
.setPicture("https://example.com/avatar.png")
.setNip05("alice@example.com")
.toTemplate() // EventTemplate {kind: 0, content, tags: []}
```
Editing round-trips through the reader. `buildContent` re-serializes `values` to JSON, so unknown profile fields you never touched are preserved:
```typescript
const signed = await profile.builder()
.setAbout("updated bio")
.toEvent(signer) // SignedEvent
```
Available setters: `setName`, `setDisplayName`, `setNip05`, `setAbout`, `setBanner`, `setPicture`, `setWebsite`, plus `update(values)` to merge an arbitrary object into `values`.
```typescript
new ProfileBuilder().update({name: "alice", lud16: "alice@walletofsatoshi.com"})
```
::: warning Asymmetric display name
`setDisplayName(x)` writes `values.displayName` (camelCase), but `displayName()` reads `values.display_name` (snake_case, the NIP-01 field). They are **not** symmetric — a value you set with `setDisplayName` will not be read back by `displayName()`. Use `update({display_name: x})` if you need the getter to see it.
:::
## Free functions
`Profile.ts` also exports two standalone helpers, used internally by the getters above but available on their own:
```typescript
import {parseLnUrl, displayPubkey} from "@welshman/domain"
// Resolve an lnurl from a metadata object: checks lud06 then lud16.
parseLnUrl({lud16: "alice@example.com"}) // string | undefined
// A short, human-readable npub: first 8 chars + "…" + last 5.
displayPubkey(pubkey) // "npub1abc…wxyz"
```
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern every kind shares.
+270
View File
@@ -0,0 +1,270 @@
# Readers & Builders
Every kind in `@welshman/domain` is a thin subclass of four base classes:
- `EventReader` — read-only view over one event.
- `EventBuilder` — mutable producer of an event template.
- `ListReader``EventReader` with a public/private (encrypted) tag split.
- `ListBuilder``EventBuilder` with the same split, and NIP-44 encryption baked into its build step.
Understanding these four classes means you understand every kind: the per-kind files (`Profile`, `FollowList`, `ZapReceipt`, …) only add getters and setters on top of the machinery described here.
## EventReader
A `Reader` wraps a single `TrustedEvent` and answers questions about it. It is abstract — each kind pins its `kind` and provides a `builder()` — but the construction, parsing, and base getters all live here.
### Construction: factory and fromEvent
You never call the constructor directly to get a parsed reader, because parsing is async. Use one of the two static entry points instead. Both validate the event's kind (throwing `Expected a kind X event, got kind Y` on mismatch) and then `await reader.parse(signer)` before handing the reader back.
```typescript
import {Profile} from "@welshman/domain"
// One-shot: the usual entry point.
const profile = await Profile.fromEvent(event)
const profile2 = await Profile.fromEvent(event, signer) // signer optional
// Reusable, class-bound factory over a fixed signer. Returns an
// async (event) => Promise<Reader>, safe to use point-free.
const toProfile = Profile.factory(signer)
const profile3 = await toProfile(event)
```
`factory(signer?)` is what `@welshman/app`'s data plugins use as their `eventToItem`: a single bound function that turns each incoming event into a parsed reader. Both `fromEvent` and the function `factory` returns are **async**.
The signer is always optional. A reader that does not need it (every non-list kind) simply ignores it, so callers can pass a signer unconditionally without knowing which kinds carry encrypted content.
### Async parse
```typescript
protected async parse(signer?: ISigner): Promise<void> {}
```
`parse` is the one async hook. The base implementation is a no-op; subclasses override it to decode whatever they need:
- `Profile.parse` JSON-parses `event.content` into a `values` object.
- `ZapReceipt.parse` decodes the embedded zap-request JSON out of the `description` tag.
- `ListReader.parse` decrypts the private tags (see below).
Because `parse` is the only async step, everything downstream — the getters — is synchronous.
### Getters
All base getters are synchronous reads over the wrapped event:
| Getter | Returns |
|---|---|
| `id()` | `event.id` |
| `author()` | `event.pubkey` |
| `content()` | `event.content` |
| `tags()` | `event.tags` (overridden by `ListReader` to merge public + private) |
| `createdAt()` | `event.created_at` |
| `identifier()` | the `d` tag value |
| `address()` | the replaceable address `kind:pubkey:d` (via `getAddress`) |
| `group()` | the NIP-29 `h` tag value |
| `protect()` | `true` if a `["-"]` tag is present |
| `expires()` | the parsed `expiration` tag as a number, or `undefined` |
```typescript
const reader = await SomeKind.fromEvent(event)
reader.author() // pubkey
reader.identifier() // d tag, if any
reader.expires() // number | undefined
```
Each subclass adds its own getters on top — `profile.name()`, `followList.pubkeys()`, `zapGoal.amount()`, and so on.
### builder()
```typescript
abstract builder(): EventBuilder<EventReader>
```
Every reader returns its matching builder, pre-populated from itself — internally just `new XBuilder(this)`. This is the read-edit-rebuild bridge:
```typescript
const edited = await profile.builder().setName("alice").toTemplate()
```
## EventBuilder
A `Builder` is a mutable, chainable producer of an `EventTemplate`. Construct it empty to author a new event, or from a reader to edit an existing one. Every setter returns `this`.
### Construction and extra-tag passthrough
```typescript
constructor(readonly reader?: Reader)
```
When you pass a reader, the builder seeds `content` from `reader.event.content` and copies **all** of `event.tags` into `extraTags`. It then *consumes* the tags it manages — `h`, `-`, `expiration`, and `d` — lifting each out of `extraTags` into a dedicated field (`groupTag`, `protectTag`, `expirationTag`, `identifierTag`).
Whatever remains in `extraTags` is **passed through verbatim** when the event is rebuilt. This is the extra-tag passthrough guarantee: tags the package does not model (or a subclass does not claim) survive an edit round-trip instead of being silently dropped.
```typescript
protected consumeTags(key: string): string[][]
```
Subclasses call `consumeTags` in their own constructors to lift the tags they understand out of the passthrough set. For example `RelaySetBuilder` consumes `title`/`description`/`image`, `CommentBuilder` consumes its root/parent ref tags, and `ListBuilder` takes over everything left as `publicTags`.
### Setters
The base behavior setters, all chainable:
```typescript
new SomeBuilder()
.setContent("…")
.setGroup(groupId) // h tag / clearGroup()
.setProtected(true) // ["-"] tag
.setExpiration(timestamp) // / clearExpiration()
.setIdentifier() // d tag (defaults to a random id) / clearIdentifier()
```
`setIdentifier(identifier = randomId())` defaults to a freshly generated id, which is what you want for new parameterized-replaceable events. Each subclass adds its own setters (`setName`, `addFollow`, `setAmount`, …) on top of these.
### The build pipeline
Subclasses customize the output by overriding two protected hooks (both may be async, both receive the optional signer) and `validate`:
```typescript
protected buildTags(signer?): MaybeAsync<string[][]> // default: [] — kind-specific tags
protected buildContent(signer?): MaybeAsync<string> // default: this.content
protected validate(): void // default: enforce d tag for replaceable kinds
```
`validate` by default throws `A d tag is required for kind X` for parameterized-replaceable kinds with no identifier. Subclasses call `super.validate()` and add their own checks — `ZapGoal requires a title`, `RoomDelete` requires an `h` group, and so on.
The three output methods assemble these into a result. All are async:
```typescript
const template = await builder.toTemplate(signer?) // EventTemplate {kind, content, tags}
const rumor = await builder.toRumor(signer) // HashedEvent (needs signer for pubkey)
const signed = await builder.toEvent(signer) // SignedEvent (signs + stamps)
```
`toTemplate` is the heart of it. It runs `validate()`, then awaits `buildContent`, `buildTags`, and the internal behavior tags in parallel, and concatenates the final tag list as:
```
[...implTags, ...behaviorTags, ...extraTags]
```
That ordering is the passthrough in action: kind-specific tags first, then the group/protect/expiration/identifier tags, then the untouched leftovers.
The signer rules:
- `toTemplate`'s signer is **optional** and only consulted by kinds whose `buildContent`/`buildTags` need it — in practice, the encrypting list kinds.
- `toRumor` and `toEvent` always **require** a signer (they need the author's pubkey, and `toEvent` signs).
```typescript
// Plain kind: no signer needed for a template
const template = await new ProfileBuilder().setName("alice").toTemplate()
// Any kind, signed
const event = await new ProfileBuilder().setName("alice").toEvent(signer)
```
## ListReader
NIP-51-style lists split their tags into a public set and a private (encrypted) set. `ListReader` extends `EventReader` and handles the decryption.
```typescript
decrypted = false
publicTags: string[][] = []
privateTags: string[][] = []
```
Its `parse` override:
1. Sets `publicTags = event.tags`.
2. If `event.content` is empty, there is nothing to decrypt → `decrypted = true`.
3. Otherwise, if a signer is supplied **and it belongs to the event's author** (`signer.getPubkey() === event.pubkey`), it decrypts the content, marks `decrypted = true`, parses the JSON array, and keeps only well-formed string-tuple tags into `privateTags`. A decryption failure is swallowed — `decrypted` simply stays `false`.
The practical consequence: **private tags only appear when you pass the author's own signer** to `fromEvent`/`factory`. Reading someone else's list, or your own list without a signer, gives you the public tags only.
```typescript
const list = await MuteList.fromEvent(event, signer) // signer = the list author's
list.pubkeys() // includes both public and private mutes, when decrypted
```
`tags()` is overridden to return `[...publicTags, ...privateTags]`, so every inherited getter that reads `this.tags()` transparently sees the merged view.
## ListBuilder
`ListBuilder` extends `EventBuilder` with the same public/private split and a chainable set of tag mutators. Its constructor takes over the leftover `extraTags` as `publicTags` (`this.publicTags = this.extraTags.splice(0)`) and copies `privateTags` from the reader.
### Tag mutators
All chainable (return `this`):
```typescript
builder
.addPublic(...tags) // append to public set
.addPrivate(...tags) // append to private (encrypted) set
.keepPublic(pred) // filter public to matches; also keepPrivate, keep (both)
.dropPublic(pred) // filter out matches; also dropPrivate, drop (both)
.clearPublic() // empty the set; also clearPrivate, clear (both)
```
Subclasses build their domain methods on these. For instance `FollowListBuilder.addFollow(tag)` is `addPublic(tag)`, and `MuteListBuilder` exposes `mutePublicly` (public) vs `mutePrivately` (private).
### validate
```typescript
protected validate()
```
`ListBuilder.validate` throws `Unable to modify list when decryption was not performed` if the source event had encrypted content that was never decrypted (because you did not pass the author's signer) yet you are trying to write private tags. This guards against clobbering private data you could not read.
### buildContent: where encryption lives
This is the important part. In the old `@welshman/util` design, encryption was a separate `Encryptable` wrapper you composed around an event. In `@welshman/domain` it is folded directly into the list builder's `buildContent`:
```typescript
protected async buildContent(signer?: ISigner): Promise<string> {
// Preserve the original ciphertext when we never decrypted it.
if (this.reader?.decrypted === false) return this.reader.event.content
// No need to encrypt an empty array
if (this.privateTags.length === 0) return ""
if (!signer) {
throw new Error("A signer is required to encrypt private tags")
}
const pubkey = await signer.getPubkey()
return signer.nip44.encrypt(pubkey, JSON.stringify(this.privateTags))
}
```
Three branches:
1. **Never decrypted** — return the original ciphertext untouched. You can edit public tags on a list you could not decrypt without destroying its private contents.
2. **No private tags** — return `""`. Nothing to encrypt.
3. **Has private tags** — require a signer (else throw `A signer is required to encrypt private tags`), then `signer.nip44.encrypt(pubkey, JSON.stringify(privateTags))`. The encryption is **NIP-44, self-encrypted to the author's own pubkey**.
`buildTags` simply returns `publicTags`. Because encryption happens here and needs the signer, list kinds are the case where `toTemplate(signer)` actually uses its optional argument:
```typescript
import {MuteListBuilder} from "@welshman/domain"
// Private mute → buildContent encrypts, so toTemplate needs the signer
const template = await new MuteListBuilder()
.mutePrivately(targetPubkey)
.toTemplate(signer)
// toEvent/toRumor always pass the signer through for you
const signed = await new MuteListBuilder()
.mutePublicly(targetPubkey)
.toEvent(signer)
```
## Old API → new API
| Old (`@welshman/util` helpers) | New (`@welshman/domain`) |
|---|---|
| `readProfile(event)` / free `read*` functions | `await Profile.fromEvent(event)` (Reader per kind) |
| `editProfile(...)` / free `create*`/`edit*` functions | `new ProfileBuilder(reader?)``toTemplate()/toEvent()` |
| `Encryptable` wrapper around an event | `ListBuilder.buildContent` (NIP-44, self-encrypted) — no separate wrapper |
| manual `signer.nip44.encrypt(...)` for private list tags | `addPrivate(...)` then `toTemplate(signer)` |
| manual `decrypt(...)` to read private tags | pass the author's signer to `fromEvent`/`factory` |
| hand-built `{kind, content, tags}` templates | `builder.toTemplate(signer?)` |
+78
View File
@@ -0,0 +1,78 @@
# Relay membership
These kinds model **relay-level** membership — Flotilla's notion of joining a relay/space, distinct from NIP-29 room membership (which is scoped to a room by an `h` tag). Where [Rooms](./rooms) deal with groups hosted *on* a relay, these events deal with belonging to the relay itself. They are all plain `EventReader` / `EventBuilder` subclasses; see [Readers & Builders](./readers-and-builders) for the base pattern.
## Ops and snapshots
| Class | Kind | Purpose | Reader | Builder |
|---|---|---|---|---|
| `RelayJoin` | 28934 | join request (ephemeral) | `claim()`, `reason()` | `setClaim(claim)`, `setReason(reason)` |
| `RelayInvite` | 28935 | invite (NIP-29) | `claim()` | `setClaim(claim)` |
| `RelayLeave` | 28936 | leave marker (ephemeral) | — | — |
| `RelayAddMember` | 8000 | add a member | `pubkeys()` | `addPubkey(pk)` |
| `RelayRemoveMember` | 8001 | remove a member | `pubkeys()` | `addPubkey(pk)` |
| `RelayMembers` | 13534 | member-list snapshot | `pubkeys()`, `isMember(pk)` | `addPubkey(pk)`, `removePubkey(pk)` |
Each has a matching `*Builder` (`RelayJoinBuilder`, `RelayMembersBuilder`, …).
## Joining and leaving
A `RelayJoin` carries an optional claim code (the `claim` tag) and a free-text reason (the event content):
```typescript
import {RelayJoin, RelayJoinBuilder, RelayLeaveBuilder} from "@welshman/domain"
const join = await RelayJoin.fromEvent(event)
join.claim() // string | undefined — the "claim" tag value
join.reason() // event.content, or undefined when empty
await new RelayJoinBuilder()
.setClaim(inviteCode) // ["claim", inviteCode]
.setReason("hello") // becomes the content
.toEvent(signer)
// Leaving is a bare marker — no fields.
await new RelayLeaveBuilder().toEvent(signer)
```
`RelayInvite` (kind 28935) is the invite counterpart, carrying just a `claim`:
```typescript
import {RelayInviteBuilder} from "@welshman/domain"
await new RelayInviteBuilder().setClaim(inviteCode).toEvent(signer)
```
## Member management
`RelayAddMember` (8000) and `RelayRemoveMember` (8001) are admin ops that list affected pubkeys via `addPubkey` (deduped by pubkey):
```typescript
import {RelayAddMemberBuilder, RelayRemoveMemberBuilder} from "@welshman/domain"
await new RelayAddMemberBuilder()
.addPubkey(pubkeyA)
.addPubkey(pubkeyB)
.toEvent(signer)
await new RelayRemoveMemberBuilder().addPubkey(pubkeyA).toEvent(signer)
```
`RelayMembers` (13534) is the resulting member-list snapshot. Its reader answers membership questions; its builder supports both add and remove:
```typescript
import {RelayMembers, RelayMembersBuilder} from "@welshman/domain"
const members = await RelayMembers.fromEvent(event)
members.pubkeys() // string[]
members.isMember(pubkey) // boolean
await new RelayMembersBuilder()
.addPubkey(pubkeyA) // also removePubkey(pk)
.toEvent(signer)
```
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern.
- [Rooms](./rooms) — NIP-29 room-level (not relay-level) membership and metadata ops.
+118
View File
@@ -0,0 +1,118 @@
# Rooms
NIP-29 rooms are relay-hosted groups. `@welshman/domain` models the room metadata kinds plus the full set of moderation/membership operations. There are two flavors of event here:
- **Addressable metadata** (kinds 3900039002) — replaceable, identified by a `d` tag, written by the relay. These are the canonical state of a room.
- **Action ops** (kinds 90009022, 19004) — regular events scoped to a target room by the NIP-29 **`h` group tag** (set via the base `setGroup` / `clearGroup`, read via `group()`).
All of these are plain `EventReader` / `EventBuilder` subclasses — none of them are encrypted lists. See [Readers & Builders](./readers-and-builders) for the base pattern. The reactive `app.use(Rooms)` plugin in `@welshman/app` builds on these classes.
## Room metadata
`RoomMeta` (kind 39000) is the addressable metadata record. Because it is parameterized-replaceable, the builder needs a `d` tag (`setIdentifier()`).
```typescript
import {RoomMeta, RoomMetaBuilder} from "@welshman/domain"
const meta = await RoomMeta.fromEvent(event)
meta.name() // string | undefined
meta.about() // string | undefined
meta.picture() // tag[1]
meta.pictureMeta() // tag.slice(2) — imeta-style extras, or undefined
meta.isClosed() // boolean (presence of a ["closed"] tag)
meta.isHidden() // boolean
meta.isPrivate() // boolean
meta.isRestricted() // boolean
meta.hasLivekit() // boolean
const template = await new RoomMetaBuilder()
.setIdentifier("my-room")
.setName("General")
.setAbout("anything goes")
.setPicture("https://example.com/room.png")
.setClosed() // ["closed"]; pass false to clear
.toTemplate()
```
Flag setters (`setClosed`, `setHidden`, `setPrivate`, `setRestricted`, `setLivekit`) each take an optional boolean (default `true`) — passing `false` clears the tag. `setPicture(picture, meta = [])` appends the extra imeta elements.
`RoomEdit` (kind 9002) carries the **same metadata fields** but as an *action* event scoped via the `h` group tag rather than a `d` identifier — it is how a client requests a metadata change. Its reader/builder are identical to `RoomMeta`, with one naming difference: the livekit getter is `livekit()` here (vs `hasLivekit()` on `RoomMeta`).
```typescript
import {RoomEditBuilder} from "@welshman/domain"
await new RoomEditBuilder()
.setGroup(roomId) // h tag — which room to edit
.setName("Renamed")
.toEvent(signer)
```
## Admins and members (addressable)
`RoomAdmins` (39001) and `RoomMembers` (39002) are addressable p-tag lists.
```typescript
import {RoomAdmins, RoomMembers, RoomMembersBuilder} from "@welshman/domain"
const admins = await RoomAdmins.fromEvent(event)
admins.pubkeys() // string[]
const members = await RoomMembers.fromEvent(event)
members.members() // string[]
members.isMember(pubkey) // boolean
await new RoomMembersBuilder()
.setIdentifier(roomId)
.addMember(pubkeyA) // also removeMember(pk)
.toEvent(signer)
```
`RoomAdminsBuilder` exposes `addAdmin(pk)` / `removeAdmin(pk)`; `RoomMembersBuilder` exposes `addMember(pk)` / `removeMember(pk)`. Both dedupe by pubkey.
## Membership and lifecycle ops
These are scoped to a room by the `h` group tag. Several enforce its presence in `validate()`.
| Class | Kind | Purpose | Reader | Builder |
|---|---|---|---|---|
| `RoomCreate` | 9007 | create a room | — | — |
| `RoomDelete` | 9008 | delete a room | — | requires `h` group |
| `RoomJoin` | 9021 | request to join | `code()`, `reason()` | `setCode(code)`, `setReason(reason)`; requires `h` group |
| `RoomLeave` | 9022 | leave a room | — | requires `h` group |
| `RoomAddMember` | 9000 | add a member | `pubkeys()` | `addPubkey(pk)` |
| `RoomRemoveMember` | 9001 | remove a member | `pubkeys()` | `addPubkey(pk)` |
| `RoomCreatePermission` | 19004 | grant room-creation rights | `pubkeys()`, `canCreate(pk)` | `setPubkeys(pks)` |
`RoomCreate` and `RoomLeave`/`RoomDelete` have no extra fields beyond the base builder — they are marker events. The members ops (`RoomAddMember`, `RoomRemoveMember`) use `addPubkey` to accumulate the affected pubkeys (deduped); `RoomRemoveMember`'s p-tags list the pubkeys to remove.
```typescript
import {RoomJoinBuilder, RoomAddMemberBuilder, RoomCreateBuilder} from "@welshman/domain"
// Join request — code is the "claim" tag, reason is the content.
await new RoomJoinBuilder()
.setGroup(roomId)
.setCode(inviteCode) // ["claim", inviteCode]
.setReason("please let me in")
.toEvent(signer)
// Add a member (relay/admin op)
await new RoomAddMemberBuilder()
.setGroup(roomId)
.addPubkey(newMemberPubkey)
.toEvent(signer)
// Create a room
await new RoomCreateBuilder().setGroup(roomId).toEvent(signer)
```
::: tip Naming quirks
- `RoomJoin` stores the invite under the `claim` tag, but the accessor is `code()` (and the setter is `setCode`).
- `RoomCreatePermission` uses `setPubkeys(pks)` (replace-all), not an `add*` method.
- `RoomDelete`, `RoomLeave`, and `RoomJoin` builders throw from `validate()` if no `h` group is set — always `setGroup(roomId)` first.
:::
## See also
- [Readers & Builders](./readers-and-builders) — the base pattern, the `h`/group tag handling (`setGroup`/`clearGroup`/`group()`), and `d`-tag validation for the addressable kinds.
- [Lists](./lists) — `RoomList` (kind 10009), the NIP-51 membership list that tracks which rooms a user belongs to.
- [Relay membership](./relay-membership) — the Flotilla relay-level (non-NIP-29) membership ops.
+85
View File
@@ -0,0 +1,85 @@
# Zaps
The lightning-zap flow (NIP-57) and zap goals (NIP-75) are modeled by three kinds: `ZapRequest` (what a sender publishes to ask for a zap), `ZapReceipt` (what the recipient's LN service publishes as proof of payment), and `ZapGoal` (a fundraising target). All three are plain `EventReader` / `EventBuilder` subclasses — see [Readers & Builders](./readers-and-builders) for the base pattern.
## Zap request (kind 9734)
The zap request you send to a recipient's LNURL callback. The comment is the event content; everything else is tags.
```typescript
import {ZapRequest, ZapRequestBuilder} from "@welshman/domain"
const req = await ZapRequest.fromEvent(event)
req.amount() // millisats as an int, or undefined ("amount" tag)
req.lnurl() // string | undefined
req.recipient() // p-tag value
req.eventId() // e-tag value (the zapped event)
req.urls() // the "relays" tag, sliced past the key
req.comment() // event.content
const template = await new ZapRequestBuilder()
.setAmount(21000) // millisats
.setRecipient(recipientPubkey)
.setLnurl(lnurl)
.setEventId(zappedNoteId)
.setUrls(["wss://relay.example"]) // ["relays", ...urls]
.setComment("great post")
.toTemplate()
```
`buildTags` always emits a `relays` tag (bare if you never called `setUrls`).
## Zap receipt (kind 9735)
The receipt is generated by the recipient's lightning service, so it is effectively **read-only** in practice. `parse` decodes the embedded zap request out of the `description` tag into `plain`, which the getters read through.
```typescript
import {ZapReceipt} from "@welshman/domain"
const receipt = await ZapReceipt.fromEvent(event)
receipt.bolt11() // the invoice
receipt.invoiceAmount() // amount parsed from bolt11, or undefined on parse failure
receipt.request() // the embedded zap-request event (TrustedEvent | undefined)
receipt.sender() // request.pubkey
receipt.recipient() // p-tag value
receipt.eventId() // e-tag value
receipt.comment() // the embedded request's content
receipt.preimage() // string | undefined
```
The important method is `verify(zapper)`, which validates the receipt against a `Zapper` (the recipient's LNURL zapper info from `@welshman/util`). It checks that the request is present, that the invoice amount matches the requested amount, that the sender is not the zapper itself, and that the recipient / lnurl / nostr pubkey are consistent. It returns a boolean.
```typescript
import type {Zapper} from "@welshman/util"
const ok: boolean = receipt.verify(zapper)
```
A `ZapReceiptBuilder` exists (`setBolt11`, `setDescription`, `setRecipient`, `setEventId`, `setPreimage`) for completeness — e.g. tests or a service generating receipts — but you rarely construct these by hand.
## Zap goal (kind 9041)
A fundraising target. The title is the content; the goal amount and relays are tags.
```typescript
import {ZapGoal, ZapGoalBuilder} from "@welshman/domain"
const goal = await ZapGoal.fromEvent(event)
goal.title() // event.content (or "")
goal.summary() // "summary" tag value
goal.amount() // millisats as an int, default 0
goal.urls() // "relays" tag values
const template = await new ZapGoalBuilder()
.setTitle("Fund the relay")
.setSummary("keeps the lights on")
.setAmount(1000000) // millisats
.setUrls(["wss://relay.example"]) // one ["relays", url] per url
.toTemplate()
```
`validate()` requires a title (throws `ZapGoal requires a title`), and `buildTags` always emits an `amount` tag (defaulting to `["amount", "0"]`).
## See also
- [Readers & Builders](./readers-and-builders) — the base `EventReader`/`EventBuilder` pattern.
+3
View File
@@ -24,6 +24,9 @@ features:
- title: "@welshman/util"
details: Core Nostr utilities for events, filters, and data structures.
link: "/util"
- title: "@welshman/domain"
details: Nostr event kinds modeled as Reader/Builder classes for profiles, lists, rooms, handlers, and zaps.
link: "/domain"
- title: "@welshman/net"
details: Networking layer for Nostr with relay connection management and message status handling.
link: "/net"
-91
View File
@@ -1,91 +0,0 @@
# Encryptable
The Encryptable module provides utilities for handling encrypted Nostr events, allowing you to merge plaintext updates into events and encrypt them before publishing.
## API
```typescript
// Encryption function type
export type Encrypt = (x: string) => Promise<string>;
// Partial event content for updates
export type EncryptableUpdates = Partial<EventContent>;
// Event with attached plaintext data
export type DecryptedEvent = TrustedEvent & {
plaintext: EncryptableUpdates;
};
// Creates a DecryptedEvent by attaching plaintext to an event
export declare const asDecryptedEvent: (
event: TrustedEvent,
plaintext?: EncryptableUpdates
) => DecryptedEvent;
// Encryptable class for handling encrypted events
export declare class Encryptable<T extends EventTemplate> {
constructor(
event: Partial<T>,
updates: EncryptableUpdates
);
// Encrypts updates and merges them into the event
reconcile(encrypt: Encrypt): Promise<T>;
}
```
## Examples
### Basic Usage
```typescript
import { Encryptable } from '@welshman/util';
// Create encryptable with plaintext updates
const encryptable = new Encryptable(
{ kind: 10000 }, // Base event template
{ content: "secret mute list data" } // Plaintext content to encrypt
);
// Encrypt and get final event
const encryptFn = async (text: string) => {
// Your encryption logic here
return await encrypt(text);
};
const event = await encryptable.reconcile(encryptFn);
// event.content is now encrypted
```
### Encrypting Tags
```typescript
import { Encryptable } from '@welshman/util';
// Encrypt both content and tag values
const encryptable = new Encryptable(
{ kind: 10000, tags: [] },
{
content: JSON.stringify(['pubkey1', 'pubkey2']),
tags: [['p', 'sensitive-pubkey'], ['e', 'sensitive-event-id']]
}
);
// The reconcile method encrypts tag values at index 1
const event = await encryptable.reconcile(encryptFn);
// event.tags[0] = ['p', 'encrypted-pubkey']
// event.tags[1] = ['e', 'encrypted-event-id']
```
### Working with Decrypted Events
```typescript
import { asDecryptedEvent } from '@welshman/util';
// Add plaintext data to an event for reference
const event = { kind: 10000, content: "encrypted...", tags: [] };
const plaintext = { content: "original content", tags: [['p', 'pubkey']] };
const decryptedEvent = asDecryptedEvent(event, plaintext);
console.log(decryptedEvent.plaintext.content); // "original content"
```
-134
View File
@@ -1,134 +0,0 @@
# Handlers (NIP-89)
The Handlers module provides functionality for working with handler recommendations and information (NIP-89).
Handlers are events that describe which kinds a given application can display.
This module provides utilities for transforming these events into structured handler objects that applications can easily process.
## Types
### Handler Definition
```typescript
type Handler = {
kind: number // Event kind this handler can process
name: string // Display name of the handler
about: string // Description
image: string // Icon or image URL
identifier: string // Unique identifier (d-tag)
event: TrustedEvent // Original handler event
website?: string // Optional website URL
lud16?: string // Optional Lightning address
nip05?: string // Optional NIP-05 identifier
}
```
## Core Functions
### Reading Handlers
```typescript
function readHandlers(event: TrustedEvent): Handler[]
// Example
const handlers = readHandlers(handlerEvent)
handlers.forEach(handler => {
console.log(`Handler for kind ${handler.kind}: ${handler.name}`)
})
```
### Handler Identification
```typescript
function getHandlerKey(handler: Handler): string
// Returns "kind:address" format
function getHandlerAddress(event: TrustedEvent): string | undefined
// Gets handler address from event tags
```
### Display Formatting
```typescript
function displayHandler(
handler?: Handler,
fallback = ""
): string
```
## Usage Examples
### Reading Handler Information
```typescript
const event = {
kind: 31990, // Handler Information kind
content: JSON.stringify({
name: "Note Viewer",
about: "Displays text notes with formatting",
image: "https://example.com/icon.png"
}),
tags: [
['k', '1'], // Handles kind 1 (text notes)
['d', 'note-viewer']
]
}
const handlers = readHandlers(event)
// Returns array of handlers defined in the event
```
### Working with Handlers
```typescript
// Get unique handler identifier
const key = getHandlerKey(handler)
// => "1:31990:pubkey:identifier" (handler-kind:address)
// where address is the "kind:pubkey:identifier" of the handler event
// Display handler name
const name = displayHandler(handler, "Unknown Handler")
// => "Note Viewer" or fallback if handler undefined
// Get handler address
const address = getHandlerAddress(event)
// Returns address from tags with 'web' marker or first address
```
## Complete Example
```typescript
// Process handler information event
function processHandlerEvent(event: TrustedEvent) {
// Read all handlers from event
const handlers = readHandlers(event)
// Process each handler
handlers.forEach(handler => {
// Generate unique key
const key = getHandlerKey(handler)
// Store handler information
handlerRegistry.set(key, {
name: handler.name,
kind: handler.kind,
about: handler.about,
image: handler.image,
website: handler.website,
address: getHandlerAddress(handler.event)
})
})
}
// Find handler for event kind
function findHandler(kind: number): Handler | undefined {
return Array.from(handlerRegistry.values())
.find(h => h.kind === kind)
}
// Display handler information
function renderHandler(handler: Handler) {
return {
title: displayHandler(handler, "Unknown"),
description: handler.about,
icon: handler.image,
website: handler.website || null
}
}
```
+3 -3
View File
@@ -2,20 +2,20 @@
[![version](https://badgen.net/npm/v/@welshman/util)](https://npmjs.com/package/@welshman/util)
A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, profiles, and more.
A utility package for Nostr application development, providing essential tools and types for working with Nostr events, addresses, filters, tags, and more.
## What's Included
- **Event Management**: Create, validate, and process Nostr events
- **Repository**: In-memory event storage with querying and indexing
- **Filters**: Advanced event filtering and subscription management
- **Profiles**: User profile handling and formatting
- **Lists**: Public and private list management
- **Zaps**: Lightning Network payment integration
- **Tags**: Comprehensive tag parsing and manipulation
- **Addresses**: NIP-19 address handling
- **Relays**: Relay URL handling, event dispatching and in-memory storage
> Note: profiles, lists, handlers, rooms, and event Reader/Builder helpers now live in [@welshman/domain](/domain/).
## Installation
```
-148
View File
@@ -1,148 +0,0 @@
# Lists
The Lists module provides utilities for working with Nostr lists, including both public and private lists (like bookmarks, mute lists, etc.). It handles list creation, encryption, and manipulation.
## Core Types
### List Parameters
```typescript
interface ListParams {
kind: number // List kind (e.g., 10000 for mutes)
}
```
### List Structure
```typescript
interface List extends ListParams {
publicTags: string[][] // Publicly visible tags
privateTags: string[][] // Encrypted tags
event?: DecryptedEvent // Original event if list exists
}
```
### Published List
```typescript
interface PublishedList extends List {
event: DecryptedEvent // Required event for published lists
}
```
## List Creation
### Create New List
```typescript
function makeList(list: ListParams & Partial<List>): List
// Example
const muteList = makeList({
kind: 10000,
publicTags: [['d', 'mutes']],
privateTags: [['p', 'pubkey1'], ['p', 'pubkey2']]
})
```
### Read Existing List
```typescript
function readList(event: DecryptedEvent): PublishedList
// Example
const list = readList(decryptedEvent)
```
## List Operations
### Get All Tags
```typescript
function getListTags(list: List | undefined): string[][]
// Example
const allTags = getListTags(list) // Combines public and private tags
```
### Remove Items
```typescript
// Remove by predicate
function removeFromListByPredicate(
list: List,
pred: (t: string[]) => boolean
): Encryptable
// Remove by value
function removeFromList(
list: List,
value: string
): Encryptable
```
### Add Items
```typescript
// Add public items
function addToListPublicly(
list: List,
...tags: string[][]
): Encryptable
// Add private items
function addToListPrivately(
list: List,
...tags: string[][]
): Encryptable
// Update list with new tags
function updateList(
list: List,
options: {publicTags?: string[][], privateTags?: string[][]}
): Encryptable
```
## Usage Examples
### Creating a Private List
```typescript
// Create new mute list
const muteList = makeList({
kind: 10000,
publicTags: [
['d', 'mutes'],
['name', 'My Mute List']
]
})
// Add items privately
const updated = addToListPrivately(
muteList,
['p', 'pubkey1'],
['p', 'pubkey2']
)
// Add new items publicly
const addItems = addToListPublicly(
list,
['p', 'pubkey3'],
['p', 'pubkey4']
)
// Encrypt and publish
const encrypted = await updated.reconcile(encrypt)
```
### Reading and Updating Lists
```typescript
// Read existing list
const list = readList(decryptedEvent)
// Remove item
const removeItem = removeFromList(list, 'pubkey1')
// Remove by predicate
const noMentions = removeFromListByPredicate(
list,
tag => tag[0] === 'p'
)
```
### Working with Tags
```typescript
// Get all list tags
const tags = getListTags(list)
```
-115
View File
@@ -1,115 +0,0 @@
# Profile
The Profile module provides utilities for handling Nostr user profiles (kind 0 events), including profile creation, reading, and display formatting.
## Core Types
### Profile Structure
```typescript
interface Profile {
name?: string // Display name
nip05?: string // NIP-05 verification
lud06?: string // Legacy Lightning address
lud16?: string // Lightning address
lnurl?: string // Lightning URL
about?: string // Bio/description
banner?: string // Banner image URL
picture?: string // Profile picture URL
website?: string // Website URL
display_name?: string // Alternative display name
event?: TrustedEvent // Original profile event
}
```
### Published Profile
```typescript
interface PublishedProfile extends Omit<Profile, "event"> {
event: TrustedEvent // Required event for published profiles
}
```
## Core Functions
### Profile Creation & Reading
```typescript
// Create new profile
function makeProfile(profile: Partial<Profile>): Profile
// Read profile from event
function readProfile(event: TrustedEvent): PublishedProfile
// Create profile event
function createProfile(profile: Profile): EventTemplate
// Edit existing profile
function editProfile(profile: PublishedProfile): EventTemplate
```
### Display Formatting
```typescript
// Format pubkey for display
function displayPubkey(pubkey: string): string
// Format profile name for display
function displayProfile(
profile?: Profile,
fallback = ""
): string
// Check if profile has name
function profileHasName(profile?: Profile): boolean
```
## Usage Examples
### Creating New Profile
```typescript
// Create basic profile
const profile = makeProfile({
name: "Alice",
about: "Nostr user",
picture: "https://example.com/avatar.jpg",
lud16: "alice@getalby.com"
})
// Create profile event
const profileEvent = createProfile(profile)
```
### Reading Profile
```typescript
// Read profile from event
const profile = readProfile(profileEvent)
// Access profile data
console.log(profile.name)
console.log(profile.about)
console.log(profile.lnurl) // Auto-generated from lud16/lud06
```
### Displaying Profile
```typescript
// Display profile name
const name = displayProfile(profile, "Anonymous")
// Display pubkey
const shortPubkey = displayPubkey(profile.event.pubkey)
// => "npub1abc...xyz"
// Check for name
if (profileHasName(profile)) {
showName(profile)
} else {
showPubkey(profile)
}
```
### Updating Profile
```typescript
// Edit existing profile
const profileEvent = editProfile({
...existingProfile,
name: "New Name",
about: "Updated bio"
})
```
-1
View File
@@ -84,7 +84,6 @@ export class Searches {
keys: [
"nip05",
{name: "name", weight: 0.8},
{name: "display_name", weight: 0.5},
{name: "about", weight: 0.3},
],
threshold: 0.3,
@@ -1,5 +1,5 @@
import {uniq, nthEq} from "@welshman/lib"
import {BLOSSOM_SERVERS, getTagValues, normalizeRelayUrl} from "@welshman/util"
import {uniq, nthEq, normalizeUrl} from "@welshman/lib"
import {BLOSSOM_SERVERS, getTagValues} from "@welshman/util"
import {ListReader} from "../ListReader.js"
import {ListBuilder} from "../ListBuilder.js"
@@ -8,11 +8,11 @@ export class BlossomServerList extends ListReader {
readonly kind = BLOSSOM_SERVERS
urls() {
return uniq(getTagValues("server", this.tags()).map(normalizeRelayUrl))
return uniq(getTagValues("server", this.tags()).map(normalizeUrl))
}
includes(url: string) {
return this.urls().includes(normalizeRelayUrl(url))
return this.urls().includes(normalizeUrl(url))
}
builder() {
@@ -24,16 +24,16 @@ export class BlossomServerListBuilder extends ListBuilder<BlossomServerList> {
readonly kind = BLOSSOM_SERVERS
addUrl(url: string) {
return this.addPublic(["server", normalizeRelayUrl(url)])
return this.addPublic(["server", normalizeUrl(url)])
}
removeUrl(url: string) {
return this.drop(nthEq(1, normalizeRelayUrl(url)))
return this.drop(nthEq(1, normalizeUrl(url)))
}
setUrls(urls: string[]) {
this.clear()
return this.addPublic(...urls.map(url => ["server", normalizeRelayUrl(url)]))
return this.addPublic(...urls.map(url => ["server", normalizeUrl(url)]))
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
import {first} from "@welshman/lib"
import {COMMENT, Address, getAddress, getTagValue, isReplaceableKind} from "@welshman/util"
import {COMMENT, Address, getTagValue} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import type {ISigner} from "@welshman/signer"
import {EventReader} from "../EventReader.js"
@@ -68,7 +68,7 @@ export class CommentBuilder extends EventBuilder<Comment> {
this.rootTags = [["K", String(kind)], ["E", id], ["P", pubkey]]
if (identifier) {
this.rootTags.push(["A", id])
this.rootTags.push(["A", new Address(kind, pubkey, identifier).toString()])
}
return this
@@ -78,7 +78,7 @@ export class CommentBuilder extends EventBuilder<Comment> {
this.parentTags = [["k", String(kind)], ["e", id], ["p", pubkey]]
if (identifier) {
this.parentTags.push(["a", id])
this.parentTags.push(["a", new Address(kind, pubkey, identifier).toString()])
}
return this
@@ -1,4 +1,4 @@
import {nthEq, last} from "@welshman/lib"
import {nthNe, last} from "@welshman/lib"
import {HANDLER_RECOMMENDATION, getAddressTags, getAddressTagValues} from "@welshman/util"
import {EventReader} from "../EventReader.js"
import {EventBuilder} from "../EventBuilder.js"
@@ -22,6 +22,11 @@ export class HandlerRecommendation extends EventReader {
return tag?.[1]
}
// NIP-89: the `d` tag is the event-kind this recommendation applies to.
supportedKind() {
return this.identifier()
}
builder() {
return new HandlerRecommendationBuilder(this)
}
@@ -38,6 +43,14 @@ export class HandlerRecommendationBuilder extends EventBuilder<HandlerRecommenda
this.addressTags = this.consumeTags("a")
}
// NIP-89: set the `d` tag to the event-kind being recommended for, so the
// standard {kinds:[31989], "#d":[kind]} query resolves.
setSupportedKind(kind: number) {
this.setIdentifier(String(kind))
return this
}
addRecommendation(address: string, relay?: string, platform?: string) {
if (!this.addressTags.some(t => t[1] === address)) {
this.addressTags = [...this.addressTags, ["a", address, relay || "", platform || ""]]
@@ -47,7 +60,7 @@ export class HandlerRecommendationBuilder extends EventBuilder<HandlerRecommenda
}
removeRecommendation(address: string) {
this.addressTags = this.addressTags.filter(nthEq(1, address))
this.addressTags = this.addressTags.filter(nthNe(1, address))
return this
}
-14
View File
@@ -41,10 +41,6 @@ export class Profile extends EventReader {
return this.values.name
}
displayName(): Maybe<string> {
return this.values.display_name
}
nip05(): Maybe<string> {
return this.values.nip05
}
@@ -74,10 +70,6 @@ export class Profile extends EventReader {
if (name) return ellipsize(name, 60).trim()
const displayName= this.displayName()
if (displayName) return ellipsize(displayName, 60).trim()
return displayPubkey(this.event.pubkey).trim() || fallback.trim()
}
@@ -107,12 +99,6 @@ export class ProfileBuilder extends EventBuilder<Profile> {
return this
}
setDisplayName(displayName: string) {
this.values.displayName = displayName
return this
}
setNip05(nip05: string) {
this.values.nip05 = nip05
+9 -5
View File
@@ -1,14 +1,15 @@
import {uniq, nth, nthNe, uniqBy} from "@welshman/lib"
import {RELAY_MEMBERS, getPubkeyTagValues} from "@welshman/util"
import {RELAY_MEMBERS, getTagValues} from "@welshman/util"
import {EventReader} from "../EventReader.js"
import {EventBuilder} from "../EventBuilder.js"
// Flotilla kind-13534 relay/space member-list snapshot.
// Flotilla kind-13534 relay/space member-list snapshot. Members are carried in
// NIP-43 `member` tags, and the event is NIP-70 protected (`-`).
export class RelayMembers extends EventReader {
readonly kind = RELAY_MEMBERS
pubkeys() {
return uniq(getPubkeyTagValues(this.event.tags))
return uniq(getTagValues("member", this.event.tags))
}
isMember(pubkey: string) {
@@ -28,11 +29,14 @@ export class RelayMembersBuilder extends EventBuilder<RelayMembers> {
constructor(readonly reader?: RelayMembers) {
super(reader)
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("p"))
this.pubkeyTags = uniqBy(nth(1), this.consumeTags("member"))
// NIP-43 requires kind-13534 member lists to be NIP-70 protected.
this.setProtected(true)
}
addPubkey(pubkey: string) {
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["p", pubkey]])
this.pubkeyTags = uniqBy(nth(1), [...this.pubkeyTags, ["member", pubkey]])
return this
}
+12 -3
View File
@@ -16,7 +16,9 @@ export class Report extends EventReader {
}
reason() {
return getTag("e", this.event.tags)?.[2]
// NIP-56: the report-type string is the 3rd entry of the reported tag — the
// `e` tag for note reports, or the `p` tag for profile-only reports.
return getTag("e", this.event.tags)?.[2] ?? getTag("p", this.event.tags)?.[2]
}
builder() {
@@ -39,7 +41,7 @@ export class ReportBuilder extends EventBuilder<Report> {
this.reportedPubkey = p?.[1]
this.eventId = e?.[1]
this.reason = e?.[2]
this.reason = e?.[2] ?? p?.[2]
}
setReportedPubkey(reportedPubkey: string) {
@@ -63,8 +65,15 @@ export class ReportBuilder extends EventBuilder<Report> {
protected buildTags() {
const tags: string[][] = []
// NIP-56: a `p` tag referencing the reported pubkey is mandatory. The
// report-type string goes on the reported object — the `e` tag for note
// reports, otherwise the `p` tag for profile-only reports.
if (this.reportedPubkey) {
tags.push(["p", this.reportedPubkey])
tags.push(
this.eventId
? ["p", this.reportedPubkey]
: ["p", this.reportedPubkey, ...(this.reason ? [this.reason] : [])],
)
}
if (this.eventId) {
+2 -1
View File
@@ -19,7 +19,8 @@ npx skills add coracle-social/welshman
| Skill | Description |
|-------|-------------|
| welshman | General overview, package map, getting started |
| welshman-util | Core nostr types, events, filters, NIPs |
| welshman-util | Core nostr types, events, filters, tags, NIPs (profiles/lists/handlers/rooms moved to domain) |
| welshman-domain | Nostr event kinds as Reader/Builder classes: profiles, lists, rooms, handlers, zaps |
| welshman-lib | Utilities: LRU, emitter, deferred, task queue |
| welshman-net | Relay connections, request/publish, auth |
| welshman-router | Relay selection strategies |
+4 -4
View File
@@ -89,14 +89,14 @@ All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-load
| Plugin | Data | Notable accessors |
|---|---|---|
| `Profiles` | kind-0 profiles | `one(pk)`, `display(pk)`, `publish(profile)` |
| `Profiles` | kind-0 profiles | `one(pk)`, `display(pk)`, `publish(values)` |
| `FollowLists` | kind-3 follows | `one(pk)`, `follow(tag)`, `unfollow(value)` |
| `MuteLists` | kind-10000 mutes (private = encrypted) | `mutePublicly(tag)`, `mutePrivately(tag)`, `unmute(v)`, `setMutes(...)` |
| `PinLists` | kind-10001 pins | `pin(tag)`, `unpin(value)` |
| `RelayLists` | NIP-65 (kind 10002) | `urls(pk)`, `readUrls(pk)`, `writeUrls(pk)`, `addRelay(url, mode)`, `setWriteRelays(urls)` |
| `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addRelay`, `removeRelay`, `setRelays` |
| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addRelay`, ... |
| `SearchRelayLists` | kind-10007 | `urls(pk)`, `addRelay`, ... |
| `BlockedRelayLists` | kind-10006 | `urls(pk)`, `addUrl`, `removeUrl`, `setUrls` |
| `MessagingRelayLists` | kind-10050 (NIP-17 DM relays) | `urls(pk)`, `addUrl`, ... |
| `SearchRelayLists` | kind-10007 | `urls(pk)`, `addUrl`, ... |
| `Relays` | NIP-11 relay info (HTTP) | `one(url)`, `display(url)`, `hasNip(url, n)`, `hasNegentropy(url)` |
| `RelayManagement` | NIP-86 | `post(url, request)` |
| `Handles` | NIP-05 (HTTP, batched) | `forPubkey(pk)`, `display(nip05)`, `loadForPubkey(pk)` |
+219
View File
@@ -0,0 +1,219 @@
---
name: welshman-domain
description: "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 `TrustedEvent` with 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
```bash
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
1. **Readers wrap an event; Builders produce a template.** A Reader is `new Profile(event)` (validated + parsed via the static factories); a Builder is `new ProfileBuilder()` (or seeded from a reader). Each Reader knows its `builder()`, each Builder optionally takes a `reader`.
2. **Reading is async; getters are sync.** You enter through `await X.fromEvent(event, signer?)` (which runs `parse`), then call plain synchronous getters.
3. **Building is chainable; output is async.** Setters return `this`; you finish with `await b.toTemplate(signer?)` / `toRumor(signer)` / `toEvent(signer)`.
4. **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.
5. **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`.
```typescript
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 `["-"]`), `expires()`. Each kind adds its own — e.g. `profile.name()`, `profile.display()`, `followList.pubkeys()`, `followList.includes(pk)`.
## Building / editing an event
```typescript
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)`, or `reader.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 by `factory`, and all of `toTemplate`/`toRumor`/`toEvent` are async. Every getter and every setter is synchronous.
- **Reading private list tags:** a `ListReader` only surfaces `privateTags` when you pass the **author's own** signer to `fromEvent`/`factory` (it decrypts NIP-44 content only when `signer.getPubkey() === event.pubkey`). Decryption failures are swallowed — `decrypted` stays `false` and private tags stay empty.
- **Writing private list tags:** `ListBuilder.buildContent` is 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`/`toEvent` always need one. All other kinds ignore the signer entirely.
- **`d`-tag required** (base `validate` throws otherwise) for parameterized-replaceable kinds: `RelaySet` (30002), `Classified` (30402), `Feed` (31890), `TimeEvent` (31923), `HandlerRecommendation` (31989), `Handler` (31990), `RoomMeta` (39000), `RoomAdmins` (39001), `RoomMembers` (39002). Call `setIdentifier()`.
- **`h`-group required** (subclass `validate` throws): `RoomDelete` (9008), `RoomJoin` (9021), `RoomLeave` (9022). Call `setGroup(groupId)`.
## Kind classes
Each row: kind# — NIP — Reader / Builder.
### Profile
| Kind | NIP | Reader / Builder |
|---|---|---|
| 0 | NIP-01 | `Profile` / `ProfileBuilder` |
`Profile`: `name`, `displayName`, `nip05`, `lnurl`, `about`, `banner`, `picture`, `website`, `display(fallback?)`. Builder: `update`, `setName`, `setDisplayName`, `setNip05`, `setAbout`, `setBanner`, `setPicture`, `setWebsite`. (Also exports `parseLnUrl`, `displayPubkey`.) Note: `setDisplayName` writes `values.displayName` (camelCase) but `displayName()` reads `values.display_name` — they're not symmetric.
### 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 3900039002 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; `decrypted` stays `false`. 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()` throws `Unable to modify list when decryption was not performed`. Editing only public tags is fine — the original ciphertext is preserved.
- **`toRumor`/`toEvent` always need a signer**, even for kinds whose `toTemplate` doesn't (they call `getPubkey()`/`sign`).
- **Parameterized-replaceable kinds throw without a `d` tag** — call `setIdentifier()` (or let it default to a random id). Room ops scoped by `h` throw without `setGroup`.
- **`ProfileBuilder.setDisplayName` is asymmetric** with `Profile.displayName()` (camelCase write vs `display_name` read). Use `update({display_name})` if you need the getter to round-trip.
- **`RoomJoin.code()` reads the `claim` tag** (accessor name ≠ tag key); `RelayJoin`/`RelayInvite` use `claim()`.
- **No top-level free functions beyond `parseLnUrl` and `displayPubkey`.** The h/group tag is handled by `setGroup`/`group()`, not a helper. Don't invent `makeX`/`readX` helpers — 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 raw `TrustedEvent`/`EventTemplate` types, 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 as `eventToItem`.
- `welshman-signer` — the `ISigner` interface and NIP-44 `decrypt`/`encrypt` used for private list tags and for `toRumor`/`toEvent`.
+12 -64
View File
@@ -1,11 +1,13 @@
---
name: welshman-util
description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), profiles, relays, zaps, wallets, or any core nostr data structures."
description: "Use this skill when working with @welshman/util: nostr event types, kinds, tags, filters, addresses, NIPs (42/86/98), relays, zaps, wallets, or any core nostr data structures. (Profiles, lists, handlers, rooms, and Encryptable now live in @welshman/domain.)"
---
# welshman/util — Core Nostr Utilities
`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, profiles, lists, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here.
`@welshman/util` is the foundational layer of the welshman nostr stack, providing types, constants, and helpers for every nostr primitive: events, kinds, tags, filters, addresses, zaps, relays, and Lightning wallet integration. Higher level welshman packages (`@welshman/net`, `@welshman/app`, `@welshman/store`, etc.) depend on the types and utilities defined here.
> **Moved to `@welshman/domain`:** Profiles, lists, handlers, rooms, and Encryptable (`makeProfile`/`readProfile`/`displayProfile`, `makeList`/`addToList*`, `readHandlers`/`displayHandler`, room helpers, `Encryptable`/`asDecryptedEvent`/`DecryptedEvent`, etc.) now live in `@welshman/domain` as Reader/Builder classes — see the welshman-domain skill.
## Installation
@@ -32,7 +34,6 @@ yarn add @welshman/util
| `HashedEvent` | `OwnedEvent + id` |
| `SignedEvent` | `HashedEvent + sig` |
| `TrustedEvent` | `HashedEvent + optional sig` — most common in-app type |
| `DecryptedEvent` | `TrustedEvent + plaintext` (for encrypted lists/events) |
### Event Utilities
@@ -323,40 +324,6 @@ isDVMKind(kind) // 50007000
| `address.toNaddr()` | Serialize to NIP-19 naddr |
| `getAddress(event)` | Convenience: get address string from event |
### Profile
| Export | Description |
|--------|-------------|
| `makeProfile(partial)` | Create a profile object |
| `readProfile(event)` | Parse `PublishedProfile` from kind 0 event |
| `createProfile(profile)` | Create kind 0 `EventTemplate` |
| `editProfile(published)` | Update existing profile event |
| `displayProfile(profile?, fallback?)` | Get best display name string |
| `displayPubkey(pubkey)` | Shorten pubkey to `npub1abc...xyz` |
| `profileHasName(profile?)` | Check if profile has a name field |
Profile fields: `name`, `display_name`, `about`, `picture`, `banner`, `website`, `nip05`, `lud06`, `lud16`, `lnurl`
### Lists (kind 10000+)
| Export | Description |
|--------|-------------|
| `makeList(params)` | Create a new list |
| `readList(event)` | Parse `PublishedList` from `DecryptedEvent` |
| `getListTags(list)` | Combined public + private tags |
| `addToListPublicly(list, ...tags)` | Returns `Encryptable` with tag added publicly |
| `addToListPrivately(list, ...tags)` | Returns `Encryptable` with tag added privately |
| `removeFromList(list, value)` | Returns `Encryptable` with tag removed |
| `removeFromListByPredicate(list, pred)` | Returns `Encryptable` with matching tags removed |
| `updateList(list, { publicTags?, privateTags? })` | Bulk update tags |
### Encryptable
| Export | Description |
|--------|-------------|
| `Encryptable<T>` | Wraps a partial event with plaintext updates; call `.reconcile(encrypt)` to produce encrypted event |
| `asDecryptedEvent(event, plaintext?)` | Attach plaintext data to a `TrustedEvent` |
### Relay
| Export | Description |
@@ -414,15 +381,6 @@ sendManagementRequest(url: string, request: ManagementRequest, authEvent: Signed
// ManagementMethod enum covers: BanPubkey, AllowPubkey, BanEvent, AllowEvent, etc.
```
### Handlers (NIP-89)
```typescript
readHandlers(event: TrustedEvent): Handler[]
getHandlerKey(handler: Handler): string // "kind:address" format
getHandlerAddress(event: TrustedEvent): string | undefined
displayHandler(handler?: Handler, fallback?: string): string
```
### Links
```typescript
@@ -566,20 +524,6 @@ const parsed = Address.fromNaddr('naddr1...')
const addressStr = getAddress(event) // '30023:deadbeef:my-slug'
```
### Profiles
```typescript
import { readProfile, displayProfile, displayPubkey, editProfile } from '@welshman/util'
const profile = readProfile(kind0Event)
console.log(displayProfile(profile, 'Anonymous')) // name or fallback
console.log(displayPubkey(pubkey)) // 'npub1abc...xyz'
// Update profile
const updatedEvent = editProfile({ ...profile, name: 'New Name', about: 'Updated bio' })
// sign and publish updatedEvent
```
### Zap flow
```typescript
@@ -646,9 +590,9 @@ await fetch('https://api.example.com/upload', {
- **`@welshman/net`** — uses `TrustedEvent`, `Filter`, `SignedEvent` from this package as the wire types for relay connections and subscriptions.
- **`@welshman/store`** — provides Svelte stores over repositories built on `TrustedEvent`; relies on `isReplaceable`, `getAddress`, etc. for deduplication.
- **`@welshman/app`** — high-level application layer; wraps net/store/router and uses profile, list, zap, and handler helpers from this package.
- **`@welshman/app`** — high-level application layer; wraps net/store/router and uses zap helpers from this package (profile/list/handler/room helpers now live in `@welshman/domain`).
- **`@welshman/router`** — uses `RelayMode` and relay URL helpers when computing relay selections.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; the `Encrypt` function type used by `Encryptable` is typically provided by a signer.
- **`@welshman/signer`** — produces `SignedEvent` objects that satisfy types defined here; signers also provide the `Encrypt` function used by `@welshman/domain` list/Encryptable builders.
---
@@ -660,8 +604,6 @@ await fetch('https://api.example.com/upload', {
- **`getAncestors` handles two protocols**: Kind 1111 (comment/NIP-22) uses uppercase `E`/`A` for roots and lowercase for replies, returning `{ roots, replies }`. All other kinds use NIP-10 positional rules, returning `{ roots, replies, mentions }` where `mentions` is always present but may be an empty array. You do not need to branch on this; `getAncestors`, `getParentIdOrAddr`, and `isChildOf` handle it automatically.
- **List mutations return `Encryptable`**: `addToListPrivately`, `removeFromList`, etc. do not return an event directly. Call `.reconcile(encryptFn)` on the result to get the final `EventTemplate` ready to sign.
- **`zapFromEvent` returns `undefined` on any validation failure** including amount mismatch, wrong zapper pubkey, malformed invoice, or self-zap. Always check the result.
- **`getLnUrl` handles three input forms**: bare lightning address (`user@domain`), full HTTPS URL, or already-encoded `lnurl1...`. Returns `undefined` for anything else.
@@ -673,3 +615,9 @@ await fetch('https://api.example.com/upload', {
- **`getTagValue` / `getTagValues` argument order**: the type(s) come **first**, the tags array comes **second**`getTagValue('title', event.tags)`. This is the opposite of the specialized helpers like `getEventTags(tags)` which take only the tags array. Mixing up the order produces no TypeScript error but silently returns `undefined` or `[]`.
- **`verifiedSymbol` is a Symbol key**: you must import `verifiedSymbol` from `@welshman/util` and use it as a computed property key — `event[verifiedSymbol] = true`. You cannot use a string key. The symbol is re-exported from `nostr-tools/pure`, so it is the same identity as the one used internally by `verifyEvent`.
---
## Related
- **`@welshman/domain`** (welshman-domain skill) — Profiles, lists, handlers, rooms, and Encryptable (`makeProfile`/`readProfile`/`displayProfile`/`PublishedProfile`, `makeList`/`readList`/`addToList*`/`removeFromList*`/`getListTags`/`getRelaysFromList`, `readHandlers`/`displayHandler`/`getHandlerKey`/`getHandlerAddress`, room helpers, `Encryptable`/`asDecryptedEvent`/`DecryptedEvent`) moved out of `@welshman/util` and now live here as Reader/Builder classes.