diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index eab0e9c..94ed476 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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"}, diff --git a/docs/app/data.md b/docs/app/data.md index c655807..a63cc93 100644 --- a/docs/app/data.md +++ b/docs/app/data.md @@ -15,7 +15,7 @@ profiles.one(pubkey) // Readable> — lazily loads profiles.get(pubkey) // Maybe — sync snapshot, no load await profiles.load(pubkey) // explicit load (cached) profiles.display(pubkey) // Projection — 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> +follows.one(pubkey) // Readable> 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> +mutes.one(pubkey) // Readable> 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> — resolves via the profile's nip05 -handles.display(nip05) // Projection +handles.display(nip05) // string — displayable nip05 await handles.loadForPubkey(pubkey) ``` diff --git a/docs/app/feeds-and-search.md b/docs/app/feeds-and-search.md index 05c3612..584adfc 100644 --- a/docs/app/feeds-and-search.md +++ b/docs/app/feeds-and-search.md @@ -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. diff --git a/docs/domain/content.md b/docs/domain/content.md new file mode 100644 index 0000000..28a67ee --- /dev/null +++ b/docs/domain/content.md @@ -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`. diff --git a/docs/domain/handlers.md b/docs/domain/handlers.md new file mode 100644 index 0000000..b656984 --- /dev/null +++ b/docs/domain/handlers.md @@ -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. diff --git a/docs/domain/index.md b/docs/domain/index.md new file mode 100644 index 0000000..c16ba9e --- /dev/null +++ b/docs/domain/index.md @@ -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. diff --git a/docs/domain/lists.md b/docs/domain/lists.md new file mode 100644 index 0000000..5d469d6 --- /dev/null +++ b/docs/domain/lists.md @@ -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`. diff --git a/docs/domain/profile.md b/docs/domain/profile.md new file mode 100644 index 0000000..261e9a7 --- /dev/null +++ b/docs/domain/profile.md @@ -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. diff --git a/docs/domain/readers-and-builders.md b/docs/domain/readers-and-builders.md new file mode 100644 index 0000000..969c371 --- /dev/null +++ b/docs/domain/readers-and-builders.md @@ -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, 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 {} +``` + +`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 +``` + +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 // default: [] — kind-specific tags +protected buildContent(signer?): MaybeAsync // 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 { + // 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?)` | diff --git a/docs/domain/relay-membership.md b/docs/domain/relay-membership.md new file mode 100644 index 0000000..16ea5fd --- /dev/null +++ b/docs/domain/relay-membership.md @@ -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. diff --git a/docs/domain/rooms.md b/docs/domain/rooms.md new file mode 100644 index 0000000..f776dfb --- /dev/null +++ b/docs/domain/rooms.md @@ -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 39000–39002) — replaceable, identified by a `d` tag, written by the relay. These are the canonical state of a room. +- **Action ops** (kinds 9000–9022, 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. diff --git a/docs/domain/zaps.md b/docs/domain/zaps.md new file mode 100644 index 0000000..f563a79 --- /dev/null +++ b/docs/domain/zaps.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 3a9514b..1bda66e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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" diff --git a/docs/util/encryptable.md b/docs/util/encryptable.md deleted file mode 100644 index eaaead1..0000000 --- a/docs/util/encryptable.md +++ /dev/null @@ -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; - -// Partial event content for updates -export type EncryptableUpdates = Partial; - -// 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 { - constructor( - event: Partial, - updates: EncryptableUpdates - ); - - // Encrypts updates and merges them into the event - reconcile(encrypt: Encrypt): Promise; -} -``` - -## 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" -``` diff --git a/docs/util/handlers.md b/docs/util/handlers.md deleted file mode 100644 index ec0bdfb..0000000 --- a/docs/util/handlers.md +++ /dev/null @@ -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 - } -} -``` diff --git a/docs/util/index.md b/docs/util/index.md index 45ff303..69a7d43 100644 --- a/docs/util/index.md +++ b/docs/util/index.md @@ -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 ``` diff --git a/docs/util/list.md b/docs/util/list.md deleted file mode 100644 index 35f5ad7..0000000 --- a/docs/util/list.md +++ /dev/null @@ -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 - -// 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) -``` diff --git a/docs/util/profile.md b/docs/util/profile.md deleted file mode 100644 index bfdeb64..0000000 --- a/docs/util/profile.md +++ /dev/null @@ -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 { - event: TrustedEvent // Required event for published profiles -} -``` - -## Core Functions - -### Profile Creation & Reading -```typescript -// Create new profile -function makeProfile(profile: Partial): 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" -}) -``` diff --git a/packages/app/src/plugins/search.ts b/packages/app/src/plugins/search.ts index b913954..a0a350b 100644 --- a/packages/app/src/plugins/search.ts +++ b/packages/app/src/plugins/search.ts @@ -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, diff --git a/packages/domain/src/kinds/BlossomServerList.ts b/packages/domain/src/kinds/BlossomServerList.ts index 746b507..3553295 100644 --- a/packages/domain/src/kinds/BlossomServerList.ts +++ b/packages/domain/src/kinds/BlossomServerList.ts @@ -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 { 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)])) } } diff --git a/packages/domain/src/kinds/Comment.ts b/packages/domain/src/kinds/Comment.ts index 44d69a3..b9e87b4 100644 --- a/packages/domain/src/kinds/Comment.ts +++ b/packages/domain/src/kinds/Comment.ts @@ -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 { 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 { 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 diff --git a/packages/domain/src/kinds/HandlerRecommendation.ts b/packages/domain/src/kinds/HandlerRecommendation.ts index e1523f1..349ff0f 100644 --- a/packages/domain/src/kinds/HandlerRecommendation.ts +++ b/packages/domain/src/kinds/HandlerRecommendation.ts @@ -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 t[1] === address)) { this.addressTags = [...this.addressTags, ["a", address, relay || "", platform || ""]] @@ -47,7 +60,7 @@ export class HandlerRecommendationBuilder extends EventBuilder { - return this.values.display_name - } - nip05(): Maybe { 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 { return this } - setDisplayName(displayName: string) { - this.values.displayName = displayName - - return this - } - setNip05(nip05: string) { this.values.nip05 = nip05 diff --git a/packages/domain/src/kinds/RelayMembers.ts b/packages/domain/src/kinds/RelayMembers.ts index e3a7a9b..e339d95 100644 --- a/packages/domain/src/kinds/RelayMembers.ts +++ b/packages/domain/src/kinds/RelayMembers.ts @@ -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 { 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 } diff --git a/packages/domain/src/kinds/Report.ts b/packages/domain/src/kinds/Report.ts index 35a7b3a..0edcfe7 100644 --- a/packages/domain/src/kinds/Report.ts +++ b/packages/domain/src/kinds/Report.ts @@ -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 { 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 { 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) { diff --git a/skills/README.md b/skills/README.md index e436fce..8ccb272 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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 | diff --git a/skills/welshman-app/SKILL.md b/skills/welshman-app/SKILL.md index 1545513..ec8784e 100644 --- a/skills/welshman-app/SKILL.md +++ b/skills/welshman-app/SKILL.md @@ -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)` | diff --git a/skills/welshman-domain/SKILL.md b/skills/welshman-domain/SKILL.md new file mode 100644 index 0000000..6774a3a --- /dev/null +++ b/skills/welshman-domain/SKILL.md @@ -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 +const profile2 = await toProfile(someEvent) +``` + +`factory(signer?)` returns an `async (event) => Promise`. 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 39000–39002 are addressable per room. + +| Kind | NIP | Reader / Builder | +|---|---|---| +| 9000 | NIP-29 | `RoomAddMember` / `RoomAddMemberBuilder` | +| 9001 | NIP-29 | `RoomRemoveMember` / `RoomRemoveMemberBuilder` | +| 9002 | NIP-29 | `RoomEdit` / `RoomEditBuilder` | +| 9007 | NIP-29 | `RoomCreate` / `RoomCreateBuilder` | +| 9008 | NIP-29 | `RoomDelete` / `RoomDeleteBuilder` | +| 9021 | NIP-29 | `RoomJoin` / `RoomJoinBuilder` | +| 9022 | NIP-29 | `RoomLeave` / `RoomLeaveBuilder` | +| 19004 | NIP-29 / Flotilla | `RoomCreatePermission` / `RoomCreatePermissionBuilder` | +| 39000 | NIP-29 | `RoomMeta` / `RoomMetaBuilder` | +| 39001 | NIP-29 | `RoomAdmins` / `RoomAdminsBuilder` | +| 39002 | NIP-29 | `RoomMembers` / `RoomMembersBuilder` | + +`RoomMeta`: `name`, `about`, `picture`, `pictureMeta`, `isClosed`/`isHidden`/`isPrivate`/`isRestricted`/`hasLivekit`. `RoomEdit` mirrors it but its livekit getter is named `livekit()`. `RoomJoin`: `code()` (reads the `claim` tag), `reason()`; builder `setCode`/`setReason`. + +### Relay membership (Flotilla "spaces" — relay-level, NIP-29-adjacent) + +| Kind | NIP | Reader / Builder | +|---|---|---| +| 8000 | Flotilla | `RelayAddMember` / `RelayAddMemberBuilder` | +| 8001 | Flotilla | `RelayRemoveMember` / `RelayRemoveMemberBuilder` | +| 13534 | Flotilla | `RelayMembers` / `RelayMembersBuilder` | +| 28934 | Flotilla | `RelayJoin` / `RelayJoinBuilder` | +| 28935 | NIP-29 | `RelayInvite` / `RelayInviteBuilder` | +| 28936 | Flotilla | `RelayLeave` / `RelayLeaveBuilder` | + +### Handlers (NIP-89) + +| Kind | NIP | Reader / Builder | +|---|---|---| +| 31989 | NIP-89 | `HandlerRecommendation` / `HandlerRecommendationBuilder` | +| 31990 | NIP-89 | `Handler` / `HandlerBuilder` | + +`Handler`: JSON content → `values: HandlerMeta`; getters `name`, `about`, `picture`, `website`, `lud16`, `nip05`, `kinds()`; builder `setName`/…/`setKinds(number[])`. Exports type `HandlerMeta`. + +### Zaps (NIP-57 / NIP-75) + +| Kind | NIP | Reader / Builder | +|---|---|---| +| 9041 | NIP-75 | `ZapGoal` / `ZapGoalBuilder` | +| 9734 | NIP-57 | `ZapRequest` / `ZapRequestBuilder` | +| 9735 | NIP-57 | `ZapReceipt` / `ZapReceiptBuilder` | + +`ZapRequest`: `amount`, `lnurl`, `recipient`, `eventId`, `urls`, `comment`. `ZapReceipt`: `bolt11`, `invoiceAmount`, `request`, `sender`, `recipient`, `eventId`, `comment`, `preimage`, plus `verify(zapper)`. + +### Content + +| Kind | NIP | Reader / Builder | +|---|---|---| +| 11 | NIP-7D | `Thread` / `ThreadBuilder` | +| 1018 | NIP-88 | `PollResponse` / `PollResponseBuilder` | +| 1068 | NIP-88 | `Poll` / `PollBuilder` | +| 1111 | NIP-22 | `Comment` / `CommentBuilder` | +| 1984 | NIP-56 | `Report` / `ReportBuilder` | +| 30402 | NIP-99 | `Classified` / `ClassifiedBuilder` | +| 31890 | NIP-51 | `Feed` / `FeedBuilder` | +| 31923 | NIP-52 | `TimeEvent` / `TimeEventBuilder` | + +`Comment`: `root()`/`parent()`; builder `setRoot`/`setParent`/`setRootFromEvent`/`setParentFromEvent`. `Poll`: `title`, `options`, `pollType`, `endsAt`, `isClosed`, `urls`, plus `results(responses)`; builder `addOption`, `setPollType`, `setEndsAt`. Exported types: `CommentRef`, `ClassifiedPrice`, `PollType`, `PollOption`, `PollResult`. + +## Gotchas + +- **Private list tags need the author's signer.** `await FollowList.fromEvent(event)` with no signer (or someone else's) yields only public tags; `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`. diff --git a/skills/welshman-util/SKILL.md b/skills/welshman-util/SKILL.md index f7e1adc..cd42ce6 100644 --- a/skills/welshman-util/SKILL.md +++ b/skills/welshman-util/SKILL.md @@ -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) // 5000–7000 | `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` | 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.