flotilla/.agents/skills/flotilla-model/SKILL.md

328 lines
22 KiB
Markdown

---
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: `activeSpaceUrls` in
`src/app/rooms.ts` is that choice, read by background sync, notifications and editor suggestions.
### 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, read through `spaceSupportsRooms(url)` / `deriveSpaceSupportsRooms(url)` in `src/app/relays.ts`. Without it everything lives in the space chat (`makeSpaceChatPath`): `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuNavItems`, `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, read `$events.relaySignedForUrl(url, filters)`, which keeps only events signed by that
`self` key, rather than a bare `$events.forUrl`.
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(url, type)`: rooms from the user's 10009 list and the rest
of the space by `RoomType`, limited to rooms the relay still advertises. `meta.hasLivekit()`
marks a voice room (`getRoomType`).
### 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.
`toggleRoomPin(url, h, id)` (`src/app/roomPins.ts`) wraps it for the message menus and toasts
the result. `deriveRoomPinnedEvents` 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 `completeSpaceJoin` 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: a `mute:` reply (`matchReason`) is ignored, and the free-text "invite code"
means rejected and "claim" means the space needs an invite.
4. `completeSpaceJoin`: opt into the space's notifications (`optInSpaceNotifications` in
`src/app/push`, which asks for push permission first when push is on), `roomLists.addRelay(url)`,
clear the relay from `relaysMostlyRestricted`, 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=<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 `acceptInvite` to join the space (skipped if already joined) and
then the room. `InviteLink`, rendered by both `SpaceInvite` and `RoomInvite`, gets a claim over
NIP-86 (`supportedmethods`, then `listclaims`, then `createclaim`), publishes a room invite code
for a room, and holds that state itself; 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`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) |
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` |
| `listClaims`, `createClaim` | `InviteLink` |
| `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 through `deriveSpacePermissions(url)`, a store of named booleans
(`$permissions.ban`, `$permissions.editRoles`). The composite gates a parent uses to decide whether
to render a menu (`memberMenu`, `roleMenu`, `bannedMenu`, `directoryMenu`) are defined there too,
so parent and menu can't disagree. The `SpaceManagement` plugin (`spaceManagement`) caches the
methods per app, re-checked at most every five minutes per URL, and `InviteLink` reads
them through `loadSupportedMethods`. It also holds the admin and ban lists, which
`loadMethodAssignees`/`loadBannedPubkeys` refresh after a change. 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 `Publisher`.
Reads are scoped the same way. Request from the space relay (`relays: [url]`, plus `"#h": [h]` for
a room) and read results with `$events.forUrl(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 (`PUSH_SUBSCRIPTION`, 30390, local to `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:** 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