From f10dc96c43fee6eb4cb8f6d9366fa112963af82e Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 25 Sep 2026 16:06:35 -0700 Subject: [PATCH] Keep NIP-86 state on a per-app SpaceManagement plugin and gate controls on named space permissions --- .agents/skills/flotilla-model/SKILL.md | 13 +- .agents/skills/flotilla-state/SKILL.md | 9 +- src/app/access.ts | 11 +- src/app/actionItems.ts | 10 +- src/app/components/ProfileDetail.svelte | 38 ++-- src/app/components/ReactionSummary.svelte | 7 +- src/app/components/ReportMenuList.svelte | 10 +- src/app/components/RoomJoinItem.svelte | 7 +- src/app/components/SpaceAdmins.svelte | 18 +- src/app/components/SpaceInvite.svelte | 10 +- src/app/components/SpaceMember.svelte | 14 +- .../components/SpaceMemberBannedMenu.svelte | 16 +- src/app/components/SpaceMemberMenu.svelte | 21 +-- src/app/components/SpaceMemberMethods.svelte | 25 ++- src/app/components/SpaceMemberRoles.svelte | 6 +- src/app/components/SpaceMembersBanned.svelte | 15 +- src/app/components/SpaceMenuActions.svelte | 16 +- .../components/SpaceMenuActionsMobile.svelte | 16 +- src/app/components/SpaceRoleMenu.svelte | 13 +- src/app/components/SpaceRoles.svelte | 12 +- src/app/management.ts | 174 ++++++++++++------ src/app/rooms.ts | 10 +- .../spaces/[relay]/directory/+page.svelte | 15 +- 23 files changed, 243 insertions(+), 243 deletions(-) diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index a379b596..fc1c53e5 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -166,7 +166,7 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | Methods | Called from | |---|---| -| `supportedMethods` | `deriveSpaceSupportedMethods` (`src/app/management.ts`), `Access.prepareInvite` | +| `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) | | `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` | @@ -174,9 +174,14 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | `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. +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 `Access.prepareInvite` 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. diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index a1d5fbe1..1028beb2 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -117,6 +117,7 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores. | `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey | | `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay | | `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s | +| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL | Each is exposed with `usePlugin`. `Statuses` is the minimal shape: @@ -197,12 +198,12 @@ export const deriveSpaceActionItems = (url: string) => [ deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$, - deriveSpaceSupportedMethods(url), + deriveSpacePermissions(url), ], - ([$reports, $pendingJoins, $methods]) => + ([$reports, $pendingJoins, $permissions]) => sortEventsDesc([ - ...($methods.includes("banevent") ? $reports : []), - ...($methods.includes("allowpubkey") ? $pendingJoins : []), + ...($permissions.deleteContent ? $reports : []), + ...($permissions.addMembers ? $pendingJoins : []), ]), ) ``` diff --git a/src/app/access.ts b/src/app/access.ts index 72120475..fac888f4 100644 --- a/src/app/access.ts +++ b/src/app/access.ts @@ -12,13 +12,13 @@ import { MESSAGING_RELAYS, PROFILE, RELAYS, - type ManagementResponse, } from "@welshman/util" import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain" import {Sync, User, publish} from "@welshman/app" import {stripPrefix} from "@lib/util" import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core" import {PLATFORM_URL} from "@app/env" +import {spaceManagement} from "@app/management" import {relaysMostlyRestricted} from "@app/policies" import {Push} from "@app/push" import {deriveSocket} from "@app/relays" @@ -389,18 +389,15 @@ export class Access { try { const management = relayManagement.get().forUrl(this.url) - const [{result: methods}, roomInviteResult] = await Promise.all([ - management.supportedMethods().catch((error): ManagementResponse => { - console.error(error) - return {} - }), + const [methods, roomInviteResult] = await Promise.all([ + spaceManagement.get().loadSupportedMethods(this.url), h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), // Keep the spinner up long enough that a fast relay doesn't make it flash sleep(300), ]) // A relay reporting methods relay-wide can still come back "blocked" for this user. - if (methods?.includes("createclaim")) { + if (methods.includes("createclaim")) { const {result: claims} = await management.listClaims() if (claims?.[0]) { diff --git a/src/app/actionItems.ts b/src/app/actionItems.ts index 38f1f2cb..379ba029 100644 --- a/src/app/actionItems.ts +++ b/src/app/actionItems.ts @@ -1,7 +1,7 @@ import {derived} from "svelte/store" import {REPORT, sortEventsDesc} from "@welshman/util" import {rooms} from "@app/core" -import {deriveSpaceSupportedMethods} from "@app/management" +import {deriveSpacePermissions} from "@app/management" import {deriveEventsForUrl} from "@app/repository" // Action items (admin review queue) @@ -12,11 +12,11 @@ export const deriveSpaceActionItems = (url: string) => [ deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$, - deriveSpaceSupportedMethods(url), + deriveSpacePermissions(url), ], - ([$reports, $pendingJoins, $methods]) => + ([$reports, $pendingJoins, $permissions]) => sortEventsDesc([ - ...($methods.includes("banevent") ? $reports : []), - ...($methods.includes("allowpubkey") ? $pendingJoins : []), + ...($permissions.deleteContent ? $reports : []), + ...($permissions.addMembers ? $pendingJoins : []), ]), ) diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index f6c40b6f..2df78d9c 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -1,5 +1,6 @@ @@ -53,7 +45,7 @@ - {#if canAssign || canUnassign || canUnallow || canBan || canEditMethods} + {#if $permissions.memberMenu}
history.back() + const permissions = deriveSpacePermissions(url) const unbanMember = async () => { const {error} = await $relayManagement.forUrl(url).unbanPubkey(pubkey) @@ -29,7 +25,7 @@ pushToast({theme: "error", message: error}) } else { pushToast({message: "User has successfully been removed from the ban list!"}) - back() + $spaceManagement.loadBannedPubkeys(url) } } @@ -40,7 +36,7 @@ pushToast({theme: "error", message: error}) } else { pushToast({message: "User has successfully been restored to membership!"}) - back() + $spaceManagement.loadBannedPubkeys(url) } } @@ -52,7 +48,7 @@
diff --git a/src/app/components/SpaceMenuActions.svelte b/src/app/components/SpaceMenuActions.svelte index 5ddfcd6b..e8823102 100644 --- a/src/app/components/SpaceMenuActions.svelte +++ b/src/app/components/SpaceMenuActions.svelte @@ -19,7 +19,7 @@ import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import {relays, user} from "@app/core" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" - import {deriveSpaceSupportedMethods} from "@app/management" + import {deriveSpacePermissions} from "@app/management" import {userSpaceUrls} from "@app/rooms" import {deriveSpaceActionItems} from "@app/actionItems" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" @@ -33,15 +33,7 @@ const {url}: Props = $props() const relay = $relays.one(url) - const supportedMethods = deriveSpaceSupportedMethods(url) - const canReview = $derived( - ["banevent", "allowpubkey"].some(method => $supportedMethods.includes(method)), - ) - const canEditSpace = $derived( - ["changerelayname", "changerelaydescription", "changerelayicon"].some(method => - $supportedMethods.includes(method), - ), - ) + const permissions = deriveSpacePermissions(url) const hostedRelay = deriveHostedRelay(url) const actionItems = deriveSpaceActionItems(url) const shouldNotify = deriveShouldNotify(url) @@ -85,7 +77,7 @@ {/snippet} {@render actionButton(createInvite, LinkRound, "Create Invite")} -{#if canReview} +{#if $permissions.review}
  • -{:else if canEditSpace} +{:else if $permissions.editSpace} {@render actionButton(startEdit, Pen, "Edit Space")} {/if} {#if $userSpaceUrls.includes(url)} diff --git a/src/app/components/SpaceMenuActionsMobile.svelte b/src/app/components/SpaceMenuActionsMobile.svelte index 198a6b14..b4c3807d 100644 --- a/src/app/components/SpaceMenuActionsMobile.svelte +++ b/src/app/components/SpaceMenuActionsMobile.svelte @@ -19,7 +19,7 @@ import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import {relays, user} from "@app/core" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" - import {deriveSpaceSupportedMethods} from "@app/management" + import {deriveSpacePermissions} from "@app/management" import {userSpaceUrls} from "@app/rooms" import {deriveSpaceActionItems} from "@app/actionItems" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" @@ -33,15 +33,7 @@ const {url}: Props = $props() const relay = $relays.one(url) - const supportedMethods = deriveSpaceSupportedMethods(url) - const canReview = $derived( - ["banevent", "allowpubkey"].some(method => $supportedMethods.includes(method)), - ) - const canEditSpace = $derived( - ["changerelayname", "changerelaydescription", "changerelayicon"].some(method => - $supportedMethods.includes(method), - ), - ) + const permissions = deriveSpacePermissions(url) const hostedRelay = deriveHostedRelay(url) const actionItems = deriveSpaceActionItems(url) const shouldNotify = deriveShouldNotify(url) @@ -84,7 +76,7 @@ {/snippet} {@render actionButton(createInvite, LinkRound, "Create Invite")} -{#if canReview} +{#if $permissions.review} {/if} - {#if canEdit} + {#if $permissions.editRoles}
  • {/if} - {#if canDelete} + {#if $permissions.deleteRoles}
  • - {#if canCreate} + {#if $permissions.createRoles} - {#if $canManageRoles || canListBans || canListAdmins} + {#if $permissions.directoryMenu}
  • {/if} - {#if canListAdmins} + {#if $permissions.listAdmins}
  • {/if} - {#if canListBans} + {#if $permissions.listBans}