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 |
|---|---|
| `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.

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 |
| `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 : []),
]),
)
```

View file

@ -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]) {

View file

@ -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 : []),
]),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.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 {relayManagement} from "@app/core"
@ -16,11 +16,7 @@
const {url, pubkey, onClick}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url)
const canUnban = $derived($supportedMethods.includes("unbanpubkey"))
const canRestore = $derived($supportedMethods.includes("allowpubkey"))
const back = () => 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 @@
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if canUnban}
{#if $permissions.unban}
<li>
<Button onclick={unbanMember}>
<Icon icon={CloseCircle} />
@ -60,7 +56,7 @@
</Button>
</li>
{/if}
{#if canRestore}
{#if $permissions.addMembers}
<li>
<Button onclick={restoreMember}>
<Icon icon={Restart} />

View file

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

View file

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

View file

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

View file

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

View file

@ -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}
<li>
<Button onclick={showActionItems}>
<Icon icon={Danger} />
@ -120,7 +112,7 @@
Hosting settings
</Link>
</li>
{:else if canEditSpace}
{:else if $permissions.editSpace}
{@render actionButton(startEdit, Pen, "Edit Space")}
{/if}
{#if $userSpaceUrls.includes(url)}

View file

@ -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}
<Button class="button button-neutral w-full justify-start" onclick={showActionItems}>
<Icon size={4} icon={Danger} />
Action Items ({$actionItems.length})
@ -113,7 +105,7 @@
<Icon size={4} icon={ServerPath} />
Hosting settings
</Link>
{:else if canEditSpace}
{:else if $permissions.editSpace}
{@render actionButton(startEdit, Pen, "Edit Space")}
{/if}
{#if $userSpaceUrls.includes(url)}

View file

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

View file

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

View file

@ -1,27 +1,11 @@
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 {RelayManagement, Relays} from "@welshman/app"
import type {IApp} from "@welshman/app"
import {fromApp, usePlugin, user} from "@app/core"
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.
export const MANAGEMENT_METHOD_GROUPS = [
{
@ -92,51 +76,134 @@ export type MethodAssigneeItem = {
methods: string[]
}
// The relay answers the same assignments to anyone allowed to ask, so one store per space.
export const deriveSpaceMethodAssignees = simpleCache(([url]: [url: string]) =>
writable<MethodAssigneeItem[]>([]),
)
export const loadSpaceMethodAssignees = async (url: string) => {
const {result, error} = await relayManagement.get().forUrl(url).listMethodAssignees()
if (result) {
deriveSpaceMethodAssignees(url).set(result)
}
return error
export type BannedPubkeyItem = {
pubkey: string
reason: string
}
const deriveSupportedMethodsForPubkey = simpleCache(([, url]: [pubkey: string, url: string]) => {
let checkedAt = 0
// `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[]>}>()
return readable<string[]>([], set => {
if (checkedAt < ago(5, MINUTE)) {
checkedAt = now()
constructor(private readonly app: IApp) {}
relayManagement
.get()
.forUrl(url)
.supportedMethods()
.then(({result = []}) => set(result))
.catch(error => {
checkedAt = 0
console.error(error)
})
loadSupportedMethods = (url: string) => {
const cached = this.methodRequests.get(url)
if (cached && cached.checkedAt >= ago(5, MINUTE)) {
return cached.request
}
})
})
// The request is answered for the pubkey that signed it, so logging in swaps the methods out.
export const deriveSpaceSupportedMethods = (url?: string) =>
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)
}),
)
methodAssignees = simpleCache(([url]: [url: string]) => writable<MethodAssigneeItem[]>([]))
loadMethodAssignees = async (url: string) => {
const {result, error} = await this.app.use(RelayManagement).forUrl(url).listMethodAssignees()
if (result) {
this.methodAssignees(url).set(result)
}
return error
}
bannedPubkeys = simpleCache(([url]: [url: string]) => writable<BannedPubkeyItem[]>([]))
loadBannedPubkeys = async (url: string) => {
const {result, error} = await this.app.use(RelayManagement).forUrl(url).listBannedPubkeys()
if (result) {
this.bannedPubkeys(url).set(result)
}
return error
}
}
export const spaceManagement = usePlugin(SpaceManagement)
const deriveSpaceSupportedMethods = (url?: string) =>
fromApp($app => {
if (url && $app.user) {
return deriveSupportedMethodsForPubkey($app.user.pubkey, url)
return $app.use(SpaceManagement).supportedMethods(url)
}
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
// 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,
)
export const deriveUserCanManageRoles = (url: string) =>
derived(deriveSpaceSupportedMethods(url), $methods =>
["createrole", "editrole", "deleterole"].some(method => $methods.includes(method)),
)
export const deriveUserCanCreateRoom = (url: string) =>
derived(
[

View file

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

View file

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