20 KiB
| name | description |
|---|---|
| flotilla-model | Use this skill when working on how flotilla speaks nostr: publishing or reading content in a space or room, adding or changing an event kind, NIP-29 room state, moderation and room invites, NIP-43 space membership and invite links, NIP-86 relay management and admin gating, NIP-42 auth or NIP-70 protected events, deciding which relays an event goes to, or replacing code that hand-parses tags or builds events with makeEvent. |
Flotilla's nostr model
Flotilla treats a relay as a community (a space) and a NIP-29 group on that relay as a channel (a
room). Most of the protocol logic lives below the app: @welshman/domain has a Reader/Writer pair
per kind, and @welshman/app plugins (Rooms, RelayMemberLists, RelayManagement, …) assemble
relay state out of those readers. Flotilla's own protocol code is a thin layer on top, mostly in
src/app/access.ts, src/app/rooms.ts, src/app/management.ts and the components that publish.
The per-feature kind inventory is in kinds.md.
Spaces and rooms
A space is a relay URL. No event defines one; the normalized URL is the identity, and routes
carry it as encodeRelay(url) (src/app/relays.ts) under /spaces/[relay]. Any relay can be
opened as a space. NIP-29 support only decides whether it has rooms.
A room is a NIP-29 group h on that relay. The same h can exist independently on several
relays, so anything room-scoped is keyed by both: makeRoomKey(url, h) from @welshman/app gives
${url}'${h}, and isRoomId in src/app/rooms.ts tests for the '. Other relay-scoped keys use
| (${url}|${d} for relay roles and NIP-CD commands) so the two can't collide. Room content
carries ["h", h]; content with no h belongs to the whole space. /spaces/[relay]/chat
(makeSpaceChatPath) is the space-wide chat, which is RoomChat with no h.
The user's spaces are their kind 10009 ROOMS list (RoomList factory, RoomLists plugin):
r tags for spaces, group tags for rooms with the space URL as the hint. userSpaceUrls and
deriveUserRooms(url) in src/app/rooms.ts read it. "Joined" in the UI means "in this list",
which is separate from NIP-43 membership on the relay: src/routes/spaces/[relay]/+layout.svelte
prompts SpaceJoin for any URL not in userSpaceUrls. A deployment with VITE_PLATFORM_RELAYS
uses PLATFORM_RELAYS in place of the list for sync and navigation.
NIP-11 relay info
The Relays plugin (relays in src/app/core.ts) fetches each relay's NIP-11 document into a
domain Relay. These fields drive protocol decisions:
| Read | Decides |
|---|---|
hasNip(29) |
whether the space has rooms. Without it everything lives in the space chat: makeSpaceEntryPath (src/app/routes.ts), shareEvent (src/app/share.ts), room search, notification grouping, SpaceMenuRooms |
hasNip(70) |
whether space content is marked protected (below) |
self |
the relay's own pubkey, the trust anchor for relay-signed state |
pubkey |
the space's owner, who writes space-wide content that has no other author (deriveUserIsSpaceOwner) |
redirect_to |
the relay has moved. The space layout offers SpaceRedirect, which runs roomLists.migrateRelay and goToMovedSpace |
hasNip(50), hasNip("BUD-02"), hasNip("9a") |
search, blossom uploads, push |
Relay-signed state
The relay publishes NIP-29 and NIP-43 state under its NIP-11 self key: room metadata, admins,
members and pins, the space member list, and roles. Anyone can publish events of those kinds, so
readers must check the author. The welshman collections do: Rooms and every
RelaySignedDerivedPlugin (RelayMemberLists, RelayRoles, RoomPinLists) drop events whose
author isn't the relay's self, and re-check when NIP-11 loads. For a relay-authored kind with no
plugin, filter deriveEventsForUrl(url, filters) on that self key yourself rather than reading a
bare deriveEventsForUrl.
The app signs everything it publishes with the user's own key. Space-wide content with no author of
its own belongs to the space's owner, the pubkey NIP-11 names: featured content
(setFeaturedContent in src/app/featured.ts) is published by that person and read back scoped to
them, so deriveUserIsSpaceOwner(url) is what shows the editor.
The library is written by its members. A shelf or a pin is signed with the member's own key and
published to the space like any other space content, so the library reads every PINBOARD seen on
the relay rather than only the relay's own. Whoever signed a shelf is the only one who can edit or
delete it, and anyone can pin to it.
NIP-29 rooms
| Constant | Kind | Factory | Author | Flotilla use |
|---|---|---|---|---|
ROOM_META |
39000 | RoomMeta |
relay | name, about, picture, flags |
ROOM_ADMINS |
39001 | RoomAdmins |
relay | room admins |
ROOM_MEMBERS |
39002 | RoomMembers |
relay | member snapshot |
ROOM_PINS |
39005 | RoomPins |
relay | pinned messages |
ROOM_ADD_MEMBER / ROOM_REMOVE_MEMBER |
9000 / 9001 | RoomAddMember / RoomRemoveMember |
admin | addRoomMembers, RoomMemberMenu |
ROOM_EDIT_META |
9002 | RoomEdit |
admin | rooms.editRoom |
ROOM_CREATE / ROOM_DELETE |
9007 / 9008 | RoomCreate / RoomDelete |
admin | RoomForm, RoomDetailMenu |
none (ROOM_CREATE_INVITE in src/app/access.ts) |
9009 | none | admin | publishRoomInvite |
ROOM_UPDATE_PINS |
9010 | RoomUpdatePins |
admin | roomPinLists.setPins |
ROOM_JOIN / ROOM_LEAVE |
9021 / 9022 | RoomJoin / RoomLeave |
user | joinRoom / leaveRoom in src/app/access.ts |
ROOM_CREATE_PERMISSION |
19004 | RoomCreatePermission |
not checked | deriveUserCanCreateRoom |
@welshman/util also defines ROOM_ADD_PERM (9003), ROOM_REMOVE_PERM (9004),
ROOM_DELETE_EVENT (9005) and ROOM_EDIT_STATUS (9006); flotilla uses none of them. An admin
removes a message with NIP-86 banEvent instead (RoomItemMenu, EventMenu).
How Rooms builds a room
rooms.get().forRoom(url, h) yields {id, url, h, meta, members, admins} from the three
relay-signed state kinds. A ROOM_DELETE tombstones the room when it is at least as new as all of
that state, so a room re-created after deletion comes back. Membership (members(url, h),
membershipStatus(url, h)) replays the 39002 snapshot, then newer 9000/9001 ops authored by an
admin or the relay, then pending 9021/9022 requests, into MembershipStatus.Initial | Pending | Granted. pendingJoins(url, h?) lists unanswered join requests, and deriveSpaceActionItems
(src/app/actionItems.ts) merges them with reports into the admin queue.
src/app/rooms.ts puts space authority on top:
deriveUserIsRoomAdmin: a space admin administers every room.deriveUserRoomMembershipStatus: an admin is alwaysGranted.addRoomMembers: allows each non-member at the relay (NIP-86allowPubkey) before publishing 9000, because a room member the relay won't serve can't read the room.deriveUserRooms,deriveOtherRooms,deriveOtherVoiceRooms: rooms from the user's 10009 list and the rest of the space, limited to rooms the relay still advertises.meta.hasLivekit()marks a voice room.
Room flows
- Create (
RoomForm.svelte):rooms.createRoom(9007,hfromrandomId()), tolerating an "already" error, thenrooms.editRoom(9002) with metadata and flags, thenjoinRoom. - Delete (
RoomDetailMenu.svelte):rooms.deleteRoom(9008), thenroomLists.removeRoom. - Join and leave (
joinRoom,leaveRoominsrc/app/access.ts): two publishes, the 9021/9022 the relay may refuse and the user's 10009 list, which is what puts the room in the sidebar.isMembershipRefusalcountsduplicate:and "already a member" replies as success. - Invite (
publishRoomInvite): a 9009 with a randomcodetag. The link carrieshandcode, andjoinRoom(url, h, code)sends the code as the join'sclaim. - Pins:
roomPinLists.setPins(url, h, pins)sends 9010 and the relay republishes 39005.deriveRoomPinnedEvents(src/app/roomPins.ts) loads the pinned events from the room's relay.
The relay enforces the RoomMetaReader flags (isClosed, isHidden, isPrivate,
isRestricted); the UI only reflects them. Until membership is Granted, RoomChat hides a
private room's messages and blocks posting to a restricted room, and RoomDetail describes the
flags.
NIP-43 space membership
| Constant | Kind | Factory | Flotilla use |
|---|---|---|---|
RELAY_MEMBERS |
13534 | RelayMembers |
relay-signed member list, relayMemberLists.forUrl(url) |
RELAY_ADD_MEMBER / RELAY_REMOVE_MEMBER |
8000 / 8001 | RelayAddMember / RelayRemoveMember |
membership ops; the space chat shows 8000 the way a room shows 9000 |
RELAY_JOIN |
28934 | RelayJoin |
join request carrying a claim (publishJoinRequest) |
RELAY_INVITE |
28935 | RelayInvite |
unused since claims moved to NIP-86 |
RELAY_LEAVE |
28936 | RelayLeave |
publishLeaveRequest |
RELAY_ROLE |
33534 | RelayRole |
relay-signed role definitions (relayRoles) |
Roles are a flotilla extension. A member tag is ["member", pubkey, ...roleIds], and
deriveSpaceMemberRoles in src/app/roles.ts parses the role ids because RelayMembersReader
has no getter for them yet. Roles are only ever changed over
NIP-86 (createRole, editRole, deleteRole, assignRole, unassignRole), never by publishing
33534.
Joining a space (attemptRelayAccess and Access in src/app/access.ts):
- Open the socket and drive NIP-42 auth, retrying up to three times.
- Publish
RelayJoinwith the claim. The writer protects the event and requires a forced relay. - Translate refusals: "invite code" means rejected, "claim" means the space needs an invite.
completeJoin:roomLists.addRelay(url), restart sync, andSync.pushthe user'sRELAYS,MESSAGING_RELAYS,FOLLOWSandPROFILEto the space so other members can see them.
Invite links are ${PLATFORM_URL}/join?r=<relay>&c=<claim>, plus h and code for a room
(makeInviteLink; parseInviteLink also accepts a bare relay URL). src/routes/join renders
SpaceInviteAccept, which calls Access.acceptInvite to join the space and then the room.
Access.prepareInvite gets a claim over NIP-86 (supportedmethods, then listclaims, then
createclaim); this replaced reading RELAY_INVITE events. Leaving (SpaceExit,
SpaceAuthError) is roomLists.removeRelay(url) plus publishLeaveRequest(url).
NIP-86 relay management
relayManagement.get().forUrl(url) returns welshman's ManagementApi: JSON-RPC over HTTP at the
relay's URL, each call signed with a fresh NIP-98 event. Every method resolves to
{result, error}.
| Methods | Called from |
|---|---|
supportedMethods |
deriveSpaceSupportedMethods (src/app/management.ts), Access.prepareInvite |
banPubkey, unbanPubkey, allowPubkey, unallowPubkey, listBannedPubkeys |
ProfileDetail, SpaceMemberMenu, SpaceMemberBannedMenu, SpaceInvite, ReportMenuList, addRoomMembers |
banEvent |
RoomItemMenu, EventMenu, ReportMenuList, RoomJoinItem (dismissing a join request) |
createRole, editRole, deleteRole, assignRole, unassignRole |
RoleCreate, RoleEdit, SpaceRoleMenu, SpaceMemberRoles, RoleAddMembers |
listClaims, createClaim |
Access.prepareInvite |
changeRelayName, changeRelayDescription, changeRelayIcon |
SpaceEdit |
Admin status is inferred. A relay answers supportedmethods with everything it implements rather
than what the caller may use, and refuses non-admins outright, so deriveUserIsSpaceAdmin(url)
only means the list came back non-empty (re-checked at most every five minutes per URL). To gate
one capability, check the method ($supportedMethods.includes("banpubkey")) and still handle an
error from the call, since a listed method can be blocked for a particular user.
deriveUserCanCreateRoom adds ROOM_CREATE_PERMISSION grants to space admins.
The hosting backend in src/app/hosting.ts is a separate HTTP API at HOSTING_BACKEND_URL for
relays the platform hosts. It authenticates with one NIP-98 header per pubkey, cached for a TTL,
instead of signing every call. spaces/[relay]/admin is its page, not a NIP-86 console. Hosting is
off on iOS (HOSTING_ENABLED).
Auth, trust and protected events
- NIP-42.
authPolicyinsrc/app/policies.tsnever authenticates to a relay on the user's blocked-relay list and always does underrelay_auth: aggressive. Under the defaultconservative, it authenticates only to relays in the user's room, relay or messaging-relay lists, or ones they have published to this session.attemptRelayAccessauthenticates explicitly before a join. - Refusals.
mostlyRestrictedPolicycountsrestricted:andblocked:replies per socket. Once most requests fail,relaysMostlyRestrictedturns the space status to "Access Denied" and the layout showsSpaceAuthError. - Unsigned events. Some relays strip signatures (hosted relays have a
policy_strip_signaturesflag).ingestPolicyandtrustPolicyhold back unsigned events unless the relay is in thetrusted_relayssetting, whileSpaceTrustRelayasks the user. - NIP-70. Space content is protected exactly when the relay advertises NIP-70, so every space
publish passes
setProtected(await relays.hasNip(url, 70)). Per NIP-70 (a protocol claim not verified in this repo) a relay then accepts the event only from its author, which keeps space content from being copied elsewhere. TheRelayJoin,RelayLeaveandRelayMemberswriters protect themselves, andpublishReactionandretractReactioninsrc/app/reactions.tscheck the relay for their callers.
Which relays an event goes to
Space content goes to the space relay and nowhere else. The writer's routes decide where
command.publish() sends an event. See flotilla-state for the publishing pipeline itself.
| Tool | Effect | Use for |
|---|---|---|
writer.setRoom(url, h) |
adds ["h", h] and forces relay(url) |
anything in a room |
writer.forceRoutes(relay(url)) |
forces the relay, no h |
space-wide content, NIP-43 requests |
| default routes | the user's outbox plus inboxes of p-tagged pubkeys |
profile, lists, settings, anything outside a space |
command.publishToRelays(urls) |
ignores the writer's relays | reactions, deletes, reports, comments, replies |
wraps.get().publish({event, recipients}) |
a NIP-59 wrap per recipient, to their MESSAGING_RELAYS |
DMs and DM reactions and deletes |
validate() throws when an h tag has no forced route, and every room and relay-membership writer
sets requiresRelays, so a missing relay fails loudly. A content form forces the space relay and
adds setRoom only when it is posting into a room, as ThreadCreate, ClassifiedForm,
CalendarEventForm, GoalCreate, PollCreate and the article create route all do.
Some routing is built into welshman. Reactions.react and Deletes.deleteEvent find the target's
relay in the tracker and copy its h. The 10009 writer publishes to the user's outbox and to every
space it lists or used to list, so each relay hears about joins and leaves. Kind 9 has no factory,
so RoomChat and publishRoomQuote publish raw templates through Thunks.
Reads are scoped the same way. Request from the space relay (relays: [url], plus "#h": [h] for
a room) and read results with deriveEventsForUrl(url, filters), which uses the tracker to keep
only events seen on that relay. A repository-wide query would mix rooms that share an h across
relays. src/app/sync.ts pulls each space's room state, membership and recent content in the
background; see flotilla-state.
Content kinds
The space sections are the kinds in CONTENT_KINDS (src/app/content.ts): ZAP_GOAL,
EVENT_TIME, THREAD, CLASSIFIED, POLL, PINBOARD and LONG_FORM. Each has list and detail
routes under spaces/[relay]/, reached through makeContentPath in src/app/routes.ts. Chat is
MESSAGE (kind 9). Comments (COMMENT, NIP-22) thread under any content kind, and
makeCommentFilter(kinds) selects them by #K. A content form with shareToChat set also posts
a kind 9 quoting the new event (publishRoomQuote), so clients that only render chat still see
it. Zaps and goals are off on iOS (ENABLE_ZAPS in src/app/env.ts).
kinds.md lists every kind by feature, with its factory, module and route.
Adding a kind
- Add the constant to
@welshman/utiland a factory to@welshman/domainupstream (see welshman-domain, "Adding a new kind"). - Publish through
writer(Factory)andcommand(...), withsetRoomorforceRoutesandsetProtected(await relays.hasNip(url, 70))for space content. - For a new space section, add the kind to
CONTENT_KINDS(which feeds sync, notifications, push, search andSpaceMenuNavItems),CONTENT_NOUNSandmakeContentPath, and add routes underspaces/[relay]/. - If users sign it through a remote signer, add it to
NIP46_PERMSinsrc/app/nip46.ts. - If it is relay-scoped state that has to survive a reload, add it to the
kindsmap insrc/app/storage.ts. - If the relay signs it, read it through a
RelaySignedDerivedPlugin.
Parsing and building events
AGENTS.md gives the rule. In protocol code a hand-built event also loses behavior the writer
carries:
RelayJoinWriter adds -, DeleteWriter.addEvent copies the target's h and routes to its
relay, RoomListWriter publishes to every listed space, and validate() refuses a room event with
no relay.
In order of preference:
- The kind's reader:
reader(Thread)(event).title(),reader(TimeEvent)(event).start(). - Base reader getters when the kind is known (
room(),protect(),expiration(),imeta()), or the standalone helpers when it isn't (getImeta,getExpiration,getReplyTags,getCommentTagValues,getEmojis). tagValue(tagSpec("h"), event.tags)in code that handles many kinds at once (notifications,makeEventPath, feed grouping), where no single reader applies.
When welshman lacks a kind or a getter, add it there rather than working around it in the app. A
tag spec is the right tool only for a tag nothing else will read, like the content tags on
featured-content app data.
Known exceptions
These predate the rule or are waiting on welshman. Don't copy them; fix one when you touch it.
- No factory yet:
MESSAGE(9) inRoomChatandpublishRoomQuote; 9009 room invites insrc/app/access.ts;LIVEKIT_PARTICIPANTS(39004, a local constant insrc/app/call.ts);STATUS(30315) insrc/app/statuses.ts, whereProfileStatusstill usestags.find; push subscriptions (a literal 30390) insrc/app/push/adapters/capacitor.ts;DIRECT_MESSAGE_FILE(15) inChat.svelte. - Factory exists but bypassed: DMs built with
makeEventinChat.svelte(DirectMessage); relay lists inSignUp.svelte(RelayList,MessagingRelayList); deletes inProfileDelete.svelteand the push adapter (Delete); the vanish request as a literal62(VANISH). - Getter exists but bypassed: titles in
src/app/title.ts,NoteContentThreadand the threads pages; calendarstart/endinsrc/app/feeds.tsand the calendar page; pollresponsetags inPollVotes;ptags ofRELAY_ADD_MEMBER,ROOM_ADD_MEMBERandROOM_CREATE_PERMISSION(SpaceMembersSummary,RoomItemAddMember,src/app/management.ts); commentE/Atags in the list pages andsrc/app/classifieds.ts;imetaunpacking insrc/app/content.tsandChat.svelte. - Getter missing upstream: role ids on
membertags (src/app/roles.ts); the legacynamefallback for calendar titles (CalendarEventHeader).
Mind the name clash: ROOM from @app/rooms is the tag name "h", while ROOM from
@welshman/util is kind 35834.
Related skills
- flotilla-architecture: where protocol code sits among the layers and modules
- flotilla-state: the
App, plugins, background sync, persistence, and the publishing pipeline - flotilla-views: routes, components, and loading data from components
- welshman-domain: readers, writers, and adding a kind
- welshman-util: kind constants, tag specs,
RelaySelectionrouting, NIP-42/86/98 helpers - welshman-app:
Rooms,RelayManagement,RelaySignedDerivedPlugin,Command,Wraps - welshman-net: sockets, auth state and socket policies