From 7a41863ed5b1ab44258bfa21c19a2beb7f21f306 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:57:09 -0400 Subject: [PATCH 1/5] Update permissions to work with DMs --- packages/auth/src/role/types.ts | 12 +++++++++++- packages/auth/src/team/validate.ts | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/role/types.ts b/packages/auth/src/role/types.ts index 5f4c02e4..4df33664 100644 --- a/packages/auth/src/role/types.ts +++ b/packages/auth/src/role/types.ts @@ -1,4 +1,14 @@ -export type PermissionsMap = Record +export enum Permission { + MODIFIABLE_MEMBERSHIP = 'modifiable-membership', +} + +export interface ModifiableMembershipPermissionConfig { + memberIds: string[] +} + +export type PermissionsMap = { + [Permission.MODIFIABLE_MEMBERSHIP]?: true | ModifiableMembershipPermissionConfig +} export type Role = { roleName: string diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index 6fd17114..846c2eeb 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -11,6 +11,7 @@ import { type TeamStateValidatorSet, type ValidationArgs, } from './types.js' +import { Permission } from 'role/types.js' export const validate: TeamStateValidator = (previousState: TeamState, link: TeamLink, extendableLogger?: Logger) => { const logger = extendableLogger != null ? extendableLogger.extend('validate') : new Logger({ moduleName: 'auth:validate' }) @@ -134,6 +135,27 @@ const validators: TeamStateValidatorSet = { } return VALID }, + + /** Check that members not listed in the permissions map for a role aren't added */ + cantAddNewMembersToStaticRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('cantAddNewMembersToStaticRole') + if (link.body.type === 'ADD_MEMBER_ROLE') { + const { userId: assigningUserId } = link.body + const { userId, roleName } = link.body.payload + const role = select.role(previousState, roleName) + if (role.permissions == null) { + return VALID + } + if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null || role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) { + return VALID + } + if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP].memberIds.includes(userId)) { + return VALID + } + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} not specified in role's permissions`, previousState, link, logger) + } + return VALID + }, } const fail = (message: string, previousState: TeamState, link: TeamLink, extendableLogger: Logger) => { From 40befd9a9061c89ee623cb40c0bff45347e97867 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:14:54 -0400 Subject: [PATCH 2/5] Export role types --- packages/auth/src/team/index.ts | 1 + packages/auth/src/team/types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/team/index.ts b/packages/auth/src/team/index.ts index 18bf3a19..64ca1e0a 100644 --- a/packages/auth/src/team/index.ts +++ b/packages/auth/src/team/index.ts @@ -9,3 +9,4 @@ export * from './load.js' export * from './membershipResolver.js' export * from './redactUser.js' export * from './types.js' +export * from '../role/types.js' diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index b3882179..81e82ef0 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -16,7 +16,7 @@ import type { Client, LocalContext } from 'team/context.js' import type { Device } from 'device/index.js' import type { Invitation, InvitationState } from 'invitation/types.js' import type { Lockbox } from 'lockbox/index.js' -import type { PermissionsMap, Role } from 'role/index.js' +import type { PermissionsMap, Role, Permission } from 'role/index.js' import type { Host, Server } from 'server/index.js' import type { ValidationResult } from 'util/index.js' import { Logger, SharedLogger } from '@localfirst/shared' From 3b3eb4233a87d21f4df397ea8fe72adac143bb03 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:54:12 -0400 Subject: [PATCH 3/5] Fix export --- packages/auth/src/index.ts | 2 +- packages/auth/src/team/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index fa22f1eb..20ff8d0f 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -20,7 +20,7 @@ export type * from './team/context.js' export type * from './device/types.js' export type * from './invitation/types.js' export type * from './lockbox/types.js' -export type * from './role/types.js' +export * from './role/types.js' export type * from './server/types.js' export type * from './team/types.js' diff --git a/packages/auth/src/team/index.ts b/packages/auth/src/team/index.ts index 64ca1e0a..18bf3a19 100644 --- a/packages/auth/src/team/index.ts +++ b/packages/auth/src/team/index.ts @@ -9,4 +9,3 @@ export * from './load.js' export * from './membershipResolver.js' export * from './redactUser.js' export * from './types.js' -export * from '../role/types.js' From 458528d4e2fdbd25ca82adb824a96c279b76969e Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:06:34 -0400 Subject: [PATCH 4/5] Add static role-specific actions and validations --- packages/auth/src/team/Team.ts | 9 +++++--- packages/auth/src/team/isAdminOnlyAction.ts | 2 ++ packages/auth/src/team/membershipResolver.ts | 10 +++++++-- packages/auth/src/team/reducer.ts | 16 +++++++++++++- packages/auth/src/team/types.ts | 16 ++++++++++++++ packages/auth/src/team/validate.ts | 22 ++++++++++++++++++-- packages/auth/src/util/actionFingerprint.ts | 4 +++- 7 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 2c555fd2..c023f9df 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -28,7 +28,7 @@ import * as invitations from 'invitation/index.js' import { type ProofOfInvitation } from 'invitation/index.js' import { normalize } from 'invitation/normalize.js' import * as lockbox from 'lockbox/index.js' -import { AddRoleInput, ADMIN, type Role } from 'role/index.js' +import { AddRoleInput, ADMIN, Permission, type Role } from 'role/index.js' import { castServer } from 'server/castServer.js' import { type Host, type Server } from 'server/types.js' import { type LocalUserContext } from 'team/context.js' @@ -352,8 +352,9 @@ export class Team extends EventEmitter { } // Post the role to the graph + const eventType = role.permissions && role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true ? 'ADD_ROLE' : 'ADD_STATIC_ROLE' this.dispatch({ - type: 'ADD_ROLE', + type: eventType, payload: { ...(role as Role), lockboxes: lockboxes }, }) @@ -374,8 +375,10 @@ export class Team extends EventEmitter { /** Dispatch the add role action */ private _dispatchAddMemberRole(userId: string, roleName: string, lockboxes: lockbox.Lockbox[]) { + const role = select.role(this.state, roleName) + const eventType = role.permissions && role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true ? 'ADD_MEMBER_ROLE' : 'ADD_MEMBER_STATIC_ROLE' this.dispatch({ - type: 'ADD_MEMBER_ROLE', + type: eventType, payload: { userId, roleName, lockboxes }, }) } diff --git a/packages/auth/src/team/isAdminOnlyAction.ts b/packages/auth/src/team/isAdminOnlyAction.ts index 03ffa774..e8f29cd7 100644 --- a/packages/auth/src/team/isAdminOnlyAction.ts +++ b/packages/auth/src/team/isAdminOnlyAction.ts @@ -11,6 +11,8 @@ export const isAdminOnlyAction = (action: TeamLinkBody) => { 'ADMIT_MEMBER', 'ADMIT_DEVICE', 'ADD_MEMBER_ROLE', + 'ADD_STATIC_ROLE', + 'ADD_MEMBER_STATIC_ROLE', 'SET_METADATA', ] diff --git a/packages/auth/src/team/membershipResolver.ts b/packages/auth/src/team/membershipResolver.ts index c8091979..b2d392d7 100644 --- a/packages/auth/src/team/membershipResolver.ts +++ b/packages/auth/src/team/membershipResolver.ts @@ -14,6 +14,7 @@ import { type TeamLink, type TeamGraph, type AdmitMemberAction, + type AddMemberStaticRoleAction, } from 'team/types.js' import { arraysAreEqual } from 'util/arraysAreEqual.js' @@ -123,7 +124,7 @@ const leastSenior = (chain: TeamGraph, userNames: string[]) => userNames.sort(bySeniority(chain)).pop()! const isAddAction = (link: TeamLink): link is AddActionLink => - ['ADD_MEMBER', 'ADD_MEMBER_ROLE', 'ADMIT_MEMBER'].includes(link.body.type) + ['ADD_MEMBER', 'ADD_MEMBER_ROLE', 'ADD_MEMBER_STATIC_ROLE', 'ADMIT_MEMBER', ].includes(link.body.type) const isRemovalAction = (link: TeamLink): boolean => link.body.type === 'REMOVE_MEMBER' @@ -168,6 +169,11 @@ const addedUserId = (link: AddActionLink): string => { return addAction.payload.userId } + case 'ADD_MEMBER_STATIC_ROLE': { + const addAction = link.body + return addAction.payload.userId + } + case 'ADMIT_MEMBER': { const addAction = link.body return addAction.payload.memberKeys.name @@ -185,4 +191,4 @@ const usesInvitation = (invitation: Invitation) => (l: TeamLink) => l.body.payload.id === invitation.id type RemoveActionLink = Link -type AddActionLink = Link +type AddActionLink = Link diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index c28d408b..b337127e 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -1,5 +1,5 @@ import { ROOT, type Reducer } from '@localfirst/crdx' -import { ADMIN } from 'role/index.js' +import { ADMIN, Permission, type Role } from 'role/index.js' import { clone, composeTransforms } from 'util/index.js' import { invalidLinkReducer } from './invalidLinkReducer.js' import { setHead } from './setHead.js' @@ -128,6 +128,20 @@ const getTransforms = (action: TeamAction): Transform[] => { ] } + case 'ADD_STATIC_ROLE': { + const newRole = action.payload + return [ + addRole(newRole), // Add this role to the team + ] + } + + case 'ADD_MEMBER_STATIC_ROLE': { + const { userId, roleName } = action.payload + return [ + ...addMemberRoles(userId, [roleName]), // Add this role to the member's list of roles + ] + } + case 'ADD_DEVICE': { const { device } = action.payload return [ diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 81e82ef0..7d24b6d8 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -120,6 +120,11 @@ export type AddRoleAction = { payload: BasePayload & Role } +export type AddStaticRoleAction = { + type: 'ADD_STATIC_ROLE' + payload: BasePayload & Role +} + export type RemoveRoleAction = { type: 'REMOVE_ROLE' payload: BasePayload & { @@ -136,6 +141,15 @@ export type AddMemberRoleAction = { } } +export type AddMemberStaticRoleAction = { + type: 'ADD_MEMBER_STATIC_ROLE' + payload: BasePayload & { + userId: string + roleName: string + permissions?: PermissionsMap + } +} + export type RemoveMemberRoleAction = { type: 'REMOVE_MEMBER_ROLE' payload: BasePayload & { @@ -269,6 +283,8 @@ export type TeamAction = | RemoveDeviceAction | RemoveRoleAction | RemoveMemberRoleAction + | AddStaticRoleAction + | AddMemberStaticRoleAction | InviteMemberAction | InviteDeviceAction | RevokeInvitationAction diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index 466fbe9e..209be34b 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -136,8 +136,8 @@ const validators: TeamStateValidatorSet = { }, /** Check that members not listed in the permissions map for a role aren't added */ - cantAddNewMembersToStaticRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { - const logger = extendableLogger.extend('cantAddNewMembersToStaticRole') + cantModifyStaticRolesWithAddMemberRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('cantModifyStaticRolesWithAddMemberRole') if (link.body.type === 'ADD_MEMBER_ROLE') { const { userId: assigningUserId } = link.body const { userId, roleName } = link.body.payload @@ -148,6 +148,24 @@ const validators: TeamStateValidatorSet = { if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null || role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) { return VALID } + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} using ADD_MEMBER_ROLE but the role is static`, previousState, link, logger) + } + return VALID + }, + + /** Check that members not listed in the permissions map for a role aren't added */ + cantAddNewMembersToStaticRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('cantAddNewMembersToStaticRole') + if (link.body.type === 'ADD_MEMBER_STATIC_ROLE') { + const { userId: assigningUserId } = link.body + const { userId, roleName } = link.body.payload + const role = select.role(previousState, roleName) + if (role.permissions == null) { + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} using ADD_MEMBER_STATIC_ROLE but the role has no permissions`, previousState, link, logger) + } + if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP] == null || role.permissions[Permission.MODIFIABLE_MEMBERSHIP] === true) { + return fail(`User ${assigningUserId} attempted to assign role ${roleName} to member ${userId} using ADD_MEMBER_STATIC_ROLE but the role is not static`, previousState, link, logger) + } if (role.permissions[Permission.MODIFIABLE_MEMBERSHIP].memberIds.includes(userId)) { return VALID } diff --git a/packages/auth/src/util/actionFingerprint.ts b/packages/auth/src/util/actionFingerprint.ts index 980229d6..20b42409 100644 --- a/packages/auth/src/util/actionFingerprint.ts +++ b/packages/auth/src/util/actionFingerprint.ts @@ -15,11 +15,13 @@ export const actionFingerprint = (link: TeamLink) => { return action.payload.userId } - case 'ADD_ROLE': { + case 'ADD_ROLE': + case 'ADD_STATIC_ROLE': { return action.payload.roleName } case 'ADD_MEMBER_ROLE': + case 'ADD_MEMBER_STATIC_ROLE': case 'REMOVE_MEMBER_ROLE': { return `${action.payload.roleName}:${action.payload.userId}` } From 18316f02fa8e70ec9ef3d5ea51a13fca57b38774 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:29:37 -0400 Subject: [PATCH 5/5] Update isAdminOnlyAction.ts --- packages/auth/src/team/isAdminOnlyAction.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/auth/src/team/isAdminOnlyAction.ts b/packages/auth/src/team/isAdminOnlyAction.ts index e8f29cd7..566da6bd 100644 --- a/packages/auth/src/team/isAdminOnlyAction.ts +++ b/packages/auth/src/team/isAdminOnlyAction.ts @@ -10,6 +10,7 @@ export const isAdminOnlyAction = (action: TeamLinkBody) => { 'CHANGE_SERVER_KEYS', 'ADMIT_MEMBER', 'ADMIT_DEVICE', + 'ADD_ROLE', 'ADD_MEMBER_ROLE', 'ADD_STATIC_ROLE', 'ADD_MEMBER_STATIC_ROLE',