NIP fixes: - RelayMembers (13534): use NIP-43 `member` tags (not `p`) and set the required NIP-70 `-` protected tag. - Profile (kind 0): remove display-name support entirely (getter, setter, display() fallback, and the search weight). - Comment (1111): A/a tags now carry a real address, not the event id. - BlossomServerList (10063): normalize server URLs with normalizeUrl (HTTP), not normalizeRelayUrl (which forced wss://). - HandlerRecommendation (31989): fix inverted removeRecommendation filter; add setSupportedKind()/supportedKind() for the NIP-89 d-tag. - Report (1984): place the report-type string on the e tag (note reports) or p tag (profile reports); always emit the p tag. Docs/skills: - Add @welshman/domain docs (docs/domain/) and the welshman-domain skill. - Prune @welshman/util docs/skill of the moved Profile/List/Handler/Encryptable helpers; register domain in the sidebar, index, and skills README. - Apply accuracy fixes to the @welshman/app docs/skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsMjvv7krpZeHK1Njeneru
5.5 KiB
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
dtag, 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
hgroup tag (set via the basesetGroup/clearGroup, read viagroup()).
All of these are plain EventReader / EventBuilder subclasses — none of them are encrypted lists. See Readers & 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()).
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).
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.
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.
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
RoomJoinstores the invite under theclaimtag, but the accessor iscode()(and the setter issetCode).RoomCreatePermissionusessetPubkeys(pks)(replace-all), not anadd*method.RoomDelete,RoomLeave, andRoomJoinbuilders throw fromvalidate()if nohgroup is set — alwayssetGroup(roomId)first. :::
See also
- Readers & Builders — the base pattern, the
h/group tag handling (setGroup/clearGroup/group()), andd-tag validation for the addressable kinds. - Lists —
RoomList(kind 10009), the NIP-51 membership list that tracks which rooms a user belongs to. - Relay membership — the Flotilla relay-level (non-NIP-29) membership ops.