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