diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md index e4297252..5b7ec557 100644 --- a/.agents/skills/flotilla-architecture/SKILL.md +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -100,7 +100,7 @@ only gate. - `access.ts`: joining, invites, relay auth errors - `management.ts` (NIP-86 admin checks, bans), `roles.ts` (member roles) - `actionItems.ts`: the admin review queue (reports and pending joins) -- `featured.ts` (relay-signed featured content), `roomPins.ts`, `commands.ts` (NIP-CD slash +- `featured.ts` (the space owner's featured content), `roomPins.ts`, `commands.ts` (NIP-CD slash commands) - `hosting.ts`: client for the hosting backend's HTTP API diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index 593e7841..fbd856f2 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -43,6 +43,7 @@ domain `Relay`. These fields drive protocol decisions: | `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 | @@ -53,13 +54,13 @@ members and pins, the space member list, and roles. Anyone can publish events of 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 through `deriveRelaySignedEvents(url, filters)` in `src/app/repository.ts`, as -`src/app/featured.ts` does, rather than a bare `deriveEventsForUrl`. +plugin, filter `deriveEventsForUrl(url, filters)` on that `self` key yourself rather than reading a +bare `deriveEventsForUrl`. -Content the space owns is published as the relay: `command.publishAsRelay(url)` has the relay -sign the event with its own key through the NIP-86 `signevent` method, then sends it back. Featured -content (`setFeaturedContent` in `src/app/featured.ts`) is written this way, and only users the -relay allows to call `signevent` can write it. +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 @@ -170,12 +171,11 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` | | `listClaims`, `createClaim` | `Access.prepareInvite` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | -| `signEvent` | `Command.publishAsRelay` | 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("signevent")`) and still handle an +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. @@ -215,7 +215,6 @@ Space content goes to the space relay and nowhere else. The writer's routes deci | `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 | -| `command.publishAsRelay(url)` | the relay signs via NIP-86 `signevent` | space-owned content | | `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 @@ -258,7 +257,7 @@ it. Zaps and goals are off on iOS (`ENABLE_ZAPS` in `src/app/env.ts`). 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` or `deriveRelaySignedEvents`. +6. If the relay signs it, read it through a `RelaySignedDerivedPlugin`. ## Parsing and building events diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index 80f61fd6..bbd2ff39 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -179,7 +179,6 @@ space content be keyed by relay. - `deriveEvent`, `deriveEvents`, `deriveEventsById`, `deriveIsDeleted` - relay-scoped: `deriveEventsForUrl`, `deriveEventsByIdForUrl`, `deriveEventsByIdByUrl`, `getEventsForUrl` -- `deriveRelaySignedEvents`, which keeps only events signed by the relay's NIP-11 `self` key - `deriveLatestEvent` Use these for raw event queries. Welshman's `Events` plugin has the same surface returning @@ -352,7 +351,7 @@ if (error) { ``` `publish` sends to the writer's own routes, which is why the excerpt forces them with -`forceRoutes`. `publishToRelays(urls)` and `publishAsRelay(url)` override those routes instead. +`forceRoutes`. `publishToRelays(urls)` overrides those routes instead. flotilla-model's "Which relays an event goes to" says which one each kind needs. Plugin mutators already return a `Command`: `roomLists.get().addRelay(url).then(publish)`, diff --git a/.agents/skills/welshman-app/SKILL.md b/.agents/skills/welshman-app/SKILL.md index ed0af0a3..4d2391e0 100644 --- a/.agents/skills/welshman-app/SKILL.md +++ b/.agents/skills/welshman-app/SKILL.md @@ -96,7 +96,7 @@ setNip55Plugin(NostrSignerPlugin) All follow the same shape — `get(key)` (sync), `one(key)` (reactive, lazy-loads), `load(key)`/`forceLoad(key)` (promises), plus convenience accessors returning `Projection`. Resolve with `app.use(...)`. -Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` (or `.publishAsRelay(url)`) on the result to actually send it. See [Commands](#commands-deferred-publishing) below. +Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) is `async` and returns a **`Command`**, not a `Thunk` — it builds the event but does not publish it. Call `.publish()` on the result to actually send it. See [Commands](#commands-deferred-publishing) below. | Plugin | Data | Notable accessors | |---|---|---| @@ -115,7 +115,7 @@ Every mutation method (`create`/`update`/`follow`/`addRelay`/`setRelays`/etc.) i | `Pinboards` | kind-30067 pinboards (many per author, keyed by address) | `forAuthor(pk)`, `loadForAuthor(pk)`, `create(fields)`, `update(addr, fn)` → `Command` | | `Pins` | kind-39067 pins (keyed by address; each pin has its own `d` tag) | `forBoard(addr)`, `forProfile(pk)`, `loadForBoard(addr)`, `loadForProfile(pk)`, `create`, `update`, `addToBoard`, `removeFromBoard` → `Command` | | `Relays` | NIP-11 relay info (HTTP) | `display(url)`, `hasNip(url, n)`, `hasNegentropy(url)`; `relaySearch` | -| `RelayManagement` | NIP-86 mgmt API | `forUrl(url)` → a `ManagementApi` client that signs auth as the app's user (`forUrl(url).signEvent(event)`, role/member ops, …) | +| `RelayManagement` | NIP-86 mgmt API | `forUrl(url)` → a `ManagementApi` client that signs auth as the app's user (role/member ops, ban/allow, …) | | `RelayStats` | per-relay connection counters | `get(url)`, `getQuality(url)` (0–1, drives router ranking) | | `RelayRoles` / `RelayMemberLists` / `RoomPinLists` | relay-signed state, keyed per relay | relay-scoped collections (see `RelaySignedDerivedPlugin`) | | `Handles` | NIP-05 (HTTP, batched) | `forPubkey(pk)`, `display(nip05)`, `loadForPubkey(pk)` | @@ -145,9 +145,9 @@ const writeRelays = app.use(RelayLists).writeUrls(pubkey).get() // string[] // Mutations return a Command — build it, then decide how to publish it const command = await app.use(RelayLists).addWriteUrl("wss://relay.example") command.publish() // normal outbox/relays flow via Thunks -// or: command.publishAsRelay("wss://relay.example") // sign + send straight to one relay (NIP-86 style) +// or: command.publishToRelays(["wss://relay.example"]) // send straight to one relay -// Since these methods are async, `publish`/`publishAsRelay` free functions avoid a double-await: +// Since these methods are async, `publish`/`publishToRelays` free functions avoid a double-await: import {publish} from "@welshman/app" await app.use(RelayLists).addWriteUrl("wss://relay.example").then(publish) ``` @@ -203,21 +203,18 @@ command.relays // string[] — where publish() will send it command.publish() // normal path: app.use(Thunks).publish({event, relays: command.relays}) command.publishToRelays(urls) // publish to a specific relay set instead of command.relays -command.publishAsRelay(url) // NIP-86: the relay signs the event with its own key - // (signevent), then publish the relay-signed event back to `url` -command.signAsRelay(url) // just the NIP-86 signevent step (returns {result, error}) ``` This lets a caller preview/log a command, choose a different transport, or drop it entirely, instead of every plugin method publishing unconditionally. `Wraps.publish` is the one exception — it fans a single rumor out to a `MergedThunk` of per-recipient wraps (each with its own relays), which doesn't fit the one-event/one-relay-set `Command` shape, so it still publishes directly. -`publish`/`publishToRelays`/`publishAsRelay`/`signAsRelay` are also exported as free functions (e.g. `(command) => command.publish()`, `(url) => (command) => command.publishAsRelay(url)`) so you can chain straight off the mutation method's promise instead of double-awaiting: +`publish`/`publishToRelays` are also exported as free functions (e.g. `(command) => command.publish()`, `(urls) => (command) => command.publishToRelays(urls)`) so you can chain straight off the mutation method's promise instead of double-awaiting: ```typescript -import {publish, publishAsRelay} from "@welshman/app" +import {publish, publishToRelays} from "@welshman/app" await app.use(FollowLists).follow(["p", otherPubkey]).then(publish) await app.use(Rooms).leave(relayUrl, roomMeta).then(publish) -await app.use(Rooms).join(relayUrl, roomMeta).then(publishAsRelay(relayUrl)) +await app.use(Rooms).join(relayUrl, roomMeta).then(publishToRelays([relayUrl])) ``` ## Requests & sync diff --git a/AGENTS.md b/AGENTS.md index 4f37ef36..9012ef37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,7 @@ callbacks and hot paths. 1. Build a writer: `app.use(Domain).writer(Kind, reader?)`, then chain its setters 2. Wrap it: `const command = await app.use(Domain).command(writer)` -3. Publish it: `command.publish()`, `.publishToRelays(urls)`, or `.publishAsRelay(url)` +3. Publish it: `command.publish()` or `.publishToRelays(urls)` 4. Display thunk status to user (for cancel/error handling) Plugin mutators (`app.use(FollowLists).follow(...)`, `app.use(Rooms).joinRoom(...)`, …) already diff --git a/src/app/components/SpaceFeaturedContent.svelte b/src/app/components/SpaceFeaturedContent.svelte index e37b5eda..2c6d5dab 100644 --- a/src/app/components/SpaceFeaturedContent.svelte +++ b/src/app/components/SpaceFeaturedContent.svelte @@ -7,7 +7,7 @@ import Content from "@app/components/Content.svelte" import EditFeaturedContent from "@app/components/EditFeaturedContent.svelte" import {deriveFeaturedContent} from "@app/featured" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveUserIsSpaceOwner} from "@app/management" import {pushModal} from "@app/modal" type Props = { @@ -17,7 +17,7 @@ const {url}: Props = $props() const content = deriveFeaturedContent(url) - const canEdit = deriveUserIsSpaceAdmin(url) + const canEdit = deriveUserIsSpaceOwner(url) const edit = () => pushModal(EditFeaturedContent, {url, initial: $content}) diff --git a/src/app/featured.ts b/src/app/featured.ts index 0b9a7c94..a78b4a21 100644 --- a/src/app/featured.ts +++ b/src/app/featured.ts @@ -1,37 +1,38 @@ import {derived} from "svelte/store" -import {APP_DATA, tagSpec, tagValues, relay} from "@welshman/util" +import {APP_DATA, relay, sortEventsDesc, tagSpec, tagValues} from "@welshman/util" import {AppData} from "@welshman/domain" -import {Domain} from "@welshman/app" -import {app} from "@app/core" -import {deriveRelaySignedEvents} from "@app/repository" +import {Relays, publish} from "@welshman/app" +import {command, fromApp, relays, writer} from "@app/core" +import {deriveEventsForUrl} from "@app/repository" -// NIP-78 app data published by the relay's self key. Each featured entry is a -// ["content", ] tag (freeform text, intended to be a url or nevent). +// NIP-78 app data published by the space's owner, the pubkey its NIP-11 document names. Each +// featured entry is a ["content", ] tag (freeform text, intended to be a url or nevent). export const FEATURED_CONTENT_D = "flotilla/featured-content" export const deriveFeaturedContent = (url: string) => derived( - deriveRelaySignedEvents(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}]), - ([event]) => tagValues(tagSpec("content"), event?.tags ?? []), + [ + fromApp($app => $app.use(Relays).one(url)), + deriveEventsForUrl(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}]), + ], + ([$relay, $events]) => { + const [event] = sortEventsDesc($events.filter(e => e.pubkey === $relay?.pubkey)) + + return tagValues(tagSpec("content"), event?.tags ?? []) + }, ) -// Publish the featured content list by asking the relay to sign it with its self -// key (the unofficial NIP-86 "signevent" method). export const setFeaturedContent = async (url: string, content: string[]) => { const tags = content .map(value => value.trim()) .filter(Boolean) .map(value => ["content", value]) - const writer = app - .get() - .use(Domain) - .writer(AppData) - .forceRoutes(relay(url)) + const eventWriter = writer(AppData) .setIdentifier(FEATURED_CONTENT_D) + .setProtected(await relays.get().hasNip(url, 70)) + .forceRoutes(relay(url)) .addTags(...tags) - const command = await app.get().use(Domain).command(writer) - - return command.publishAsRelay(url) + return command(eventWriter).then(publish) } diff --git a/src/app/management.ts b/src/app/management.ts index 126d7994..cd59269a 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -1,6 +1,7 @@ import {derived, readable, writable} from "svelte/store" import {ago, MINUTE, now, simpleCache} from "@welshman/lib" import {ROOM_CREATE_PERMISSION, hexTags, tagValues} from "@welshman/util" +import {Relays} from "@welshman/app" import {fromApp, relayManagement, user} from "@app/core" import {deriveEventsForUrl} from "@app/repository" @@ -59,6 +60,14 @@ export const deriveSpaceSupportedMethods = (url?: string) => export const deriveUserIsSpaceAdmin = (url?: string) => derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) +// The one identity a space names as its own, in its NIP-11 `pubkey`. Space-wide content with no +// author to scope it to belongs to that person. +export const deriveUserIsSpaceOwner = (url: string) => + derived( + [user, fromApp($app => $app.use(Relays).one(url))], + ([$user, $relay]) => $user.pubkey === $relay?.pubkey, + ) + export const deriveUserCanCreateRoom = (url: string) => derived( [ diff --git a/src/app/repository.ts b/src/app/repository.ts index 35ea1202..5a06d063 100644 --- a/src/app/repository.ts +++ b/src/app/repository.ts @@ -1,11 +1,11 @@ -import {derived, readable} from "svelte/store" +import {readable} from "svelte/store" import type {Unsubscriber} from "svelte/store" -import {filter, first, on, spec} from "@welshman/lib" +import {first, on} from "@welshman/lib" import type {Maybe} from "@welshman/lib" import {sortEventsDesc} from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" import * as store from "@welshman/store" -import {Network, Relays} from "@welshman/app" +import {Network} from "@welshman/app" import {app, fromApp} from "@app/core" // Events @@ -59,12 +59,6 @@ export const deriveEventsByIdByUrl = (filters: Filter[] = [{}]) => store.deriveEventsByIdByUrl({filters, tracker: $app.tracker, repository: $app.repository}), ) -export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) => - derived( - [fromApp($app => $app.use(Relays).one(url)), deriveEventsForUrl(url, filters)], - ([$relay, $events]) => filter(spec({pubkey: $relay?.self}), $events as TrustedEvent[]), - ) - // The most recent event held from one author. const latestByPubkey = new Map>()