Keep NIP-86 state on a per-app SpaceManagement plugin and gate controls on named space permissions

This commit is contained in:
Jon Staab 2026-09-25 16:06:35 -07:00
parent f130af4edd
commit f10dc96c43
23 changed files with 243 additions and 243 deletions

View file

@ -166,7 +166,7 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t
| Methods | Called from | | 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` | | `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `SpaceInvite`, `ReportMenuList`, `addRoomMembers` |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) | | `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles`, `RoleAddMembers` | | `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` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control 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 is gated on the method behind it through `deriveSpacePermissions(url)`, a store of named booleans
five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an (`$permissions.ban`, `$permissions.editRoles`). The composite gates a parent uses to decide whether
error from the call, since a listed method can be refused for a particular event or target. 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 `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 on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and
`deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants. `deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants.

View file

@ -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 | | `Statuses` (`statuses.ts`) | `DerivedPlugin` | NIP-38 general status, keyed by pubkey |
| `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay | | `Commands` (`commands.ts`) | `RelayScopedDerivedPlugin` | slash-command definitions, keyed per relay |
| `HealthChecks` (`healthChecks.ts`) | none | a plain class over `IApp` exposing `Projection`s | | `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: Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
@ -197,12 +198,12 @@ export const deriveSpaceActionItems = (url: string) =>
[ [
deriveEventsForUrl(url, [{kinds: [REPORT]}]), deriveEventsForUrl(url, [{kinds: [REPORT]}]),
rooms.get().pendingJoins(url).$, rooms.get().pendingJoins(url).$,
deriveSpaceSupportedMethods(url), deriveSpacePermissions(url),
], ],
([$reports, $pendingJoins, $methods]) => ([$reports, $pendingJoins, $permissions]) =>
sortEventsDesc([ sortEventsDesc([
...($methods.includes("banevent") ? $reports : []), ...($permissions.deleteContent ? $reports : []),
...($methods.includes("allowpubkey") ? $pendingJoins : []), ...($permissions.addMembers ? $pendingJoins : []),
]), ]),
) )
``` ```

View file

@ -12,13 +12,13 @@ import {
MESSAGING_RELAYS, MESSAGING_RELAYS,
PROFILE, PROFILE,
RELAYS, RELAYS,
type ManagementResponse,
} from "@welshman/util" } from "@welshman/util"
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain" import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app" import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util" import {stripPrefix} from "@lib/util"
import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core" import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core"
import {PLATFORM_URL} from "@app/env" import {PLATFORM_URL} from "@app/env"
import {spaceManagement} from "@app/management"
import {relaysMostlyRestricted} from "@app/policies" import {relaysMostlyRestricted} from "@app/policies"
import {Push} from "@app/push" import {Push} from "@app/push"
import {deriveSocket} from "@app/relays" import {deriveSocket} from "@app/relays"
@ -389,18 +389,15 @@ export class Access {
try { try {
const management = relayManagement.get().forUrl(this.url) const management = relayManagement.get().forUrl(this.url)
const [{result: methods}, roomInviteResult] = await Promise.all([ const [methods, roomInviteResult] = await Promise.all([
management.supportedMethods().catch((error): ManagementResponse => { spaceManagement.get().loadSupportedMethods(this.url),
console.error(error)
return {}
}),
h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), 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 // Keep the spinner up long enough that a fast relay doesn't make it flash
sleep(300), sleep(300),
]) ])
// A relay reporting methods relay-wide can still come back "blocked" for this user. // 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() const {result: claims} = await management.listClaims()
if (claims?.[0]) { if (claims?.[0]) {

View file

@ -1,7 +1,7 @@
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {REPORT, sortEventsDesc} from "@welshman/util" import {REPORT, sortEventsDesc} from "@welshman/util"
import {rooms} from "@app/core" import {rooms} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {deriveEventsForUrl} from "@app/repository" import {deriveEventsForUrl} from "@app/repository"
// Action items (admin review queue) // Action items (admin review queue)
@ -12,11 +12,11 @@ export const deriveSpaceActionItems = (url: string) =>
[ [
deriveEventsForUrl(url, [{kinds: [REPORT]}]), deriveEventsForUrl(url, [{kinds: [REPORT]}]),
rooms.get().pendingJoins(url).$, rooms.get().pendingJoins(url).$,
deriveSpaceSupportedMethods(url), deriveSpacePermissions(url),
], ],
([$reports, $pendingJoins, $methods]) => ([$reports, $pendingJoins, $permissions]) =>
sortEventsDesc([ sortEventsDesc([
...($methods.includes("banevent") ? $reports : []), ...($permissions.deleteContent ? $reports : []),
...($methods.includes("allowpubkey") ? $pendingJoins : []), ...($permissions.addMembers ? $pendingJoins : []),
]), ]),
) )

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {readable} from "svelte/store"
import {removeUndefined, spec} from "@welshman/lib" import {removeUndefined, spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
@ -20,7 +21,8 @@
import ProfilePinnedNote from "@app/components/ProfilePinnedNote.svelte" import ProfilePinnedNote from "@app/components/ProfilePinnedNote.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte" import ProfileStatus from "@app/components/ProfileStatus.svelte"
import {messagingRelayLists, profiles, relayManagement, user} from "@app/core" import {messagingRelayLists, profiles, relayManagement, user} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions, spaceManagement} from "@app/management"
import type {BannedPubkeyItem} from "@app/management"
import {navigate, popModal, pushModal} from "@app/modal" import {navigate, popModal, pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {goToChat, makeProfilePath} from "@app/routes" import {goToChat, makeProfilePath} from "@app/routes"
@ -32,11 +34,9 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canBan = $derived($supportedMethods.includes("banpubkey")) const bans = url ? $spaceManagement.bannedPubkeys(url) : readable<BannedPubkeyItem[]>([])
const canUnallow = $derived($supportedMethods.includes("unallowpubkey")) const isBanned = $derived($bans.some(spec({pubkey})))
const canAllow = $derived($supportedMethods.includes("allowpubkey"))
const canListBans = $derived($supportedMethods.includes("listbannedpubkeys"))
const isSelf = $derived($user.pubkey === pubkey) const isSelf = $derived($user.pubkey === pubkey)
@ -49,11 +49,18 @@
goToChat([pubkey]) goToChat([pubkey])
} }
const loadBans = () => {
if (url && $permissions.listBans) {
$spaceManagement.loadBannedPubkeys(url)
}
}
const report = (error: string | undefined, message: string) => { const report = (error: string | undefined, message: string) => {
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message}) pushToast({message})
loadBans()
back() back()
} }
} }
@ -81,18 +88,7 @@
report(error, "User has successfully been restored!") report(error, "User has successfully been restored!")
} }
let isBanned = $state(false) $effect(loadBans)
$effect(() => {
if (url && canListBans) {
$relayManagement
.forUrl(url)
.listBannedPubkeys()
.then(({result = []}) => {
isBanned = result.some(spec({pubkey}))
})
}
})
onMount(() => { onMount(() => {
$messagingRelayLists.load(pubkey) $messagingRelayLists.load(pubkey)
@ -115,7 +111,7 @@
</li> </li>
{/if} {/if}
{#if isBanned} {#if isBanned}
{#if canAllow} {#if $permissions.addMembers}
<li> <li>
<Button onclick={restoreMember}> <Button onclick={restoreMember}>
<Icon size={4} icon={Restart} /> <Icon size={4} icon={Restart} />
@ -124,7 +120,7 @@
</li> </li>
{/if} {/if}
{:else} {:else}
{#if canUnallow} {#if $permissions.removeMembers}
<li> <li>
<Button onclick={removeMember}> <Button onclick={removeMember}>
<Icon size={4} icon={UserMinus} /> <Icon size={4} icon={UserMinus} />
@ -132,7 +128,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canBan} {#if $permissions.ban}
<li> <li>
<Button class="text-error" onclick={banMember}> <Button class="text-error" onclick={banMember}>
<Icon size={4} icon={MinusCircle} /> <Icon size={4} icon={MinusCircle} />

View file

@ -28,7 +28,7 @@
import ZapModal from "@app/components/Zap.svelte" import ZapModal from "@app/components/Zap.svelte"
import {app, user} from "@app/core" import {app, user} from "@app/core"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {deriveDisplaysByPubkey} from "@app/social" import {deriveDisplaysByPubkey} from "@app/social"
@ -128,8 +128,7 @@
} }
} }
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const onReportClick = () => pushModal(ReportDetails, {url, event}) const onReportClick = () => pushModal(ReportDetails, {url, event})
@ -156,7 +155,7 @@
{#if $reactions.length > 0 || (!hideZaps && $zaps.length > 0) || $reports.length > 0 || children} {#if $reactions.length > 0 || (!hideZaps && $zaps.length > 0) || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2"> <div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0 && canBanEvent} {#if url && $reports.length > 0 && $permissions.deleteContent}
<Button <Button
data-tip={`This content has been reported as "${displayList(reportReasons)}".`} data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
class={cx( class={cx(

View file

@ -10,7 +10,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import {app, deletes, profiles, relayManagement, relays, user} from "@app/core" import {app, deletes, profiles, relayManagement, relays, user} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -23,9 +23,7 @@
const {url, event, onResolved, onClick}: Props = $props() const {url, event, onResolved, onClick}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const canBanPubkey = $derived($supportedMethods.includes("banpubkey"))
const etag = matchTag(tagSpec("e"), event.tags) const etag = matchTag(tagSpec("e"), event.tags)
const ptag = matchTag(tagSpec("p"), event.tags) const ptag = matchTag(tagSpec("p"), event.tags)
@ -108,7 +106,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canBanEvent} {#if $permissions.deleteContent}
<li> <li>
<Button onclick={dismissReport}> <Button onclick={dismissReport}>
<Icon icon={InboxOut} /> <Icon icon={InboxOut} />
@ -124,7 +122,7 @@
</li> </li>
{/if} {/if}
{/if} {/if}
{#if ptag && canBanPubkey} {#if ptag && $permissions.ban}
<li> <li>
<Button class="text-error" onclick={banMember}> <Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} /> <Icon icon={MinusCircle} />

View file

@ -7,7 +7,7 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {app, relayManagement} from "@app/core" import {app, relayManagement} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {addRoomMembers} from "@app/rooms" import {addRoomMembers} from "@app/rooms"
@ -22,8 +22,7 @@
const h = tagValue(tagSpec("h"), event.tags) || "" const h = tagValue(tagSpec("h"), event.tags) || ""
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canDismiss = $derived($supportedMethods.includes("banevent"))
const showProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url}) const showProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
@ -78,7 +77,7 @@
</span> </span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{#if canDismiss} {#if $permissions.deleteContent}
<Button class="button button-neutral button-sm" onclick={dismiss} disabled={loading} <Button class="button button-neutral button-sm" onclick={dismiss} disabled={loading}
>Dismiss</Button> >Dismiss</Button>
{/if} {/if}

View file

@ -17,12 +17,7 @@
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import SpaceMemberMethods from "@app/components/SpaceMemberMethods.svelte" import SpaceMemberMethods from "@app/components/SpaceMemberMethods.svelte"
import {fromApp} from "@app/core" import {fromApp} from "@app/core"
import { import {deriveSpacePermissions, displayManagementMethod, spaceManagement} from "@app/management"
deriveSpaceMethodAssignees,
deriveSpaceSupportedMethods,
displayManagementMethod,
loadSpaceMethodAssignees,
} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -33,12 +28,9 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = fromApp($app => $app.use(Relays).one(url)) const relay = fromApp($app => $app.use(Relays).one(url))
const assignees = deriveSpaceMethodAssignees(url) const assignees = $spaceManagement.methodAssignees(url)
const others = $derived($assignees.filter(({pubkey}) => pubkey !== $relay?.pubkey)) const others = $derived($assignees.filter(({pubkey}) => pubkey !== $relay?.pubkey))
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canEdit = $derived(
["assignmethod", "unassignmethod"].some(method => $supportedMethods.includes(method)),
)
const back = () => history.back() const back = () => history.back()
@ -47,7 +39,7 @@
let loading = $state(true) let loading = $state(true)
onMount(async () => { onMount(async () => {
const error = await loadSpaceMethodAssignees(url) const error = await $spaceManagement.loadMethodAssignees(url)
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
@ -85,7 +77,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<Profile {pubkey} {url} /> <Profile {pubkey} {url} />
</div> </div>
{#if canEdit} {#if $permissions.editAdmins}
<Button <Button
class="button button-ghost button-sm button-square" class="button button-ghost button-sm button-square"
aria-label="Edit permissions" aria-label="Edit permissions"

View file

@ -18,7 +18,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import QRCode from "@app/components/QRCode.svelte" import QRCode from "@app/components/QRCode.svelte"
import {Access, makeInviteLink} from "@app/access" import {Access, makeInviteLink} from "@app/access"
import {relayManagement, relayMemberLists} from "@app/core" import {relayManagement, relayMemberLists} from "@app/core"
@ -73,9 +73,7 @@
} }
} }
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canAddMembers = $derived($supportedMethods.includes("allowpubkey"))
let canShare = $state(false) let canShare = $state(false)
let invite = $derived(makeInviteLink({url, claim: $claim})) let invite = $derived(makeInviteLink({url, claim: $claim}))
@ -158,7 +156,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if canAddMembers} {#if $permissions.addMembers}
<Divider>or</Divider> <Divider>or</Divider>
<Field> <Field>
{#snippet label()} {#snippet label()}
@ -171,7 +169,7 @@
{/if} {/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
{#if canAddMembers} {#if $permissions.addMembers}
<Button class="button button-link" onclick={back}> <Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back

View file

@ -6,7 +6,7 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import SpaceMemberMenu from "@app/components/SpaceMemberMenu.svelte" import SpaceMemberMenu from "@app/components/SpaceMemberMenu.svelte"
import RoleBadge from "@app/components/RoleBadge.svelte" import RoleBadge from "@app/components/RoleBadge.svelte"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {profiles} from "@app/core" import {profiles} from "@app/core"
@ -19,15 +19,7 @@
const {url, pubkey, roles = []}: Props = $props() const {url, pubkey, roles = []}: Props = $props()
const profileDisplay = $profiles.display(pubkey, [url]).$ const profileDisplay = $profiles.display(pubkey, [url]).$
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canUnallow = $derived($supportedMethods.includes("unallowpubkey"))
const canBan = $derived($supportedMethods.includes("banpubkey"))
const canAssign = $derived($supportedMethods.includes("assignrole"))
const canUnassign = $derived($supportedMethods.includes("unassignrole"))
const canEditMethods = $derived(
$supportedMethods.includes("listmethodassignees") &&
["assignmethod", "unassignmethod"].some(method => $supportedMethods.includes(method)),
)
const openProfile = () => pushModal(ProfileDetail, {pubkey, url}) const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
@ -53,7 +45,7 @@
<ProfileAbout {pubkey} {url} singleLine /> <ProfileAbout {pubkey} {url} singleLine />
</div> </div>
</div> </div>
{#if canAssign || canUnassign || canUnallow || canBan || canEditMethods} {#if $permissions.memberMenu}
<div class="pointer-events-auto shrink-0"> <div class="pointer-events-auto shrink-0">
<MenuButton <MenuButton
class="button button-ghost button-sm button-square" class="button button-ghost button-sm button-square"

View file

@ -4,7 +4,7 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions, spaceManagement} from "@app/management"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {relayManagement} from "@app/core" import {relayManagement} from "@app/core"
@ -16,11 +16,7 @@
const {url, pubkey, onClick}: Props = $props() const {url, pubkey, onClick}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canUnban = $derived($supportedMethods.includes("unbanpubkey"))
const canRestore = $derived($supportedMethods.includes("allowpubkey"))
const back = () => history.back()
const unbanMember = async () => { const unbanMember = async () => {
const {error} = await $relayManagement.forUrl(url).unbanPubkey(pubkey) const {error} = await $relayManagement.forUrl(url).unbanPubkey(pubkey)
@ -29,7 +25,7 @@
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message: "User has successfully been removed from the ban list!"}) pushToast({message: "User has successfully been removed from the ban list!"})
back() $spaceManagement.loadBannedPubkeys(url)
} }
} }
@ -40,7 +36,7 @@
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message: "User has successfully been restored to membership!"}) pushToast({message: "User has successfully been restored to membership!"})
back() $spaceManagement.loadBannedPubkeys(url)
} }
} }
@ -52,7 +48,7 @@
</script> </script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if canUnban} {#if $permissions.unban}
<li> <li>
<Button onclick={unbanMember}> <Button onclick={unbanMember}>
<Icon icon={CloseCircle} /> <Icon icon={CloseCircle} />
@ -60,7 +56,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canRestore} {#if $permissions.addMembers}
<li> <li>
<Button onclick={restoreMember}> <Button onclick={restoreMember}>
<Icon icon={Restart} /> <Icon icon={Restart} />

View file

@ -10,7 +10,7 @@
import SpaceMemberMethods from "@app/components/SpaceMemberMethods.svelte" import SpaceMemberMethods from "@app/components/SpaceMemberMethods.svelte"
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte" import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
import {profiles, relayManagement} from "@app/core" import {profiles, relayManagement} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -22,16 +22,7 @@
const {url, pubkey, onClick}: Props = $props() const {url, pubkey, onClick}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canUnallow = $derived($supportedMethods.includes("unallowpubkey"))
const canBan = $derived($supportedMethods.includes("banpubkey"))
const canAssign = $derived($supportedMethods.includes("assignrole"))
const canUnassign = $derived($supportedMethods.includes("unassignrole"))
const canEditMethods = $derived(
$supportedMethods.includes("listmethodassignees") &&
["assignmethod", "unassignmethod"].some(method => $supportedMethods.includes(method)),
)
const back = () => history.back() const back = () => history.back()
@ -79,7 +70,7 @@
</script> </script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if canAssign || canUnassign} {#if $permissions.assignRoles || $permissions.unassignRoles}
<li> <li>
<Button onclick={editRoles}> <Button onclick={editRoles}>
<Icon icon={Pen} /> <Icon icon={Pen} />
@ -87,7 +78,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canEditMethods} {#if $permissions.editMethods}
<li> <li>
<Button onclick={editMethods}> <Button onclick={editMethods}>
<Icon icon={ShieldUser} /> <Icon icon={ShieldUser} />
@ -95,7 +86,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canUnallow} {#if $permissions.removeMembers}
<li> <li>
<Button onclick={removeMember}> <Button onclick={removeMember}>
<Icon icon={UserMinus} /> <Icon icon={UserMinus} />
@ -103,7 +94,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canBan} {#if $permissions.ban}
<li> <li>
<Button class="text-error" onclick={banMember}> <Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} /> <Icon icon={MinusCircle} />

View file

@ -13,12 +13,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {profiles, relayManagement} from "@app/core" import {profiles, relayManagement} from "@app/core"
import { import {MANAGEMENT_METHOD_GROUPS, deriveSpacePermissions, spaceManagement} from "@app/management"
MANAGEMENT_METHOD_GROUPS,
deriveSpaceMethodAssignees,
deriveSpaceSupportedMethods,
loadSpaceMethodAssignees,
} from "@app/management"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -29,9 +24,7 @@
const {url, pubkey}: Props = $props() const {url, pubkey}: Props = $props()
const profileDisplay = $profiles.display(pubkey, [url]).$ const profileDisplay = $profiles.display(pubkey, [url]).$
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canAssign = $derived($supportedMethods.includes("assignmethod"))
const canUnassign = $derived($supportedMethods.includes("unassignmethod"))
// A relay can grant methods this client doesn't know, and leaving them out would revoke them. // A relay can grant methods this client doesn't know, and leaving them out would revoke them.
const groups = $derived.by(() => { const groups = $derived.by(() => {
@ -90,7 +83,7 @@
} }
} }
await loadSpaceMethodAssignees(url) await $spaceManagement.loadMethodAssignees(url)
pushToast({message: "Permissions updated!"}) pushToast({message: "Permissions updated!"})
back() back()
@ -105,13 +98,15 @@
let saving = $state(false) let saving = $state(false)
onMount(async () => { onMount(async () => {
const error = await loadSpaceMethodAssignees(url) const error = await $spaceManagement.loadMethodAssignees(url)
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} }
assigned = new Set(get(deriveSpaceMethodAssignees(url)).find(spec({pubkey}))?.methods ?? []) assigned = new Set(
get($spaceManagement.methodAssignees(url)).find(spec({pubkey}))?.methods ?? [],
)
selected = new Set(assigned) selected = new Set(assigned)
loading = false loading = false
}) })
@ -139,7 +134,9 @@
type="checkbox" type="checkbox"
class="checkbox" class="checkbox"
checked={selected.has(method)} checked={selected.has(method)}
disabled={selected.has(method) ? !canUnassign : !canAssign} disabled={selected.has(method)
? !$permissions.unassignMethods
: !$permissions.assignMethods}
onchange={() => toggle(method)} /> onchange={() => toggle(method)} />
</label> </label>
{/each} {/each}
@ -153,7 +150,7 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
{#if canAssign || canUnassign} {#if $permissions.editAdmins}
<Button class="button button-primary" onclick={submit} disabled={saving}> <Button class="button button-primary" onclick={submit} disabled={saving}>
<Spinner loading={saving}>Save changes</Spinner> <Spinner loading={saving}>Save changes</Spinner>
</Button> </Button>

View file

@ -16,7 +16,7 @@
import RoleItem from "@app/components/RoleItem.svelte" import RoleItem from "@app/components/RoleItem.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte" import SpaceRoles from "@app/components/SpaceRoles.svelte"
import {app, profiles, relayManagement} from "@app/core" import {app, profiles, relayManagement} from "@app/core"
import {deriveUserCanManageRoles} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles" import {deriveSpaceMemberRoles} from "@app/roles"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -34,7 +34,7 @@
const profileDisplay = $profiles.display(pubkey, [url]).$ const profileDisplay = $profiles.display(pubkey, [url]).$
const memberRoles = deriveSpaceMemberRoles(url) const memberRoles = deriveSpaceMemberRoles(url)
const initial = new Set(get(memberRoles).get(pubkey) ?? []) const initial = new Set(get(memberRoles).get(pubkey) ?? [])
const canManageRoles = deriveUserCanManageRoles(url) const permissions = deriveSpacePermissions(url)
const back = () => history.back() const back = () => history.back()
@ -125,7 +125,7 @@
<Button class="button button-primary" onclick={submit} disabled={loading}> <Button class="button button-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner> <Spinner {loading}>Save changes</Spinner>
</Button> </Button>
{:else if $canManageRoles} {:else if $permissions.manageRoles}
<Button class="button button-primary" onclick={manageRoles}> <Button class="button button-primary" onclick={manageRoles}>
<Icon icon={UsersGroup} /> <Icon icon={UsersGroup} />
Manage Roles Manage Roles

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -12,7 +13,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import SpaceMemberBannedMenu from "@app/components/SpaceMemberBannedMenu.svelte" import SpaceMemberBannedMenu from "@app/components/SpaceMemberBannedMenu.svelte"
import {deriveSpaceBannedPubkeyItems, deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions, spaceManagement} from "@app/management"
interface Props { interface Props {
url: string url: string
@ -20,12 +21,14 @@
const {url}: Props = $props() const {url}: Props = $props()
const bans = deriveSpaceBannedPubkeyItems(url) const bans = $spaceManagement.bannedPubkeys(url)
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canUnban = $derived($supportedMethods.includes("unbanpubkey"))
const canRestore = $derived($supportedMethods.includes("allowpubkey"))
const back = () => history.back() const back = () => history.back()
onMount(() => {
$spaceManagement.loadBannedPubkeys(url)
})
</script> </script>
<Modal> <Modal>
@ -44,7 +47,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<Profile {pubkey} {url} /> <Profile {pubkey} {url} />
</div> </div>
{#if canUnban || canRestore} {#if $permissions.bannedMenu}
<MenuButton component={SpaceMemberBannedMenu} componentProps={{url, pubkey}} /> <MenuButton component={SpaceMemberBannedMenu} componentProps={{url, pubkey}} />
{/if} {/if}
</div> </div>

View file

@ -19,7 +19,7 @@
import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import {relays, user} from "@app/core" import {relays, user} from "@app/core"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {userSpaceUrls} from "@app/rooms" import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems" import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
@ -33,15 +33,7 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = $relays.one(url) const relay = $relays.one(url)
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canReview = $derived(
["banevent", "allowpubkey"].some(method => $supportedMethods.includes(method)),
)
const canEditSpace = $derived(
["changerelayname", "changerelaydescription", "changerelayicon"].some(method =>
$supportedMethods.includes(method),
),
)
const hostedRelay = deriveHostedRelay(url) const hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url) const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url) const shouldNotify = deriveShouldNotify(url)
@ -85,7 +77,7 @@
{/snippet} {/snippet}
{@render actionButton(createInvite, LinkRound, "Create Invite")} {@render actionButton(createInvite, LinkRound, "Create Invite")}
{#if canReview} {#if $permissions.review}
<li> <li>
<Button onclick={showActionItems}> <Button onclick={showActionItems}>
<Icon icon={Danger} /> <Icon icon={Danger} />
@ -120,7 +112,7 @@
Hosting settings Hosting settings
</Link> </Link>
</li> </li>
{:else if canEditSpace} {:else if $permissions.editSpace}
{@render actionButton(startEdit, Pen, "Edit Space")} {@render actionButton(startEdit, Pen, "Edit Space")}
{/if} {/if}
{#if $userSpaceUrls.includes(url)} {#if $userSpaceUrls.includes(url)}

View file

@ -19,7 +19,7 @@
import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import {relays, user} from "@app/core" import {relays, user} from "@app/core"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {userSpaceUrls} from "@app/rooms" import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems" import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
@ -33,15 +33,7 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = $relays.one(url) const relay = $relays.one(url)
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canReview = $derived(
["banevent", "allowpubkey"].some(method => $supportedMethods.includes(method)),
)
const canEditSpace = $derived(
["changerelayname", "changerelaydescription", "changerelayicon"].some(method =>
$supportedMethods.includes(method),
),
)
const hostedRelay = deriveHostedRelay(url) const hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url) const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url) const shouldNotify = deriveShouldNotify(url)
@ -84,7 +76,7 @@
{/snippet} {/snippet}
{@render actionButton(createInvite, LinkRound, "Create Invite")} {@render actionButton(createInvite, LinkRound, "Create Invite")}
{#if canReview} {#if $permissions.review}
<Button class="button button-neutral w-full justify-start" onclick={showActionItems}> <Button class="button button-neutral w-full justify-start" onclick={showActionItems}>
<Icon size={4} icon={Danger} /> <Icon size={4} icon={Danger} />
Action Items ({$actionItems.length}) Action Items ({$actionItems.length})
@ -113,7 +105,7 @@
<Icon size={4} icon={ServerPath} /> <Icon size={4} icon={ServerPath} />
Hosting settings Hosting settings
</Link> </Link>
{:else if canEditSpace} {:else if $permissions.editSpace}
{@render actionButton(startEdit, Pen, "Edit Space")} {@render actionButton(startEdit, Pen, "Edit Space")}
{/if} {/if}
{#if $userSpaceUrls.includes(url)} {#if $userSpaceUrls.includes(url)}

View file

@ -10,7 +10,7 @@
import RoleEdit from "@app/components/RoleEdit.svelte" import RoleEdit from "@app/components/RoleEdit.svelte"
import RoleAddMembers from "@app/components/RoleAddMembers.svelte" import RoleAddMembers from "@app/components/RoleAddMembers.svelte"
import {relayManagement} from "@app/core" import {relayManagement} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
@ -22,10 +22,7 @@
const {url, role, onClick}: Props = $props() const {url, role, onClick}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canEdit = $derived($supportedMethods.includes("editrole"))
const canDelete = $derived($supportedMethods.includes("deleterole"))
const canAssign = $derived($supportedMethods.includes("assignrole"))
const back = () => history.back() const back = () => history.back()
@ -59,7 +56,7 @@
</script> </script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}> <ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if canAssign} {#if $permissions.assignRoles}
<li> <li>
<Button onclick={addMembers}> <Button onclick={addMembers}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
@ -67,7 +64,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canEdit} {#if $permissions.editRoles}
<li> <li>
<Button onclick={editRole}> <Button onclick={editRole}>
<Icon icon={Pen} /> <Icon icon={Pen} />
@ -75,7 +72,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canDelete} {#if $permissions.deleteRoles}
<li> <li>
<Button class="text-error" onclick={confirmDelete}> <Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} /> <Icon icon={TrashBin} />

View file

@ -17,7 +17,7 @@
import RoleItem from "@app/components/RoleItem.svelte" import RoleItem from "@app/components/RoleItem.svelte"
import SpaceRoleMenu from "@app/components/SpaceRoleMenu.svelte" import SpaceRoleMenu from "@app/components/SpaceRoleMenu.svelte"
import {app} from "@app/core" import {app} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -29,11 +29,7 @@
const relayRoles = $app.use(RelayRoles).forUrl(url).$ const relayRoles = $app.use(RelayRoles).forUrl(url).$
const roles = $derived(sortBy(role => [role.order(), role.label() ?? ""], $relayRoles)) const roles = $derived(sortBy(role => [role.order(), role.label() ?? ""], $relayRoles))
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canCreate = $derived($supportedMethods.includes("createrole"))
const canEdit = $derived($supportedMethods.includes("editrole"))
const canDelete = $derived($supportedMethods.includes("deleterole"))
const canAssign = $derived($supportedMethods.includes("assignrole"))
const back = () => history.back() const back = () => history.back()
@ -55,7 +51,7 @@
{#each roles as role (role.identifier())} {#each roles as role (role.identifier())}
<div class="card card-sm flex justify-between gap-2"> <div class="card card-sm flex justify-between gap-2">
<RoleItem {role} /> <RoleItem {role} />
{#if canEdit || canDelete || canAssign} {#if $permissions.roleMenu}
<div class="shrink-0"> <div class="shrink-0">
<MenuButton <MenuButton
class="button button-ghost button-sm button-square" class="button button-ghost button-sm button-square"
@ -73,7 +69,7 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
{#if canCreate} {#if $permissions.createRoles}
<Button class="button button-primary" onclick={createRole}> <Button class="button button-primary" onclick={createRole}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Create Role Create Role

View file

@ -1,27 +1,11 @@
import {derived, readable, writable} from "svelte/store" import {derived, readable, writable} from "svelte/store"
import {ago, MINUTE, now, simpleCache} from "@welshman/lib" import {ago, MINUTE, now, simpleCache} from "@welshman/lib"
import {ROOM_CREATE_PERMISSION, hexTags, tagValues} from "@welshman/util" import {ROOM_CREATE_PERMISSION, hexTags, tagValues} from "@welshman/util"
import {Relays} from "@welshman/app" import {RelayManagement, Relays} from "@welshman/app"
import {fromApp, relayManagement, user} from "@app/core" import type {IApp} from "@welshman/app"
import {fromApp, usePlugin, user} from "@app/core"
import {deriveEventsForUrl} from "@app/repository" import {deriveEventsForUrl} from "@app/repository"
export type BannedPubkeyItem = {
pubkey: string
reason: string
}
export const deriveSpaceBannedPubkeyItems = (url: string) => {
const store = writable<BannedPubkeyItem[]>([])
relayManagement
.get()
.forUrl(url)
.listBannedPubkeys()
.then(({result = []}) => store.set(result))
return store
}
// `supportedmethods` answers for the pubkey that signed the request, so this catalog is the client's. // `supportedmethods` answers for the pubkey that signed the request, so this catalog is the client's.
export const MANAGEMENT_METHOD_GROUPS = [ export const MANAGEMENT_METHOD_GROUPS = [
{ {
@ -92,51 +76,134 @@ export type MethodAssigneeItem = {
methods: string[] methods: string[]
} }
// The relay answers the same assignments to anyone allowed to ask, so one store per space. export type BannedPubkeyItem = {
export const deriveSpaceMethodAssignees = simpleCache(([url]: [url: string]) => pubkey: string
writable<MethodAssigneeItem[]>([]), reason: string
}
// `supportedmethods` answers for the pubkey that signed the request, so the cache lives on the app.
export class SpaceManagement {
private methodRequests = new Map<string, {checkedAt: number; request: Promise<string[]>}>()
constructor(private readonly app: IApp) {}
loadSupportedMethods = (url: string) => {
const cached = this.methodRequests.get(url)
if (cached && cached.checkedAt >= ago(5, MINUTE)) {
return cached.request
}
const request = this.app
.use(RelayManagement)
.forUrl(url)
.supportedMethods()
.then(({result = []}): string[] => result)
.catch((error): string[] => {
this.methodRequests.delete(url)
console.error(error)
return []
})
this.methodRequests.set(url, {checkedAt: now(), request})
return request
}
supportedMethods = simpleCache(([url]: [url: string]) =>
readable<string[]>([], set => {
this.loadSupportedMethods(url).then(set)
}),
) )
export const loadSpaceMethodAssignees = async (url: string) => { methodAssignees = simpleCache(([url]: [url: string]) => writable<MethodAssigneeItem[]>([]))
const {result, error} = await relayManagement.get().forUrl(url).listMethodAssignees()
loadMethodAssignees = async (url: string) => {
const {result, error} = await this.app.use(RelayManagement).forUrl(url).listMethodAssignees()
if (result) { if (result) {
deriveSpaceMethodAssignees(url).set(result) this.methodAssignees(url).set(result)
} }
return error return error
} }
const deriveSupportedMethodsForPubkey = simpleCache(([, url]: [pubkey: string, url: string]) => { bannedPubkeys = simpleCache(([url]: [url: string]) => writable<BannedPubkeyItem[]>([]))
let checkedAt = 0
return readable<string[]>([], set => { loadBannedPubkeys = async (url: string) => {
if (checkedAt < ago(5, MINUTE)) { const {result, error} = await this.app.use(RelayManagement).forUrl(url).listBannedPubkeys()
checkedAt = now()
relayManagement if (result) {
.get() this.bannedPubkeys(url).set(result)
.forUrl(url)
.supportedMethods()
.then(({result = []}) => set(result))
.catch(error => {
checkedAt = 0
console.error(error)
})
} }
})
})
// The request is answered for the pubkey that signed it, so logging in swaps the methods out. return error
export const deriveSpaceSupportedMethods = (url?: string) => }
}
export const spaceManagement = usePlugin(SpaceManagement)
const deriveSpaceSupportedMethods = (url?: string) =>
fromApp($app => { fromApp($app => {
if (url && $app.user) { if (url && $app.user) {
return deriveSupportedMethodsForPubkey($app.user.pubkey, url) return $app.use(SpaceManagement).supportedMethods(url)
} }
return readable<string[]>([]) return readable<string[]>([])
}) })
const getSpacePermissions = (methods: string[]) => {
const can = (method: string) => methods.includes(method)
const addMembers = can("allowpubkey")
const removeMembers = can("unallowpubkey")
const ban = can("banpubkey")
const unban = can("unbanpubkey")
const listBans = can("listbannedpubkeys")
const deleteContent = can("banevent")
const createRoles = can("createrole")
const editRoles = can("editrole")
const deleteRoles = can("deleterole")
const assignRoles = can("assignrole")
const unassignRoles = can("unassignrole")
const assignMethods = can("assignmethod")
const unassignMethods = can("unassignmethod")
const listAdmins = can("listmethodassignees")
const editAdmins = assignMethods || unassignMethods
const editMethods = listAdmins && editAdmins
const manageRoles = createRoles || editRoles || deleteRoles
return {
addMembers,
removeMembers,
ban,
unban,
listBans,
deleteContent,
createRoles,
editRoles,
deleteRoles,
assignRoles,
unassignRoles,
assignMethods,
unassignMethods,
listAdmins,
editAdmins,
editMethods,
manageRoles,
editSpace: ["changerelayname", "changerelaydescription", "changerelayicon"].some(can),
review: deleteContent || addMembers,
memberMenu: assignRoles || unassignRoles || removeMembers || ban || editMethods,
roleMenu: editRoles || deleteRoles || assignRoles,
bannedMenu: unban || addMembers,
directoryMenu: manageRoles || listBans || listAdmins,
}
}
// Each control is gated on the NIP-86 method behind it, as reported for the signed-in user.
export const deriveSpacePermissions = (url?: string) =>
derived(deriveSpaceSupportedMethods(url), getSpacePermissions)
// User // User
// Holding any management method makes someone staff, for the room permissions NIP-86 has no method for. // Holding any management method makes someone staff, for the room permissions NIP-86 has no method for.
@ -150,11 +217,6 @@ export const deriveUserIsSpaceOwner = (url: string) =>
([$user, $relay]) => $user.pubkey === $relay?.pubkey, ([$user, $relay]) => $user.pubkey === $relay?.pubkey,
) )
export const deriveUserCanManageRoles = (url: string) =>
derived(deriveSpaceSupportedMethods(url), $methods =>
["createrole", "editrole", "deleterole"].some(method => $methods.includes(method)),
)
export const deriveUserCanCreateRoom = (url: string) => export const deriveUserCanCreateRoom = (url: string) =>
derived( derived(
[ [

View file

@ -31,7 +31,7 @@ import {
user, user,
writer, writer,
} from "@app/core" } from "@app/core"
import {deriveSpaceSupportedMethods, deriveUserIsSpaceStaff} from "@app/management" import {deriveSpacePermissions, deriveUserIsSpaceStaff} from "@app/management"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
export const PROTECTED = ["-"] export const PROTECTED = ["-"]
@ -224,13 +224,13 @@ export const deriveUserAdminDelete = (url: string, event: TrustedEvent) => {
const h = tagValue(tagSpec("h"), event.tags) ?? "" const h = tagValue(tagSpec("h"), event.tags) ?? ""
return derived( return derived(
[deriveUserRoomPermissions(url, h), deriveSpaceSupportedMethods(url)], [deriveUserRoomPermissions(url, h), deriveSpacePermissions(url)],
([$permissions, $methods]): Maybe<AdminDelete> => { ([$roomPermissions, $spacePermissions]): Maybe<AdminDelete> => {
if (h && $permissions.includes(ROOM_DELETE_EVENT)) { if (h && $roomPermissions.includes(ROOM_DELETE_EVENT)) {
return AdminDelete.Room return AdminDelete.Room
} }
if ($methods.includes("banevent")) { if ($spacePermissions.deleteContent) {
return AdminDelete.Space return AdminDelete.Space
} }

View file

@ -18,7 +18,7 @@
import SpaceAdmins from "@app/components/SpaceAdmins.svelte" import SpaceAdmins from "@app/components/SpaceAdmins.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte" import SpaceRoles from "@app/components/SpaceRoles.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {deriveSpaceSupportedMethods, deriveUserCanManageRoles} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles" import {deriveSpaceMemberRoles} from "@app/roles"
import {relayMemberLists, relayRoles} from "@app/core" import {relayMemberLists, relayRoles} from "@app/core"
import {deriveDisplaysByPubkey} from "@app/social" import {deriveDisplaysByPubkey} from "@app/social"
@ -32,10 +32,7 @@
const roles = $relayRoles.forUrl(url).$ const roles = $relayRoles.forUrl(url).$
const members = $relayMemberLists.forUrl(url) const members = $relayMemberLists.forUrl(url)
const memberRoles = deriveSpaceMemberRoles(url) const memberRoles = deriveSpaceMemberRoles(url)
const supportedMethods = deriveSpaceSupportedMethods(url) const permissions = deriveSpacePermissions(url)
const canManageRoles = deriveUserCanManageRoles(url)
const canListBans = $derived($supportedMethods.includes("listbannedpubkeys"))
const canListAdmins = $derived($supportedMethods.includes("listmethodassignees"))
// Each member with their resolved roles (sorted by order). // Each member with their resolved roles (sorted by order).
const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => { const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => {
@ -114,7 +111,7 @@
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Invite people Invite people
</Button> </Button>
{#if $canManageRoles || canListBans || canListAdmins} {#if $permissions.directoryMenu}
<div class="relative"> <div class="relative">
<Button <Button
class="button button-neutral button-sm button-square" class="button button-neutral button-sm button-square"
@ -127,7 +124,7 @@
<ul <ul
transition:fly transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2"> class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
{#if $canManageRoles} {#if $permissions.manageRoles}
<li> <li>
<Button onclick={manageRoles}> <Button onclick={manageRoles}>
<Icon icon={UsersGroup} /> <Icon icon={UsersGroup} />
@ -135,7 +132,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canListAdmins} {#if $permissions.listAdmins}
<li> <li>
<Button onclick={spaceAdmins}> <Button onclick={spaceAdmins}>
<Icon icon={ShieldUser} /> <Icon icon={ShieldUser} />
@ -143,7 +140,7 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canListBans} {#if $permissions.listBans}
<li> <li>
<Button onclick={bannedMembers}> <Button onclick={bannedMembers}>
<Icon icon={MinusCircle} /> <Icon icon={MinusCircle} />