diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md
index fbd856f2..a379b596 100644
--- a/.agents/skills/flotilla-model/SKILL.md
+++ b/.agents/skills/flotilla-model/SKILL.md
@@ -95,11 +95,12 @@ that state, so a room re-created after deletion comes back. Membership (`members
`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.
+(`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 admin administers every room.
+- `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.
@@ -172,12 +173,13 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t
| `listClaims`, `createClaim` | `Access.prepareInvite` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
-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("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.
+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.
+`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,
diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md
index 48cf47bc..a1d5fbe1 100644
--- a/.agents/skills/flotilla-state/SKILL.md
+++ b/.agents/skills/flotilla-state/SKILL.md
@@ -194,8 +194,16 @@ projections and repository derivations:
// src/app/actionItems.ts
export const deriveSpaceActionItems = (url: string) =>
derived(
- [deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$],
- ([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]),
+ [
+ deriveEventsForUrl(url, [{kinds: [REPORT]}]),
+ rooms.get().pendingJoins(url).$,
+ deriveSpaceSupportedMethods(url),
+ ],
+ ([$reports, $pendingJoins, $methods]) =>
+ sortEventsDesc([
+ ...($methods.includes("banevent") ? $reports : []),
+ ...($methods.includes("allowpubkey") ? $pendingJoins : []),
+ ]),
)
```
@@ -206,7 +214,7 @@ export const deriveSpaceActionItems = (url: string) =>
- plain verbs mutate: `addRoomMembers`, `reorderSpaceUrls`
Rules that involve more than one plugin belong in these functions rather than in components.
-"A space admin is a room admin" lives in `deriveUserIsRoomAdmin`.
+"A space's staff are room admins" lives in `deriveUserIsRoomAdmin`.
### Hand-built indexes for hot paths
diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md
index 53ddfb19..e4722516 100644
--- a/.agents/skills/flotilla-views/SKILL.md
+++ b/.agents/skills/flotilla-views/SKILL.md
@@ -299,7 +299,7 @@ How you bind depends on what the method returns:
In a handler, call `.get()` on a projection instead of subscribing. The app modules add `derive*`
factories over the same data: `deriveEvent` and `deriveEventsById` in `src/app/repository.ts`,
-`deriveUserIsSpaceAdmin` in `src/app/management.ts`, `deriveRelayAuthError` in
+`deriveSpaceSupportedMethods` in `src/app/management.ts`, `deriveRelayAuthError` in
`src/app/access.ts`. Call them at the top of the script with fixed arguments, and wrap one in
`$derived` only when its arguments change, as the thread page does for filters that wait on the
root event.
diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md
index 1f5d758f..382bd0bc 100644
--- a/e2e/USER_STORIES.md
+++ b/e2e/USER_STORIES.md
@@ -17,8 +17,9 @@ identities:
- **alice**, **bob**, **carol** — ordinary members. Multi-user stories give each
their own browser context against the same relay, so one genuinely observes
another's writes over the wire.
-- **admin** — the space admin, recognized by the relay's NIP-86 answers, which
- is what unlocks the space, room, event and directory management surfaces.
+- **admin** — the relay's owner, so every NIP-86 method comes back for them and
+ every space, room, event and directory management control is unlocked. A
+ member the relay answers with fewer methods gets fewer controls (US-127).
The test architecture is described in `e2e/ARCHITECTURE.md`: real zooid relays in
docker, with every socket and http request terminated in the test process.
@@ -1467,6 +1468,20 @@ Acceptance:
- "Remove Content" on a report deletes the reported message and clears the item;
dismissing clears the item and leaves the content alone.
+### US-127 — Show a member only the controls their methods cover
+
+As a space, we want each admin control gated on the management method behind it,
+so that a member granted one method doesn't get an admin surface that only fails
+when they use it.
+
+Acceptance:
+
+- On a space whose members hold `allowpubkey` alone, alice sees "Report Content"
+ on bob's message and no delete, no "Edit Space" in the space menu, and no
+ "More options" in the directory.
+- She still sees "Action Items", which is the queue `allowpubkey` resolves.
+- admin, who owns the relay and so holds every method, sees all three.
+
### US-098 — Browse and create hosted spaces
As a space owner, I want to see the spaces I host and spin up new ones, so that
diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts
index f59ddca7..f56f5dcd 100644
--- a/e2e/harness/zooid/config.ts
+++ b/e2e/harness/zooid/config.ts
@@ -17,9 +17,11 @@ export const spaceTenants = {
space: "space.test",
other: "other.test",
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite,
- // `unsigned` serves events with their signatures stripped.
+ // `unsigned` serves events with their signatures stripped, `delegated` gives every member one
+ // management method.
closed: "closed.test",
unsigned: "unsigned.test",
+ delegated: "delegated.test",
} as const
// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a
diff --git a/e2e/harness/zooid/docker/config/delegated.toml b/e2e/harness/zooid/docker/config/delegated.toml
new file mode 100644
index 00000000..06a9f548
--- /dev/null
+++ b/e2e/harness/zooid/docker/config/delegated.toml
@@ -0,0 +1,42 @@
+# A space that hands its ordinary members one management method. `member_methods` is what zooid
+# answers `supportedmethods` with for a member who has been assigned nothing else, so a member here
+# holds `allowpubkey` and nothing more — which is the case the client has to gate each admin control
+# against. Everything else matches space.toml.
+#
+# `host` must equal the Host header the harness sends, which is the hostname of the url the client
+# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
+# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
+# that resolves nowhere.
+host = "delegated.test"
+schema = "e2e_delegated"
+secret = "de1e6a7ed000000000000000000000000000000000000000000000000000000f"
+
+[info]
+# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
+# name scenarios pass to `relay()`. `pubkey` is the owner, and on current zooid ownership is the
+# grant that lets an identity manage the relay (IsOwner → CanManage), so it is admin's key — the
+# identity seeding signs its room fixtures as.
+name = "delegated"
+pubkey = "6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"
+description = "Throwaway relay for Flotilla's end-to-end suite."
+
+[policy]
+public_read = false
+public_write = false
+public_join = true
+strip_signatures = false
+
+[groups]
+enabled = true
+
+[management]
+enabled = true
+member_methods = ["allowpubkey"]
+
+# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
+# the relay or the client talk to a service the test did not create.
+[blossom]
+enabled = false
+
+[push]
+enabled = false
diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts
index a5a86463..7684d04e 100644
--- a/e2e/specs/admin.spec.ts
+++ b/e2e/specs/admin.spec.ts
@@ -1146,3 +1146,60 @@ test("US-122 export and import a hosted relay's data", async ({seed, as}) => {
await expect(modal.getByText("Imported 1 event, skipped 1.")).toBeVisible()
await expect(modal.getByText("line 2: invalid event")).toBeVisible()
})
+
+// The space menu button is labeled with the space's host, the way `openSpaceMenu` relies on for
+// space.test. delegated.test grants every member one management method, so `supportedmethods` comes
+// back with a single entry for a member and the whole list for admin, who owns the relay.
+const openDelegatedMenu = (page: Page) =>
+ page.getByRole("button", {name: /delegated\.test/}).click()
+
+test("US-127 a member holding one method gets only that control", async ({seed, as}) => {
+ const scenario = await seed(({relay, user, at}) => {
+ const space = relay("delegated")
+
+ space.room("general", {name: "General"})
+ space.join(user.admin, "general")
+ space.join(user.alice, "general")
+ space.join(user.bob, "general")
+ space.profile(user.alice, {name: "Alice Anchor"})
+ space.profile(user.bob, {name: "Bob Barnacle"})
+ space.message(user.bob, "general", "aye captain", at(2, HOUR))
+ })
+
+ const {url} = scenario.space("delegated")
+
+ // alice holds allowpubkey, which covers the join requests in the queue and nothing else
+ const alice = await as(users.alice, roomPath(url, "general"))
+
+ await expect(alice.getByText("aye captain")).toBeVisible()
+
+ await openMessageMenu(alice, "aye captain")
+
+ await expect(menuItem(alice, "Report Content")).toBeVisible()
+ await expect(alice.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0)
+
+ await alice.goto(spacePath(url) + "/directory")
+
+ await expect(alice.getByRole("button", {name: "Invite people"})).toBeVisible()
+ await expect(alice.getByRole("button", {name: "More options"})).toHaveCount(0)
+
+ await openDelegatedMenu(alice)
+
+ await expect(alice.getByRole("button", {name: /^Action Items/})).toBeVisible()
+ await expect(alice.getByRole("button", {name: "Edit Space", exact: true})).toHaveCount(0)
+
+ // admin owns the relay, so every method comes back and every control is there
+ const admin = await as(users.admin, roomPath(url, "general"))
+
+ await openMessageMenu(admin, "aye captain")
+
+ await expect(menuItem(admin, "Delete Message")).toBeVisible()
+
+ await admin.goto(spacePath(url) + "/directory")
+
+ await expect(admin.getByRole("button", {name: "More options"})).toBeVisible()
+
+ await openDelegatedMenu(admin)
+
+ await expect(menuItem(admin, "Edit Space")).toBeVisible()
+})
diff --git a/src/app/actionItems.ts b/src/app/actionItems.ts
index d2c254c1..a0d3b887 100644
--- a/src/app/actionItems.ts
+++ b/src/app/actionItems.ts
@@ -1,12 +1,23 @@
import {derived} from "svelte/store"
import {REPORT, sortEventsDesc} from "@welshman/util"
import {rooms} from "@app/core"
+import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveEventsForUrl} from "@app/repository"
// Action items (admin review queue)
+// A report is resolved by banning the event it names, a join request by allowing the pubkey, so
+// the queue holds whichever of the two the user can actually act on.
export const deriveSpaceActionItems = (url: string) =>
derived(
- [deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$],
- ([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]),
+ [
+ deriveEventsForUrl(url, [{kinds: [REPORT]}]),
+ rooms.get().pendingJoins(url).$,
+ deriveSpaceSupportedMethods(url),
+ ],
+ ([$reports, $pendingJoins, $methods]) =>
+ sortEventsDesc([
+ ...($methods.includes("banevent") ? $reports : []),
+ ...($methods.includes("allowpubkey") ? $pendingJoins : []),
+ ]),
)
diff --git a/src/app/components/EventMenu.svelte b/src/app/components/EventMenu.svelte
index 5790f75d..40ab7529 100644
--- a/src/app/components/EventMenu.svelte
+++ b/src/app/components/EventMenu.svelte
@@ -16,7 +16,7 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {shareEvent} from "@app/share"
- import {deriveUserIsSpaceAdmin} from "@app/management"
+ import {deriveSpaceSupportedMethods} from "@app/management"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {app, relayManagement, user} from "@app/core"
@@ -32,7 +32,8 @@
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT
- const userIsAdmin = deriveUserIsSpaceAdmin(url)
+ const supportedMethods = deriveSpaceSupportedMethods(url)
+ const canBanEvent = $derived($supportedMethods.includes("banevent"))
const report = () => pushModal(Report, {url, event})
@@ -104,7 +105,7 @@
Report Content
- {#if $userIsAdmin}
+ {#if canBanEvent}