From 559db6b9308500882685d2e6d1fabf9c6e92e4f1 Mon Sep 17 00:00:00 2001 From: 1amKhush Date: Fri, 17 Apr 2026 05:57:10 +0530 Subject: [PATCH 1/5] feat(rbac): implement NIP-29 room roles and permission gating (#47) --- src/app/components/EventMenu.svelte | 8 +- src/app/components/ProfileDetail.svelte | 43 +- src/app/components/RoleBadge.svelte | 25 ++ src/app/components/RoomDetail.svelte | 50 ++- src/app/components/RoomMembers.svelte | 140 ++++-- src/app/components/SpaceDetail.svelte | 6 +- src/app/components/SpaceMembers.svelte | 184 ++++++-- src/app/core/roles.ts | 542 ++++++++++++++++++++++++ src/app/core/state.ts | 88 ++-- src/app/core/sync.ts | 3 +- src/app/util/storage.ts | 2 + 11 files changed, 951 insertions(+), 140 deletions(-) create mode 100644 src/app/components/RoleBadge.svelte create mode 100644 src/app/core/roles.ts diff --git a/src/app/components/EventMenu.svelte b/src/app/components/EventMenu.svelte index 174a28b5..0527c34e 100644 --- a/src/app/components/EventMenu.svelte +++ b/src/app/components/EventMenu.svelte @@ -3,7 +3,7 @@ import type {Snippet} from "svelte" import {goto} from "$app/navigation" import type {TrustedEvent} from "@welshman/util" - import {COMMENT, ManagementMethod} from "@welshman/util" + import {COMMENT, ManagementMethod, getTagValue} from "@welshman/util" import {pubkey, repository, relaysByUrl, manageRelay} from "@welshman/app" import ShareCircle from "@assets/icons/share-circle.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" @@ -18,6 +18,7 @@ import EventShare from "@app/components/EventShare.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import {hasNip29, deriveUserIsSpaceAdmin} from "@app/core/state" + import {hasPermission} from "@app/core/roles" import {pushModal} from "@app/util/modal" import {pushToast} from "@app/util/toast" import {makeSpaceChatPath} from "@app/util/routes" @@ -33,7 +34,8 @@ const {url, noun, event, onClick, customActions}: Props = $props() const isRoot = event.kind !== COMMENT - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const h = getTagValue("h", event.tags) + const canDelete = h ? hasPermission(url, h, 9005) : deriveUserIsSpaceAdmin(url) const report = () => pushModal(Report, {url, event}) @@ -107,7 +109,7 @@ Report Content - {#if $userIsAdmin} + {#if $canDelete}
  • - {:else if $members === undefined} -
    - Member list not available from this relay -
    {/if} {#if $userIsAdmin && $roleDefinitions.length > 0}
    diff --git a/src/app/components/RoomMembers.svelte b/src/app/components/RoomMembers.svelte index d96495f6..5ddbbdb2 100644 --- a/src/app/components/RoomMembers.svelte +++ b/src/app/components/RoomMembers.svelte @@ -83,11 +83,7 @@
    - {#if $members === undefined} -
    - Member list not available from this relay -
    - {:else if $members.length === 0} + {#if $members.length === 0}
    No members yet
    diff --git a/src/app/components/SpaceMembers.svelte b/src/app/components/SpaceMembers.svelte index 56c314d7..5049e22d 100644 --- a/src/app/components/SpaceMembers.svelte +++ b/src/app/components/SpaceMembers.svelte @@ -22,13 +22,10 @@ import Profile from "@app/components/Profile.svelte" import SpaceMembersAdd from "@app/components/SpaceMembersAdd.svelte" import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" - import { - deriveSpaceMembers, - deriveSpaceBannedPubkeyItems, - deriveSupportedMethods, - } from "@app/core/state" + import {deriveSpaceMembers, deriveSpaceBannedPubkeyItems} from "@app/core/state" import { deriveGroupedSpaceMembers, + deriveSupportedMethods, deriveUserHasSpacePermission, ROOM_PERMISSION_ADD_MEMBER, ROOM_PERMISSION_REMOVE_MEMBER, @@ -126,11 +123,7 @@ {/if} {/if}
    - {#if $members === undefined} -
    - Member list not available from this space -
    - {:else if $members.length === 0} + {#if $members.length === 0}
    No members yet
    diff --git a/src/app/components/SpaceMembersBanned.svelte b/src/app/components/SpaceMembersBanned.svelte index 135ce86e..6a21f011 100644 --- a/src/app/components/SpaceMembersBanned.svelte +++ b/src/app/components/SpaceMembersBanned.svelte @@ -16,7 +16,8 @@ import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte" import Profile from "@app/components/Profile.svelte" - import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state" + import {deriveSupportedMethods} from "@app/core/roles" + import {deriveSpaceBannedPubkeyItems} from "@app/core/state" import {addSpaceMembers} from "@app/core/commands" import {pushToast} from "@app/util/toast" diff --git a/src/app/core/roles.ts b/src/app/core/roles.ts index 3fc05126..f2df0779 100644 --- a/src/app/core/roles.ts +++ b/src/app/core/roles.ts @@ -1,8 +1,9 @@ import {derived, readable, type Readable} from "svelte/store" import {first, memoize, removeUndefined, simpleCache, sortBy, uniq} from "@welshman/lib" import {deriveArray, deriveEventsByIdForUrl} from "@welshman/store" -import {pubkey, repository, tracker} from "@welshman/app" +import {pubkey, repository, tracker, manageRelay} from "@welshman/app" import { + ManagementMethod, ROOM_ADD_MEMBER, ROOM_REMOVE_MEMBER, ROOM_EDIT_META, @@ -13,7 +14,6 @@ import { isRelayUrl, } from "@welshman/util" import type {Filter, TrustedEvent} from "@welshman/util" -import {deriveSupportedMethods} from "@app/core/state" export const ROOM_ROLES = 39003 @@ -31,6 +31,15 @@ const ALL_ROOM_PERMISSIONS = [ ROOM_PERMISSION_BAN_USER, ] +export const deriveSupportedMethods = simpleCache(([url]: [string]) => + readable([], set => { + manageRelay(url, { + method: ManagementMethod.SupportedMethods, + params: [], + }).then(({result = []}) => set(result)) + }), +) + export type RoleAccess = "read" | "write" | "join" export type RoleDefinition = { @@ -53,13 +62,13 @@ export type RoomMember = { roles: string[] } -export type MemberRoleInfo = { +type MemberRoleInfo = { pubkey: string roles: RoleDefinition[] primaryRole?: RoleDefinition } -export type MemberRoleGroup = { +type MemberRoleGroup = { key: string role?: RoleDefinition members: MemberRoleInfo[] @@ -77,8 +86,6 @@ type RoomSnapshot = { admins: RoomMember[] } -export type SpaceMemberRoleInfo = MemberRoleInfo - type SpaceRoleState = { hasPermissionTags: boolean userPermissions: Set @@ -283,14 +290,10 @@ const getResolvedRoles = (rolesByName: Map, roleNames: s export const sortRolesDesc = (items: T[]) => sortBy(item => -(item.order ?? -Infinity), items) -export const getRoleLabel = (role: RoleDefinition) => role.label || role.name - export const getRolePermissionsLabel = (role: RoleDefinition) => role.permissions.join(", ") export const getRoleAccessLabel = (role: RoleDefinition) => Array.from(role.access).join(", ") -const getPrimaryRole = (roles: RoleDefinition[]) => first(sortRolesDesc(roles)) - const toMemberRoleInfo = (pubkey: string, roles: RoleDefinition[]): MemberRoleInfo => { const sortedRoles = sortRolesDesc(roles) @@ -304,7 +307,7 @@ const toMemberRoleInfo = (pubkey: string, roles: RoleDefinition[]): MemberRoleIn const sortMemberRoleInfos = (members: MemberRoleInfo[]) => sortBy(member => -(member.primaryRole?.order ?? -Infinity), members) -export const groupMemberRoleInfos = (members: MemberRoleInfo[]) => { +const groupMemberRoleInfos = (members: MemberRoleInfo[]) => { const byRole = new Map() const ungrouped: MemberRoleGroup = { key: "members", @@ -578,19 +581,12 @@ export const deriveHasPermission = (url: string, h: string, kind: number) => ([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.has(kind), ) -export const deriveUserRoleColor = (url: string, h: string, targetPubkey: string) => - derived( - [deriveUserRoles(url, h, targetPubkey), deriveRoomRoles(url, h)], - ([$roleNames, $roomRoles]) => - getPrimaryRole(getResolvedRoles($roomRoles.roles, $roleNames))?.color, - ) - export const deriveRoomRoleDefinitions = (url: string, h: string) => derived(deriveRoomRoles(url, h), $roomRoles => sortRolesDesc(Array.from($roomRoles.roles.values())), ) -export const deriveRoomMemberRoleInfo = (url: string, h: string) => +const deriveRoomMemberRoleInfo = (url: string, h: string) => derived([deriveRoomMembers(url, h), deriveRoomRoles(url, h)], ([$members, $roomRoles]) => sortMemberRoleInfos( $members.map(member => @@ -602,16 +598,9 @@ export const deriveRoomMemberRoleInfo = (url: string, h: string) => export const deriveGroupedRoomMembers = (url: string, h: string) => derived(deriveRoomMemberRoleInfo(url, h), $members => groupMemberRoleInfos($members)) -export const getRoleSortKey = (url: string, h: string, targetPubkey: string) => - derived( - [deriveUserRoles(url, h, targetPubkey), deriveRoomRoles(url, h)], - ([$roleNames, $roomRoles]) => - getPrimaryRole(getResolvedRoles($roomRoles.roles, $roleNames))?.order, - ) - -export const deriveSpaceMemberRoleInfo = (url: string) => +const deriveSpaceMemberRoleInfo = (url: string) => derived(deriveSpaceRoleState(url), $spaceRoleState => { - const roleInfoByPubkey = new Map() + const roleInfoByPubkey = new Map() for (const [pubkey, roles] of $spaceRoleState.memberRoles.entries()) { roleInfoByPubkey.set(pubkey, toMemberRoleInfo(pubkey, roles)) diff --git a/src/app/core/state.ts b/src/app/core/state.ts index b27f30e2..49d7cc97 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -1300,15 +1300,6 @@ export const deriveSocketStatus = (url: string) => }), ) -export const deriveSupportedMethods = simpleCache(([url]: [string]) => { - return readable([], set => { - manageRelay(url, { - method: ManagementMethod.SupportedMethods, - params: [], - }).then(({result = []}) => set(result)) - }) -}) - export const deriveHasLivekit = simpleCache(([url]: [string]) => readable(undefined, set => { checkRelayHasLivekit(url).then(has => set(has)) -- 2.52.0 From ba07c339ebe039db6f233c4d1f1a5830690938dc Mon Sep 17 00:00:00 2001 From: 1amKhush Date: Sun, 3 May 2026 16:37:27 +0530 Subject: [PATCH 5/5] refactor: address PR review feedback for RBAC --- src/app/components/EventMenu.svelte | 10 +- src/app/core/roles.ts | 157 ++++++++++------------------ 2 files changed, 56 insertions(+), 111 deletions(-) diff --git a/src/app/components/EventMenu.svelte b/src/app/components/EventMenu.svelte index 262ce96d..95b84902 100644 --- a/src/app/components/EventMenu.svelte +++ b/src/app/components/EventMenu.svelte @@ -17,11 +17,7 @@ import Report from "@app/components/Report.svelte" import EventShare from "@app/components/EventShare.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" - import { - deriveUserIsSpaceAdmin, - deriveHasPermission, - ROOM_PERMISSION_DELETE_EVENT, - } from "@app/core/roles" + import {deriveHasPermission, ROOM_PERMISSION_DELETE_EVENT} from "@app/core/roles" import {hasNip29} from "@app/core/state" import {pushModal} from "@app/util/modal" import {pushToast} from "@app/util/toast" @@ -39,9 +35,7 @@ const isRoot = event.kind !== COMMENT const h = getTagValue("h", event.tags) - const canDelete = h - ? deriveHasPermission(url, h, ROOM_PERMISSION_DELETE_EVENT) - : deriveUserIsSpaceAdmin(url) + const canDelete = deriveHasPermission(url, h, ROOM_PERMISSION_DELETE_EVENT) const report = () => pushModal(Report, {url, event}) diff --git a/src/app/core/roles.ts b/src/app/core/roles.ts index f2df0779..96683af3 100644 --- a/src/app/core/roles.ts +++ b/src/app/core/roles.ts @@ -1,7 +1,7 @@ import {derived, readable, type Readable} from "svelte/store" import {first, memoize, removeUndefined, simpleCache, sortBy, uniq} from "@welshman/lib" -import {deriveArray, deriveEventsByIdForUrl} from "@welshman/store" -import {pubkey, repository, tracker, manageRelay} from "@welshman/app" +import {pubkey, manageRelay} from "@welshman/app" +import {deriveEventsForUrl} from "@app/core/state" import { ManagementMethod, ROOM_ADD_MEMBER, @@ -13,7 +13,7 @@ import { getTagValue, isRelayUrl, } from "@welshman/util" -import type {Filter, TrustedEvent} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" export const ROOM_ROLES = 39003 @@ -79,22 +79,12 @@ type ParsedRoleState = { hasPermissionTags: boolean } -type RoomSnapshot = { - h: string - rolesState: ParsedRoleState - members: RoomMember[] - admins: RoomMember[] -} - type SpaceRoleState = { hasPermissionTags: boolean userPermissions: Set memberRoles: Map } -const deriveEventsForUrl = (url: string, filters: Filter[] = [{}]) => - deriveArray(deriveEventsByIdForUrl({url, tracker, repository, filters})) - const makeRoleDefinition = (name: string): RoleDefinition => ({ name, permissions: [], @@ -121,14 +111,6 @@ const asAccess = (value: string | undefined): RoleAccess | undefined => { } } -const getLatestEventByKind = (events: TrustedEvent[], kind: number, h: string) => - first( - sortBy( - event => -event.created_at, - events.filter(event => event.kind === kind && getTagValue("d", event.tags) === h), - ), - ) - const parseRoleState = (event?: TrustedEvent): ParsedRoleState => { const roles = new Map() let hasPermissionTags = false @@ -250,27 +232,19 @@ export const parseRoomMembers = (tags: string[][]) => { })) } -const deriveRoomListStore = simpleCache(([url, h]: [string, string]) => - deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS, ROOM_ADMINS], "#d": [h]}]), -) - export const deriveRoomMembers = (url: string, h: string) => - derived(deriveRoomListStore(url, h), $events => { - const event = getLatestEventByKind($events, ROOM_MEMBERS, h) - - return parseRoomMembers(event?.tags || []) - }) + derived(deriveEventsForUrl(url, [{kinds: [ROOM_MEMBERS], "#d": [h]}]), ([event]) => + parseRoomMembers(event?.tags || []), + ) export const deriveRoomAdmins = (url: string, h: string) => - derived(deriveRoomListStore(url, h), $events => { - const event = getLatestEventByKind($events, ROOM_ADMINS, h) - - return parseRoomMembers(event?.tags || []) - }) + derived(deriveEventsForUrl(url, [{kinds: [ROOM_ADMINS], "#d": [h]}]), ([event]) => + parseRoomMembers(event?.tags || []), + ) const deriveRoomRoleState = simpleCache(([url, h]: [string, string]) => - derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), $events => - parseRoleState(getLatestEventByKind($events, ROOM_ROLES, h)), + derived(deriveEventsForUrl(url, [{kinds: [ROOM_ROLES], "#d": [h]}]), ([event]) => + parseRoleState(event), ), ) @@ -395,52 +369,6 @@ export const deriveUserPermissions = (url: string, h: string) => }, ) -const buildRoomSnapshots = (events: TrustedEvent[]) => { - const latestByH = new Map< - string, - {roles?: TrustedEvent; members?: TrustedEvent; admins?: TrustedEvent} - >() - - for (const event of sortBy(x => -x.created_at, events)) { - const h = getTagValue("d", event.tags) - - if (!h) { - continue - } - - if (!latestByH.has(h)) { - latestByH.set(h, {}) - } - - const entry = latestByH.get(h)! - - if (event.kind === ROOM_ROLES && !entry.roles) { - entry.roles = event - } - - if (event.kind === ROOM_MEMBERS && !entry.members) { - entry.members = event - } - - if (event.kind === ROOM_ADMINS && !entry.admins) { - entry.admins = event - } - } - - const snapshots: RoomSnapshot[] = [] - - for (const [h, {roles, members, admins}] of latestByH.entries()) { - snapshots.push({ - h, - rolesState: parseRoleState(roles), - members: parseRoomMembers(members?.tags || []), - admins: parseRoomMembers(admins?.tags || []), - }) - } - - return snapshots -} - const mergeRoleDefinitions = (left: RoleDefinition[], right: RoleDefinition[]) => { const merged = new Map() @@ -488,28 +416,48 @@ const deriveSpaceRoleState = simpleCache(([url]: [string]) => ([$pubkey, $events]): SpaceRoleState => { const userPermissions = new Set() const memberRoles = new Map() - const snapshots = buildRoomSnapshots($events) - const hasPermissionTags = snapshots.some(snapshot => snapshot.rolesState.hasPermissionTags) + const rolesByH = new Map>() + let hasPermissionTags = false - for (const snapshot of snapshots) { - const allMembers = [...snapshot.members, ...snapshot.admins] - - for (const member of allMembers) { - const resolvedRoles = getResolvedRoles(snapshot.rolesState.roles, member.roles) - - if (resolvedRoles.length === 0) { - continue + for (const event of $events) { + if (event.kind === ROOM_ROLES) { + const h = getTagValue("d", event.tags) + if (h) { + const parsed = parseRoleState(event) + rolesByH.set(h, parsed) + if (parsed.hasPermissionTags) { + hasPermissionTags = true + } } + } + } - memberRoles.set( - member.pubkey, - mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles), - ) + for (const event of $events) { + if (event.kind === ROOM_MEMBERS || event.kind === ROOM_ADMINS) { + const h = getTagValue("d", event.tags) + if (!h) continue - if ($pubkey === member.pubkey && hasPermissionTags) { - for (const role of resolvedRoles) { - for (const permission of role.permissions) { - userPermissions.add(permission) + const rolesState = rolesByH.get(h) + if (!rolesState) continue + + const members = parseRoomMembers(event.tags) + for (const member of members) { + const resolvedRoles = getResolvedRoles(rolesState.roles, member.roles) + + if (resolvedRoles.length === 0) { + continue + } + + memberRoles.set( + member.pubkey, + mergeRoleDefinitions(memberRoles.get(member.pubkey) || [], resolvedRoles), + ) + + if ($pubkey === member.pubkey && rolesState.hasPermissionTags) { + for (const role of resolvedRoles) { + for (const permission of role.permissions) { + userPermissions.add(permission) + } } } } @@ -575,11 +523,14 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) => ([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.size > 0, ) -export const deriveHasPermission = (url: string, h: string, kind: number) => - derived( +export const deriveHasPermission = (url: string, h: string | undefined, kind: number) => { + if (!h) return deriveUserIsSpaceAdmin(url) + + return derived( [deriveUserPermissions(url, h), deriveUserIsSpaceAdmin(url)], ([$permissions, $isSpaceAdmin]) => $isSpaceAdmin || $permissions.has(kind), ) +} export const deriveRoomRoleDefinitions = (url: string, h: string) => derived(deriveRoomRoles(url, h), $roomRoles => -- 2.52.0