--- name: flotilla-model description: "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](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, keeping the half the user holds a method for — reports under `banevent`, join requests under `allowpubkey`. `src/app/rooms.ts` puts space authority on top: - `deriveUserIsRoomAdmin`: a space's staff administer every room. - `deriveUserRoomMembershipStatus`: an admin is always `Granted`. - `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) 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, `h` from `randomId()`), tolerating an "already" error, then `rooms.editRoom` (9002) with metadata and flags, then `joinRoom`. - **Delete** (`RoomDetailMenu.svelte`): `rooms.deleteRoom` (9008), then `roomLists.removeRoom`. - **Join and leave** (`joinRoom`, `leaveRoom` in `src/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. `isMembershipRefusal` counts `duplicate:` and "already a member" replies as success. - **Invite** (`publishRoomInvite`): a 9009 with a random `code` tag. The link carries `h` and `code`, and `joinRoom(url, h, code)` sends the code as the join's `claim`. - **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`): 1. Open the socket and drive NIP-42 auth, retrying up to three times. 2. Publish `RelayJoin` with the claim. The writer protects the event and requires a forced relay. 3. Translate refusals: "invite code" means rejected, "claim" means the space needs an invite. 4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS` and `PROFILE` to the space so other members can see them. Invite links are `${PLATFORM_URL}/join?r=&c=`, 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` | A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control is gated on the method behind it: `deriveSpaceSupportedMethods(url)` (re-checked at most every five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an error from the call, since a listed method can be refused for a particular event or target. `deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and `deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants. 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.** `authPolicy` in `src/app/policies.ts` never authenticates to a relay on the user's blocked-relay list and always does under `relay_auth: aggressive`. Under the default `conservative`, it authenticates only to relays in the user's room, relay or messaging-relay lists, or ones they have published to this session. `attemptRelayAccess` authenticates explicitly before a join. - **Refusals.** `mostlyRestrictedPolicy` counts `restricted:` and `blocked:` replies per socket. Once most requests fail, `relaysMostlyRestricted` turns the space status to "Access Denied" and the layout shows `SpaceAuthError`. - **Unsigned events.** Some relays strip signatures (hosted relays have a `policy_strip_signatures` flag). `ingestPolicy` and `trustPolicy` hold back unsigned events unless the relay is in the `trusted_relays` setting, while `SpaceTrustRelay` asks 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. The `RelayJoin`, `RelayLeave` and `RelayMembers` writers protect themselves, and `publishReaction` and `retractReaction` in `src/app/reactions.ts` check 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](kinds.md) lists every kind by feature, with its factory, module and route. ### Adding a kind 1. Add the constant to `@welshman/util` and a factory to `@welshman/domain` upstream (see welshman-domain, "Adding a new kind"). 2. Publish through `writer(Factory)` and `command(...)`, with `setRoom` or `forceRoutes` and `setProtected(await relays.hasNip(url, 70))` for space content. 3. For a new space section, add the kind to `CONTENT_KINDS` (which feeds sync, notifications, push, search and `SpaceMenuNavItems`), `CONTENT_NOUNS` and `makeContentPath`, and add routes under `spaces/[relay]/`. 4. If users sign it through a remote signer, add it to `NIP46_PERMS` in `src/app/nip46.ts`. 5. If it is relay-scoped state that has to survive a reload, add it to the `kinds` map in `src/app/storage.ts`. 6. 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: 1. The kind's reader: `reader(Thread)(event).title()`, `reader(TimeEvent)(event).start()`. 2. 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`). 3. `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) in `RoomChat` and `publishRoomQuote`; 9009 room invites in `src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`); `STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push subscriptions (a literal 30390) in `src/app/push/adapters/capacitor.ts`; `DIRECT_MESSAGE_FILE` (15) in `Chat.svelte`. - **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`); relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in `ProfileDelete.svelte` and the push adapter (`Delete`); the vanish request as a literal `62` (`VANISH`). - **Getter exists but bypassed:** titles in `src/app/title.ts`, `NoteContentThread` and the threads pages; calendar `start`/`end` in `src/app/feeds.ts` and the calendar page; poll `response` tags in `PollVotes`; `p` tags of `RELAY_ADD_MEMBER`, `ROOM_ADD_MEMBER` and `ROOM_CREATE_PERMISSION` (`SpaceMembersSummary`, `RoomItemAddMember`, `src/app/management.ts`); comment `E`/`A` tags in the list pages and `src/app/classifieds.ts`; `imeta` unpacking in `src/app/content.ts` and `Chat.svelte`. - **Getter missing upstream:** role ids on `member` tags (`src/app/roles.ts`); the legacy `name` fallback 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, `RelaySelection` routing, NIP-42/86/98 helpers - welshman-app: `Rooms`, `RelayManagement`, `RelaySignedDerivedPlugin`, `Command`, `Wraps` - welshman-net: sockets, auth state and socket policies