Files
welshman/docs/domain/rooms.md
T
hodlbod 5b8fef5b23
tests / tests (push) Failing after 5m8s
Fix NIP conformance in domain kinds; add domain docs/skill
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
2026-06-20 14:55:21 +00:00

5.5 KiB
Raw Blame History

Rooms

NIP-29 rooms are relay-hosted groups. @welshman/domain models the room metadata kinds plus the full set of moderation/membership operations. There are two flavors of event here:

  • Addressable metadata (kinds 3900039002) — replaceable, identified by a d tag, written by the relay. These are the canonical state of a room.
  • Action ops (kinds 90009022, 19004) — regular events scoped to a target room by the NIP-29 h group tag (set via the base setGroup / clearGroup, read via group()).

All of these are plain EventReader / EventBuilder subclasses — none of them are encrypted lists. See Readers & Builders 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

  • 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 — the base pattern, the h/group tag handling (setGroup/clearGroup/group()), and d-tag validation for the addressable kinds.
  • ListsRoomList (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.