diff --git a/lib/permissions/minimally-encoded-thread-permissions-test-data.js b/lib/permissions/minimally-encoded-thread-permissions-test-data.js index c93224330..c69514082 100644 --- a/lib/permissions/minimally-encoded-thread-permissions-test-data.js +++ b/lib/permissions/minimally-encoded-thread-permissions-test-data.js @@ -1,679 +1,679 @@ // @flow -import type { MinimallyEncodedRawThreadInfo } from './minimally-encoded-thread-permissions.js'; +import type { MinimallyEncodedRawThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../types/thread-types-enum.js'; import type { RawThreadInfo } from '../types/thread-types.js'; const exampleRawThreadInfoA: RawThreadInfo = { id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: '1', }, visible: { value: true, source: '1', }, voiced: { value: true, source: '1', }, edit_entries: { value: true, source: '1', }, edit_thread: { value: true, source: '1', }, edit_thread_description: { value: true, source: '1', }, edit_thread_color: { value: true, source: '1', }, delete_thread: { value: true, source: '1', }, create_subthreads: { value: true, source: '1', }, create_sidebars: { value: true, source: '1', }, join_thread: { value: true, source: '1', }, edit_permissions: { value: true, source: '1', }, add_members: { value: true, source: '1', }, remove_members: { value: true, source: '1', }, change_role: { value: true, source: '1', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: '1', }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: '85171', }, visible: { value: true, source: '85171', }, voiced: { value: true, source: '85171', }, edit_entries: { value: true, source: '85171', }, edit_thread: { value: true, source: '85171', }, edit_thread_description: { value: true, source: '85171', }, edit_thread_color: { value: true, source: '85171', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: '85171', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: '85171', }, edit_message: { value: true, source: '85171', }, manage_pins: { value: false, source: null, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; const exampleMinimallyEncodedRawThreadInfoA: MinimallyEncodedRawThreadInfo = { minimallyEncoded: true, id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { minimallyEncoded: true, id: '256', role: null, permissions: '87fff', isSender: false, }, { minimallyEncoded: true, id: '83853', role: '85172', permissions: '3027f', isSender: true, }, ], roles: { '85172': { minimallyEncoded: true, id: '85172', name: 'Members', permissions: [ '000', '010', '020', '100', '110', '030', '040', '060', '050', '090', '005', '015', '0a9', ], isDefault: true, }, }, currentUser: { minimallyEncoded: true, role: '85172', permissions: '3027f', subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; const expectedDecodedExampleRawThreadInfoA: RawThreadInfo = { id: '85171', type: threadTypes.PERSONAL, name: '', description: '', color: '6d49ab', creationTime: 1675887298557, parentThreadID: '1', members: [ { id: '256', role: null, permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: true, source: 'null', }, create_subthreads: { value: true, source: 'null', }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: true, source: 'null', }, edit_permissions: { value: true, source: 'null', }, add_members: { value: true, source: 'null', }, remove_members: { value: true, source: 'null', }, change_role: { value: true, source: 'null', }, leave_thread: { value: false, source: null, }, react_to_message: { value: false, source: null, }, edit_message: { value: false, source: null, }, manage_pins: { value: true, source: 'null', }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, }, isSender: false, }, { id: '83853', role: '85172', permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: 'null', }, edit_message: { value: true, source: 'null', }, manage_pins: { value: false, source: null, }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, }, isSender: true, }, ], roles: { '85172': { id: '85172', name: 'Members', permissions: { know_of: true, visible: true, voiced: true, react_to_message: true, edit_message: true, edit_entries: true, edit_thread: true, edit_thread_color: true, edit_thread_description: true, create_sidebars: true, descendant_open_know_of: true, descendant_open_visible: true, child_open_join_thread: true, }, isDefault: true, }, }, currentUser: { role: '85172', permissions: { know_of: { value: true, source: 'null', }, visible: { value: true, source: 'null', }, voiced: { value: true, source: 'null', }, edit_entries: { value: true, source: 'null', }, edit_thread: { value: true, source: 'null', }, edit_thread_description: { value: true, source: 'null', }, edit_thread_color: { value: true, source: 'null', }, delete_thread: { value: false, source: null, }, create_subthreads: { value: false, source: null, }, create_sidebars: { value: true, source: 'null', }, join_thread: { value: false, source: null, }, edit_permissions: { value: false, source: null, }, add_members: { value: false, source: null, }, remove_members: { value: false, source: null, }, change_role: { value: false, source: null, }, leave_thread: { value: false, source: null, }, react_to_message: { value: true, source: 'null', }, edit_message: { value: true, source: 'null', }, manage_pins: { value: false, source: null, }, manage_invite_links: { source: null, value: false, }, edit_thread_avatar: { source: null, value: false, }, }, subscription: { home: true, pushNotifs: true, }, unread: false, }, repliesCount: 0, containingThreadID: '1', community: '1', pinnedCount: 0, }; export { exampleRawThreadInfoA, exampleMinimallyEncodedRawThreadInfoA, expectedDecodedExampleRawThreadInfoA, }; diff --git a/lib/permissions/minimally-encoded-thread-permissions.js b/lib/permissions/minimally-encoded-thread-permissions.js index 39d7c71d6..b283bd7b2 100644 --- a/lib/permissions/minimally-encoded-thread-permissions.js +++ b/lib/permissions/minimally-encoded-thread-permissions.js @@ -1,381 +1,216 @@ // @flow import invariant from 'invariant'; -import _mapValues from 'lodash/fp/mapValues.js'; -import t, { type TInterface } from 'tcomb'; import { parseThreadPermissionString } from './prefixes.js'; +import { tHexEncodedRolePermission } from '../types/minimally-encoded-thread-permissions-types.js'; import type { ThreadPermission, ThreadPermissionInfo, ThreadPermissionsInfo, ThreadRolePermissionsBlob, } from '../types/thread-permission-types.js'; -import type { - MemberInfo, - RawThreadInfo, - RoleInfo, - ThreadCurrentUserInfo, -} from '../types/thread-types.js'; -import { - memberInfoValidator, - rawThreadInfoValidator, - roleInfoValidator, - threadCurrentUserInfoValidator, -} from '../types/thread-types.js'; import { entries, invertObjectToMap } from '../utils/objects.js'; -import { tBool, tID, tRegex, tShape } from '../utils/validation-utils.js'; -import type { TRegex } from '../utils/validation-utils.js'; // `baseRolePermissionEncoding` maps permission names to indices. // These indices represent the 6-bit basePermission part of the 10-bit role // permission encoding created by `rolePermissionToBitmaskHex`. // The 6-bit basePermission allows for up to 2^6 = 64 different permissions. // If more than 64 permissions are needed, the encoding in // `rolePermissionToBitmaskHex` will need to be updated to accommodate this. const baseRolePermissionEncoding = Object.freeze({ // TODO (atul): Update flow to `194.0.0` for bigint support // $FlowIssue bigint-unsupported know_of: BigInt(0), visible: BigInt(1), voiced: BigInt(2), edit_entries: BigInt(3), edit_thread: BigInt(4), // EDIT_THREAD_NAME edit_thread_description: BigInt(5), edit_thread_color: BigInt(6), delete_thread: BigInt(7), create_subthreads: BigInt(8), // CREATE_SUBCHANNELS create_sidebars: BigInt(9), join_thread: BigInt(10), edit_permissions: BigInt(11), add_members: BigInt(12), remove_members: BigInt(13), change_role: BigInt(14), leave_thread: BigInt(15), react_to_message: BigInt(16), edit_message: BigInt(17), edit_thread_avatar: BigInt(18), manage_pins: BigInt(19), manage_invite_links: BigInt(20), }); // `minimallyEncodedThreadPermissions` is used to map each permission // to its respective bitmask where the index from `baseRolePermissionEncoding` // is used to set a specific bit in the bitmask. This is used in the // `permissionsToBitmaskHex` function where each permission is represented as a // single bit and the final bitmask is the union of all granted permissions. const minimallyEncodedThreadPermissions = Object.fromEntries( Object.keys(baseRolePermissionEncoding).map((key, idx) => [ key, BigInt(1) << BigInt(idx), ]), ); // This function converts a set of permissions to a hex-encoded bitmask. // Each permission is represented as a single bit in the bitmask. const permissionsToBitmaskHex = ( permissions: ThreadPermissionsInfo, ): string => { let bitmask = BigInt(0); for (const [key, permission] of entries(permissions)) { if (permission.value && key in minimallyEncodedThreadPermissions) { invariant( // TODO (atul): Update flow to `194.0.0` for bigint support // $FlowIssue illegal-typeof typeof minimallyEncodedThreadPermissions[key] === 'bigint', 'must be bigint', ); bitmask |= minimallyEncodedThreadPermissions[key]; } } return bitmask.toString(16); }; const threadPermissionsFromBitmaskHex = ( permissionsBitmaskHex: string, ): ThreadPermissionsInfo => { invariant( - tHexEncodedPermissionsBitmask.is(permissionsBitmaskHex), + tHexEncodedRolePermission.is(permissionsBitmaskHex), 'permissionsBitmaskHex must be valid hex string.', ); const permissionsBitmask = BigInt(`0x${permissionsBitmaskHex}`); const permissions: { [permission: ThreadPermission]: ThreadPermissionInfo } = {}; for (const [key, permissionBitmask] of entries( minimallyEncodedThreadPermissions, )) { if ((permissionsBitmask & permissionBitmask) !== BigInt(0)) { permissions[key] = { value: true, source: 'null' }; } else { permissions[key] = { value: false, source: null }; } } return permissions; }; const hasPermission = ( permissionsBitmaskHex: string, permission: ThreadPermission, ): boolean => { const permissionsBitmask = BigInt(`0x${permissionsBitmaskHex}`); if (!(permission in minimallyEncodedThreadPermissions)) { return false; } const permissionBitmask = minimallyEncodedThreadPermissions[permission]; invariant( // TODO (atul): Update flow to `194.0.0` for bigint support // $FlowIssue illegal-typeof typeof permissionBitmask === 'bigint', 'permissionBitmask must be of type bigint', ); return (permissionsBitmask & permissionBitmask) !== BigInt(0); }; const propagationPrefixes = Object.freeze({ '': BigInt(0), 'descendant_': BigInt(1), 'child_': BigInt(2), }); const filterPrefixes = Object.freeze({ '': BigInt(0), 'open_': BigInt(1), 'toplevel_': BigInt(2), 'opentoplevel_': BigInt(3), }); // Role Permission Bitmask Structure // [9 8 7 6 5 4 3 2 1 0] - bit positions // [b b b b b b p p f f] - symbol representation // b = basePermission (6 bits) // p = propagationPrefix (2 bits) // f = filterPrefix (2 bits) const rolePermissionToBitmaskHex = (threadRolePermission: string): string => { const parsed = parseThreadPermissionString(threadRolePermission); const basePermissionBits = baseRolePermissionEncoding[parsed.permission] & BigInt(63); const propagationPrefixBits = propagationPrefixes[parsed.propagationPrefix ?? ''] & BigInt(3); const filterPrefixBits = filterPrefixes[parsed.filterPrefix ?? ''] & BigInt(3); const bitmask = (basePermissionBits << BigInt(4)) | (propagationPrefixBits << BigInt(2)) | filterPrefixBits; return bitmask.toString(16).padStart(3, '0'); }; const inverseBaseRolePermissionEncoding = invertObjectToMap( baseRolePermissionEncoding, ); // $FlowIssue bigint-unsupported const inversePropagationPrefixes: Map = invertObjectToMap(propagationPrefixes); // $FlowIssue bigint-unsupported const inverseFilterPrefixes: Map = invertObjectToMap(filterPrefixes); const decodeRolePermissionBitmask = (bitmask: string): string => { const bitmaskInt = BigInt(`0x${bitmask}`); const basePermission = (bitmaskInt >> BigInt(4)) & BigInt(63); const propagationPrefix = (bitmaskInt >> BigInt(2)) & BigInt(3); const filterPrefix = bitmaskInt & BigInt(3); const basePermissionString = inverseBaseRolePermissionEncoding.get(basePermission); const propagationPrefixString = inversePropagationPrefixes.get(propagationPrefix) ?? ''; const filterPrefixString = inverseFilterPrefixes.get(filterPrefix) ?? ''; invariant( basePermissionString !== null && basePermissionString !== undefined && propagationPrefixString !== null && propagationPrefixString !== undefined && filterPrefixString !== null && filterPrefixString !== undefined, 'invalid bitmask', ); return `${propagationPrefixString}${filterPrefixString}${basePermissionString}`; }; const threadRolePermissionsBlobToBitmaskArray = ( threadRolePermissionsBlob: ThreadRolePermissionsBlob, ): $ReadOnlyArray => Object.keys(threadRolePermissionsBlob).map(rolePermissionToBitmaskHex); const decodeThreadRolePermissionsBitmaskArray = ( threadRolePermissionsBitmaskArray: $ReadOnlyArray, ): ThreadRolePermissionsBlob => Object.fromEntries( threadRolePermissionsBitmaskArray.map(bitmask => [ decodeRolePermissionBitmask(bitmask), true, ]), ); -export type MinimallyEncodedRoleInfo = $ReadOnly<{ - ...RoleInfo, - +minimallyEncoded: true, - +permissions: $ReadOnlyArray, -}>; - -const tHexEncodedRolePermission: TRegex = tRegex(/^[0-9a-fA-F]{3,}$/); -const minimallyEncodedRoleInfoValidator: TInterface = - tShape({ - ...roleInfoValidator.meta.props, - minimallyEncoded: tBool(true), - permissions: t.list(tHexEncodedRolePermission), - }); - -const minimallyEncodeRoleInfo = ( - roleInfo: RoleInfo, -): MinimallyEncodedRoleInfo => ({ - ...roleInfo, - minimallyEncoded: true, - permissions: threadRolePermissionsBlobToBitmaskArray(roleInfo.permissions), -}); - -const decodeMinimallyEncodedRoleInfo = ( - minimallyEncodedRoleInfo: MinimallyEncodedRoleInfo, -): RoleInfo => { - const { minimallyEncoded, ...rest } = minimallyEncodedRoleInfo; - return { - ...rest, - permissions: decodeThreadRolePermissionsBitmaskArray( - minimallyEncodedRoleInfo.permissions, - ), - }; -}; - -export type MinimallyEncodedThreadCurrentUserInfo = $ReadOnly<{ - ...ThreadCurrentUserInfo, - +minimallyEncoded: true, - +permissions: string, -}>; - -const tHexEncodedPermissionsBitmask: TRegex = tRegex(/^[0-9a-fA-F]+$/); -const minimallyEncodedThreadCurrentUserInfoValidator: TInterface = - tShape({ - ...threadCurrentUserInfoValidator.meta.props, - minimallyEncoded: tBool(true), - permissions: tHexEncodedPermissionsBitmask, - }); - -const minimallyEncodeThreadCurrentUserInfo = ( - threadCurrentUserInfo: ThreadCurrentUserInfo, -): MinimallyEncodedThreadCurrentUserInfo => ({ - ...threadCurrentUserInfo, - minimallyEncoded: true, - permissions: permissionsToBitmaskHex(threadCurrentUserInfo.permissions), -}); - -const decodeMinimallyEncodedThreadCurrentUserInfo = ( - minimallyEncodedThreadCurrentUserInfo: MinimallyEncodedThreadCurrentUserInfo, -): ThreadCurrentUserInfo => { - const { minimallyEncoded, ...rest } = minimallyEncodedThreadCurrentUserInfo; - return { - ...rest, - permissions: threadPermissionsFromBitmaskHex( - minimallyEncodedThreadCurrentUserInfo.permissions, - ), - }; -}; - -export type MinimallyEncodedMemberInfo = $ReadOnly<{ - ...MemberInfo, - +minimallyEncoded: true, - +permissions: string, -}>; - -const minimallyEncodedMemberInfoValidator: TInterface = - tShape({ - ...memberInfoValidator.meta.props, - minimallyEncoded: tBool(true), - permissions: tHexEncodedPermissionsBitmask, - }); - -const minimallyEncodeMemberInfo = ( - memberInfo: MemberInfo, -): MinimallyEncodedMemberInfo => ({ - ...memberInfo, - minimallyEncoded: true, - permissions: permissionsToBitmaskHex(memberInfo.permissions), -}); - -const decodeMinimallyEncodedMemberInfo = ( - minimallyEncodedMemberInfo: MinimallyEncodedMemberInfo, -): MemberInfo => { - const { minimallyEncoded, ...rest } = minimallyEncodedMemberInfo; - return { - ...rest, - permissions: threadPermissionsFromBitmaskHex( - minimallyEncodedMemberInfo.permissions, - ), - }; -}; - -export type MinimallyEncodedRawThreadInfo = $ReadOnly<{ - ...RawThreadInfo, - +minimallyEncoded: true, - +members: $ReadOnlyArray, - +roles: { +[id: string]: MinimallyEncodedRoleInfo }, - +currentUser: MinimallyEncodedThreadCurrentUserInfo, -}>; - -const minimallyEncodedRawThreadInfoValidator: TInterface = - tShape({ - ...rawThreadInfoValidator.meta.props, - minimallyEncoded: tBool(true), - members: t.list(minimallyEncodedMemberInfoValidator), - roles: t.dict(tID, minimallyEncodedRoleInfoValidator), - currentUser: minimallyEncodedThreadCurrentUserInfoValidator, - }); - -const minimallyEncodeRawThreadInfo = ( - rawThreadInfo: RawThreadInfo, -): MinimallyEncodedRawThreadInfo => { - const { members, roles, currentUser, ...rest } = rawThreadInfo; - return { - ...rest, - minimallyEncoded: true, - members: members.map(minimallyEncodeMemberInfo), - roles: _mapValues(minimallyEncodeRoleInfo)(roles), - currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), - }; -}; - -const decodeMinimallyEncodedRawThreadInfo = ( - minimallyEncodedRawThreadInfo: MinimallyEncodedRawThreadInfo, -): RawThreadInfo => { - const { minimallyEncoded, members, roles, currentUser, ...rest } = - minimallyEncodedRawThreadInfo; - return { - ...rest, - members: members.map(decodeMinimallyEncodedMemberInfo), - roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), - currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), - }; -}; - export { permissionsToBitmaskHex, threadPermissionsFromBitmaskHex, hasPermission, rolePermissionToBitmaskHex, decodeRolePermissionBitmask, threadRolePermissionsBlobToBitmaskArray, decodeThreadRolePermissionsBitmaskArray, - minimallyEncodedRoleInfoValidator, - minimallyEncodedThreadCurrentUserInfoValidator, - minimallyEncodedMemberInfoValidator, - minimallyEncodedRawThreadInfoValidator, - minimallyEncodeRawThreadInfo, - decodeMinimallyEncodedRawThreadInfo, }; diff --git a/lib/permissions/minimally-encoded-thread-permissions.test.js b/lib/permissions/minimally-encoded-thread-permissions.test.js index f0c1aa56b..f0e166b35 100644 --- a/lib/permissions/minimally-encoded-thread-permissions.test.js +++ b/lib/permissions/minimally-encoded-thread-permissions.test.js @@ -1,452 +1,454 @@ // @flow import { exampleMinimallyEncodedRawThreadInfoA, exampleRawThreadInfoA, expectedDecodedExampleRawThreadInfoA, } from './minimally-encoded-thread-permissions-test-data.js'; import { - decodeMinimallyEncodedRawThreadInfo, decodeRolePermissionBitmask, decodeThreadRolePermissionsBitmaskArray, hasPermission, - minimallyEncodedMemberInfoValidator, - minimallyEncodedRawThreadInfoValidator, - minimallyEncodedRoleInfoValidator, - minimallyEncodedThreadCurrentUserInfoValidator, - minimallyEncodeRawThreadInfo, permissionsToBitmaskHex, rolePermissionToBitmaskHex, threadPermissionsFromBitmaskHex, threadRolePermissionsBlobToBitmaskArray, } from './minimally-encoded-thread-permissions.js'; +import { + minimallyEncodedMemberInfoValidator, + minimallyEncodedThreadCurrentUserInfoValidator, + minimallyEncodedRawThreadInfoValidator, + minimallyEncodedRoleInfoValidator, + minimallyEncodeRawThreadInfo, + decodeMinimallyEncodedRawThreadInfo, +} from '../types/minimally-encoded-thread-permissions-types.js'; import type { ThreadRolePermissionsBlob } from '../types/thread-permission-types.js'; const permissions = { know_of: { value: true, source: '1' }, visible: { value: true, source: '1' }, voiced: { value: true, source: '1' }, edit_entries: { value: true, source: '1' }, edit_thread: { value: true, source: '1' }, edit_thread_description: { value: true, source: '1' }, edit_thread_color: { value: true, source: '1' }, delete_thread: { value: true, source: '1' }, create_subthreads: { value: true, source: '1' }, create_sidebars: { value: true, source: '1' }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: true, source: '1' }, remove_members: { value: true, source: '1' }, change_role: { value: true, source: '1' }, leave_thread: { value: false, source: null }, react_to_message: { value: true, source: '1' }, edit_message: { value: true, source: '1' }, edit_thread_avatar: { value: false, source: null }, manage_pins: { value: false, source: null }, manage_invite_links: { value: false, source: null }, }; describe('minimallyEncodedThreadPermissions', () => { it('should encode ThreadPermissionsInfo as bitmask', () => { const permissionsBitmask = permissionsToBitmaskHex(permissions); expect(permissionsBitmask).toBe('373ff'); expect(hasPermission(permissionsBitmask, 'know_of')).toBe(true); expect(hasPermission(permissionsBitmask, 'visible')).toBe(true); expect(hasPermission(permissionsBitmask, 'voiced')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_entries')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_thread')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_thread_description')).toBe( true, ); expect(hasPermission(permissionsBitmask, 'edit_thread_color')).toBe(true); expect(hasPermission(permissionsBitmask, 'delete_thread')).toBe(true); expect(hasPermission(permissionsBitmask, 'create_subthreads')).toBe(true); expect(hasPermission(permissionsBitmask, 'create_sidebars')).toBe(true); expect(hasPermission(permissionsBitmask, 'join_thread')).toBe(false); expect(hasPermission(permissionsBitmask, 'edit_permissions')).toBe(false); expect(hasPermission(permissionsBitmask, 'remove_members')).toBe(true); expect(hasPermission(permissionsBitmask, 'change_role')).toBe(true); expect(hasPermission(permissionsBitmask, 'leave_thread')).toBe(false); expect(hasPermission(permissionsBitmask, 'react_to_message')).toBe(true); expect(hasPermission(permissionsBitmask, 'edit_message')).toBe(true); }); }); describe('threadPermissionsFromBitmaskHex', () => { const expectedDecodedThreadPermissions = { know_of: { value: true, source: 'null' }, visible: { value: true, source: 'null' }, voiced: { value: true, source: 'null' }, edit_entries: { value: true, source: 'null' }, edit_thread: { value: true, source: 'null' }, edit_thread_description: { value: true, source: 'null' }, edit_thread_color: { value: true, source: 'null' }, delete_thread: { value: true, source: 'null' }, create_subthreads: { value: true, source: 'null' }, create_sidebars: { value: true, source: 'null' }, join_thread: { value: false, source: null }, edit_permissions: { value: false, source: null }, add_members: { value: true, source: 'null' }, remove_members: { value: true, source: 'null' }, change_role: { value: true, source: 'null' }, leave_thread: { value: false, source: null }, react_to_message: { value: true, source: 'null' }, edit_message: { value: true, source: 'null' }, edit_thread_avatar: { value: false, source: null }, manage_pins: { value: false, source: null }, manage_invite_links: { value: false, source: null }, }; it('should decode ThreadPermissionsInfo from bitmask', () => { const permissionsBitmask = permissionsToBitmaskHex(permissions); const decodedThreadPermissions = threadPermissionsFromBitmaskHex(permissionsBitmask); expect(decodedThreadPermissions).toStrictEqual( expectedDecodedThreadPermissions, ); }); }); describe('rolePermissionToBitmaskHex', () => { it('should encode `child_opentoplevel_visible` successfully', () => { expect(rolePermissionToBitmaskHex('child_opentoplevel_visible')).toBe( '01b', ); }); it('should encode `child_opentoplevel_know_of` successfully', () => { expect(rolePermissionToBitmaskHex('child_opentoplevel_know_of')).toBe( '00b', ); }); it('should encode `child_toplevel_visible` successfully', () => { expect(rolePermissionToBitmaskHex('child_toplevel_visible')).toBe('01a'); }); it('should encode `child_toplevel_know_of` successfully', () => { expect(rolePermissionToBitmaskHex('child_toplevel_know_of')).toBe('00a'); }); it('should encode `child_opentoplevel_join_thread` successfully', () => { expect(rolePermissionToBitmaskHex('child_opentoplevel_join_thread')).toBe( '0ab', ); }); it('should encode `child_visible` successfully', () => { expect(rolePermissionToBitmaskHex('child_visible')).toBe('018'); }); it('should encode `child_know_of` successfully', () => { expect(rolePermissionToBitmaskHex('child_know_of')).toBe('008'); }); }); describe('decodeRolePermissionBitmask', () => { it('should decode `01b` to `child_opentoplevel_visible` successfully', () => { expect(decodeRolePermissionBitmask('01b')).toBe( 'child_opentoplevel_visible', ); }); it('should decode `00b` to `child_opentoplevel_know_of` successfully', () => { expect(decodeRolePermissionBitmask('00b')).toBe( 'child_opentoplevel_know_of', ); }); it('should decode `01a` to `child_toplevel_visible` successfully', () => { expect(decodeRolePermissionBitmask('01a')).toBe('child_toplevel_visible'); }); it('should decode `00a` to `child_toplevel_know_of` successfully', () => { expect(decodeRolePermissionBitmask('00a')).toBe('child_toplevel_know_of'); }); it('should decode `0ab` to `child_opentoplevel_join_thread` successfully', () => { expect(decodeRolePermissionBitmask('0ab')).toBe( 'child_opentoplevel_join_thread', ); }); it('should decode `018` to `child_visible` successfully', () => { expect(decodeRolePermissionBitmask('018')).toBe('child_visible'); }); it('should decode `008` to `child_know_of` successfully', () => { expect(decodeRolePermissionBitmask('008')).toBe('child_know_of'); }); }); const threadRolePermissionsBlob: ThreadRolePermissionsBlob = { add_members: true, child_open_join_thread: true, create_sidebars: true, create_subthreads: true, descendant_open_know_of: true, descendant_open_visible: true, descendant_opentoplevel_join_thread: true, edit_entries: true, edit_message: true, edit_permissions: true, edit_thread: true, edit_thread_avatar: true, edit_thread_color: true, edit_thread_description: true, know_of: true, leave_thread: true, react_to_message: true, remove_members: true, visible: true, voiced: true, open_know_of: true, open_visible: true, opentoplevel_join_thread: true, toplevel_know_of: true, toplevel_visible: true, opentoplevel_know_of: true, opentoplevel_visible: true, child_know_of: true, child_visible: true, child_opentoplevel_join_thread: true, child_toplevel_know_of: true, child_toplevel_visible: true, child_opentoplevel_know_of: true, child_opentoplevel_visible: true, }; const threadRolePermissionsBitmaskArray = [ '0c0', '0a9', '090', '080', '005', '015', '0a7', '030', '110', '0b0', '040', '120', '060', '050', '000', '0f0', '100', '0d0', '010', '020', '001', '011', '0a3', '002', '012', '003', '013', '008', '018', '0ab', '00a', '01a', '00b', '01b', ]; describe('threadRolePermissionsBlobToBitmaskArray', () => { it('should encode threadRolePermissionsBlob as bitmask array', () => { const arr = threadRolePermissionsBlobToBitmaskArray( threadRolePermissionsBlob, ); expect(arr).toEqual(threadRolePermissionsBitmaskArray); }); }); describe('decodeThreadRolePermissionsBitmaskArray', () => { it('should decode threadRolePermissionsBitmaskArray', () => { expect( decodeThreadRolePermissionsBitmaskArray( threadRolePermissionsBitmaskArray, ), ).toEqual(threadRolePermissionsBlob); }); }); describe('minimallyEncodedRoleInfoValidator', () => { it('should validate correctly formed MinimallyEncodedRoleInfo', () => { expect( minimallyEncodedRoleInfoValidator.is({ minimallyEncoded: true, id: 'roleID', name: 'roleName', permissions: ['abc', 'def'], isDefault: true, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedRoleInfo', () => { expect( minimallyEncodedRoleInfoValidator.is({ id: 1234, name: 'roleName', permissions: ['abc', 'def'], isDefault: true, }), ).toBe(false); expect( minimallyEncodedRoleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: ['hello a02 test', 'def'], isDefault: true, }), ).toBe(false); expect( minimallyEncodedRoleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: [123, 456], isDefault: true, }), ).toBe(false); expect( minimallyEncodedRoleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: ['ZZZ', 'YYY'], isDefault: true, }), ).toBe(false); expect( minimallyEncodedRoleInfoValidator.is({ id: 'roleID', name: 'roleName', permissions: ['AAAAA', 'YYY'], isDefault: true, }), ).toBe(false); }); }); describe('minimallyEncodedThreadCurrentUserInfoValidator', () => { it('should validate correctly formed MinimallyEncodedThreadCurrentUserInfo', () => { expect( minimallyEncodedThreadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: '100', subscription: { home: true, pushNotifs: true }, }), ).toBe(true); expect( minimallyEncodedThreadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 'ABCDEFABCDEFABCD', subscription: { home: true, pushNotifs: true }, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedThreadCurrentUserInfo', () => { expect( minimallyEncodedThreadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 'INVALID', subscription: { home: true, pushNotifs: true }, }), ).toBe(false); expect( minimallyEncodedThreadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 'ABCDEF hello ABCDEFABCD', subscription: { home: true, pushNotifs: true }, }), ).toBe(false); expect( minimallyEncodedThreadCurrentUserInfoValidator.is({ minimallyEncoded: true, permissions: 100, subscription: { home: true, pushNotifs: true }, }), ).toBe(false); }); }); describe('minimallyEncodedMemberInfoValidator', () => { it('should validate correctly formed MinimallyEncodedMemberInfo', () => { expect( minimallyEncodedMemberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 'ABCDEF', isSender: true, }), ).toBe(true); expect( minimallyEncodedMemberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: '01b', isSender: false, }), ).toBe(true); }); it('should NOT validate malformed MinimallyEncodedMemberInfo', () => { expect( minimallyEncodedMemberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 'INVALID', isSender: false, }), ).toBe(false); expect( minimallyEncodedMemberInfoValidator.is({ minimallyEncoded: true, id: 'memberID', permissions: 100, isSender: false, }), ).toBe(false); }); }); describe('minimallyEncodedRawThreadInfoValidator', () => { it('should validate correctly formed MinimallyEncodedRawThreadInfo', () => { expect( minimallyEncodedRawThreadInfoValidator.is( exampleMinimallyEncodedRawThreadInfoA, ), ).toBe(true); }); }); describe('minimallyEncodeRawThreadInfo', () => { it('should correctly encode RawThreadInfo', () => { expect( minimallyEncodedRawThreadInfoValidator.is( minimallyEncodeRawThreadInfo(exampleRawThreadInfoA), ), ).toBe(true); }); }); describe('decodeMinimallyEncodedRawThreadInfo', () => { it('should correctly decode minimallyEncodedRawThreadInfo', () => { expect( decodeMinimallyEncodedRawThreadInfo( minimallyEncodeRawThreadInfo(exampleRawThreadInfoA), ), ).toStrictEqual(expectedDecodedExampleRawThreadInfoA); }); }); diff --git a/lib/types/minimally-encoded-thread-permissions-types.js b/lib/types/minimally-encoded-thread-permissions-types.js new file mode 100644 index 000000000..a5a6ff2f2 --- /dev/null +++ b/lib/types/minimally-encoded-thread-permissions-types.js @@ -0,0 +1,187 @@ +// @flow + +import _mapValues from 'lodash/fp/mapValues.js'; +import type { TInterface } from 'tcomb'; +import t from 'tcomb'; + +import type { + MemberInfo, + RawThreadInfo, + RoleInfo, + ThreadCurrentUserInfo, +} from './thread-types.js'; +import { + memberInfoValidator, + rawThreadInfoValidator, + roleInfoValidator, + threadCurrentUserInfoValidator, +} from './thread-types.js'; +import { + decodeThreadRolePermissionsBitmaskArray, + permissionsToBitmaskHex, + threadPermissionsFromBitmaskHex, + threadRolePermissionsBlobToBitmaskArray, +} from '../permissions/minimally-encoded-thread-permissions.js'; +import type { TRegex } from '../utils/validation-utils.js'; +import { tBool, tID, tRegex, tShape } from '../utils/validation-utils.js'; + +export type MinimallyEncodedRoleInfo = $ReadOnly<{ + ...RoleInfo, + +minimallyEncoded: true, + +permissions: $ReadOnlyArray, +}>; + +const tHexEncodedRolePermission: TRegex = tRegex(/^[0-9a-fA-F]{3,}$/); +const minimallyEncodedRoleInfoValidator: TInterface = + tShape({ + ...roleInfoValidator.meta.props, + minimallyEncoded: tBool(true), + permissions: t.list(tHexEncodedRolePermission), + }); + +const minimallyEncodeRoleInfo = ( + roleInfo: RoleInfo, +): MinimallyEncodedRoleInfo => ({ + ...roleInfo, + minimallyEncoded: true, + permissions: threadRolePermissionsBlobToBitmaskArray(roleInfo.permissions), +}); + +const decodeMinimallyEncodedRoleInfo = ( + minimallyEncodedRoleInfo: MinimallyEncodedRoleInfo, +): RoleInfo => { + const { minimallyEncoded, ...rest } = minimallyEncodedRoleInfo; + return { + ...rest, + permissions: decodeThreadRolePermissionsBitmaskArray( + minimallyEncodedRoleInfo.permissions, + ), + }; +}; + +export type MinimallyEncodedThreadCurrentUserInfo = $ReadOnly<{ + ...ThreadCurrentUserInfo, + +minimallyEncoded: true, + +permissions: string, +}>; + +const tHexEncodedPermissionsBitmask: TRegex = tRegex(/^[0-9a-fA-F]+$/); +const minimallyEncodedThreadCurrentUserInfoValidator: TInterface = + tShape({ + ...threadCurrentUserInfoValidator.meta.props, + minimallyEncoded: tBool(true), + permissions: tHexEncodedPermissionsBitmask, + }); + +const minimallyEncodeThreadCurrentUserInfo = ( + threadCurrentUserInfo: ThreadCurrentUserInfo, +): MinimallyEncodedThreadCurrentUserInfo => ({ + ...threadCurrentUserInfo, + minimallyEncoded: true, + permissions: permissionsToBitmaskHex(threadCurrentUserInfo.permissions), +}); + +const decodeMinimallyEncodedThreadCurrentUserInfo = ( + minimallyEncodedThreadCurrentUserInfo: MinimallyEncodedThreadCurrentUserInfo, +): ThreadCurrentUserInfo => { + const { minimallyEncoded, ...rest } = minimallyEncodedThreadCurrentUserInfo; + return { + ...rest, + permissions: threadPermissionsFromBitmaskHex( + minimallyEncodedThreadCurrentUserInfo.permissions, + ), + }; +}; + +export type MinimallyEncodedMemberInfo = $ReadOnly<{ + ...MemberInfo, + +minimallyEncoded: true, + +permissions: string, +}>; + +const minimallyEncodedMemberInfoValidator: TInterface = + tShape({ + ...memberInfoValidator.meta.props, + minimallyEncoded: tBool(true), + permissions: tHexEncodedPermissionsBitmask, + }); + +const minimallyEncodeMemberInfo = ( + memberInfo: MemberInfo, +): MinimallyEncodedMemberInfo => ({ + ...memberInfo, + minimallyEncoded: true, + permissions: permissionsToBitmaskHex(memberInfo.permissions), +}); + +const decodeMinimallyEncodedMemberInfo = ( + minimallyEncodedMemberInfo: MinimallyEncodedMemberInfo, +): MemberInfo => { + const { minimallyEncoded, ...rest } = minimallyEncodedMemberInfo; + return { + ...rest, + permissions: threadPermissionsFromBitmaskHex( + minimallyEncodedMemberInfo.permissions, + ), + }; +}; + +export type MinimallyEncodedRawThreadInfo = $ReadOnly<{ + ...RawThreadInfo, + +minimallyEncoded: true, + +members: $ReadOnlyArray, + +roles: { +[id: string]: MinimallyEncodedRoleInfo }, + +currentUser: MinimallyEncodedThreadCurrentUserInfo, +}>; + +const minimallyEncodedRawThreadInfoValidator: TInterface = + tShape({ + ...rawThreadInfoValidator.meta.props, + minimallyEncoded: tBool(true), + members: t.list(minimallyEncodedMemberInfoValidator), + roles: t.dict(tID, minimallyEncodedRoleInfoValidator), + currentUser: minimallyEncodedThreadCurrentUserInfoValidator, + }); + +const minimallyEncodeRawThreadInfo = ( + rawThreadInfo: RawThreadInfo, +): MinimallyEncodedRawThreadInfo => { + const { members, roles, currentUser, ...rest } = rawThreadInfo; + return { + ...rest, + minimallyEncoded: true, + members: members.map(minimallyEncodeMemberInfo), + roles: _mapValues(minimallyEncodeRoleInfo)(roles), + currentUser: minimallyEncodeThreadCurrentUserInfo(currentUser), + }; +}; + +const decodeMinimallyEncodedRawThreadInfo = ( + minimallyEncodedRawThreadInfo: MinimallyEncodedRawThreadInfo, +): RawThreadInfo => { + const { minimallyEncoded, members, roles, currentUser, ...rest } = + minimallyEncodedRawThreadInfo; + return { + ...rest, + members: members.map(decodeMinimallyEncodedMemberInfo), + roles: _mapValues(decodeMinimallyEncodedRoleInfo)(roles), + currentUser: decodeMinimallyEncodedThreadCurrentUserInfo(currentUser), + }; +}; + +export { + tHexEncodedRolePermission, + minimallyEncodedRoleInfoValidator, + minimallyEncodeRoleInfo, + decodeMinimallyEncodedRoleInfo, + tHexEncodedPermissionsBitmask, + minimallyEncodedThreadCurrentUserInfoValidator, + minimallyEncodeThreadCurrentUserInfo, + decodeMinimallyEncodedThreadCurrentUserInfo, + minimallyEncodedMemberInfoValidator, + minimallyEncodeMemberInfo, + decodeMinimallyEncodedMemberInfo, + minimallyEncodedRawThreadInfoValidator, + minimallyEncodeRawThreadInfo, + decodeMinimallyEncodedRawThreadInfo, +};