Publish featured content as the space owner rather than as the relay (#564)

This commit is contained in:
Coracle-Bot 2026-09-17 17:26:40 +00:00 committed by hodlbod
parent b6ee3504be
commit fe15d36a45
9 changed files with 52 additions and 53 deletions

View file

@ -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

View file

@ -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

View file

@ -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)`,

View file

@ -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

View file

@ -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

View file

@ -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})
</script>

View file

@ -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", <value>] 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", <value>] 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)
}

View file

@ -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(
[

View file

@ -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<string, Maybe<TrustedEvent>>()