From 20541ab198f105fbd1fc007bb2592895490212d3 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 31 Jan 2024 17:51:36 +0000 Subject: [PATCH 1/4] feat!: rename "Capability" to "Role", attach ID This change makes several improvements to the concept formerly known as "capabilities": - `Capability` is now called `Role` across the project, resulting in many simple renames. - `Role`s have an attached `roleId` property, which they didn't before. - The creator role and "no role" role now have role IDs.[^1] - The `RoleId` type now includes all possible role types, now including the creator role and the "no role" role. I created narrower types such as `RoleIdAssignableToOthers` and `RoleIdAssignableToAnyone`, which don't include those. This should help the front-end know what role someone is (for example, ). [^1]: I generated these with `crypto.randomBytes(8).toString('hex')`. --- src/mapeo-manager.js | 14 +-- src/mapeo-project.js | 22 ++--- src/member-api.js | 36 ++++---- src/{capabilities.js => roles.js} | 141 ++++++++++++++++++------------ src/sync/peer-sync-controller.js | 14 +-- src/sync/sync-api.js | 10 +-- src/types.ts | 5 -- test-e2e/manager-basic.js | 2 +- test-e2e/manager-invite.js | 4 +- test-e2e/members.js | 132 +++++++++++++--------------- test-e2e/project-leave.js | 32 +++---- test-e2e/sync.js | 2 +- test-e2e/utils.js | 4 +- tests/invite-api.js | 28 +++--- tests/local-peers.js | 34 +++---- 15 files changed, 248 insertions(+), 232 deletions(-) rename src/{capabilities.js => roles.js} (69%) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index c15f5fc1..d7728581 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -37,7 +37,7 @@ import { getFastifyServerAddress } from './fastify-plugins/utils.js' import { LocalPeers } from './local-peers.js' import { InviteApi } from './invite-api.js' import { LocalDiscovery } from './discovery/local-discovery.js' -import { Capabilities } from './capabilities.js' +import { Roles } from './roles.js' import NoiseSecretStream from '@hyperswarm/secret-stream' import { Logger } from './logger.js' import { kSyncState } from './sync/sync-api.js' @@ -590,7 +590,7 @@ export class MapeoManager extends TypedEmitter { } /** - * Sync initial data: the `auth` cores which contain the capability messages, + * Sync initial data: the `auth` cores which contain the role messages, * and the `config` cores which contain the project name & custom config (if * it exists). The API consumer should await this after `client.addProject()` * to ensure that the device is fully added to the project. @@ -605,26 +605,26 @@ export class MapeoManager extends TypedEmitter { * @returns {Promise} */ async #waitForInitialSync(project, { timeoutMs = 5000 } = {}) { - const [capability, projectSettings] = await Promise.all([ - project.$getOwnCapabilities(), + const [ownRole, projectSettings] = await Promise.all([ + project.$getOwnRole(), project.$getProjectSettings(), ]) const { auth: { localState: authState }, config: { localState: configState }, } = project.$sync[kSyncState].getState() - const isCapabilitySynced = capability !== Capabilities.NO_ROLE_CAPABILITIES + const isRoleSynced = ownRole !== Roles.NO_ROLE const isProjectSettingsSynced = projectSettings !== MapeoProject.EMPTY_PROJECT_SETTINGS // Assumes every project that someone is invited to has at least one record - // in the auth store - the capability record for the invited device + // in the auth store - the row record for the invited device const isAuthSynced = authState.want === 0 && authState.have > 0 // Assumes every project that someone is invited to has at least one record // in the config store - defining the name of the project. // TODO: Enforce adding a project name in the invite method const isConfigSynced = configState.want === 0 && configState.have > 0 if ( - isCapabilitySynced && + isRoleSynced && isProjectSettingsSynced && isAuthSynced && isConfigSynced diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 553c2ccd..9e87c2de 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -32,9 +32,9 @@ import { import { BLOCKED_ROLE_ID, COORDINATOR_ROLE_ID, - Capabilities, + Roles, LEFT_ROLE_ID, -} from './capabilities.js' +} from './roles.js' import { getDeviceId, projectKeyToId, @@ -71,7 +71,7 @@ export class MapeoProject extends TypedEmitter { #dataTypes #blobStore #coreOwnership - #capabilities + #roles /** @ts-ignore */ #ownershipWriteDone #sqlite @@ -246,7 +246,7 @@ export class MapeoProject extends TypedEmitter { coreKeypairs, identityKeypair, }) - this.#capabilities = new Capabilities({ + this.#roles = new Roles({ dataType: this.#dataTypes.role, coreOwnership: this.#coreOwnership, coreManager: this.#coreManager, @@ -256,7 +256,7 @@ export class MapeoProject extends TypedEmitter { this.#memberApi = new MemberApi({ deviceId: this.#deviceId, - capabilities: this.#capabilities, + roles: this.#roles, coreOwnership: this.#coreOwnership, encryptionKeys, projectKey, @@ -298,7 +298,7 @@ export class MapeoProject extends TypedEmitter { this.#syncApi = new SyncApi({ coreManager: this.#coreManager, - capabilities: this.#capabilities, + roles: this.#roles, logger: this.#l, }) @@ -325,7 +325,7 @@ export class MapeoProject extends TypedEmitter { } // When a new peer is found, try to replicate (if it is not a member of the - // project it will fail the capability check and be ignored) + // project it will fail the role check and be ignored) localPeers.on('peer-add', onPeerAdd) // This happens whenever a peer replicates a core to the stream. SyncApi @@ -494,8 +494,8 @@ export class MapeoProject extends TypedEmitter { } } - async $getOwnCapabilities() { - return this.#capabilities.getCapabilities(this.#deviceId) + async $getOwnRole() { + return this.#roles.getRole(this.#deviceId) } /** @@ -567,7 +567,7 @@ export class MapeoProject extends TypedEmitter { throw new Error('Cannot leave a project as a blocked device') } - const knownDevices = Object.keys(await this.#capabilities.getAll()) + const knownDevices = Object.keys(await this.#roles.getAll()) const projectCreatorDeviceId = await this.#coreOwnership.getOwner( this.#projectId ) @@ -629,7 +629,7 @@ export class MapeoProject extends TypedEmitter { // 3.2 Clear indexed data // 4. Assign LEFT role for device - await this.#capabilities.assignRole(this.#deviceId, LEFT_ROLE_ID) + await this.#roles.assignRole(this.#deviceId, LEFT_ROLE_ID) } } diff --git a/src/member-api.js b/src/member-api.js index c37292ed..8ba85f4e 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -1,15 +1,15 @@ import { TypedEmitter } from 'tiny-typed-emitter' import { InviteResponse_Decision } from './generated/rpc.js' import { projectKeyToId } from './utils.js' -import { DEFAULT_CAPABILITIES } from './capabilities.js' +import { ROLES } from './roles.js' /** @typedef {import('./datatype/index.js').DataType, typeof import('./schema/project.js').deviceInfoTable, "deviceInfo", import('@mapeo/schema').DeviceInfo, import('@mapeo/schema').DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {import('./datatype/index.js').DataType, typeof import('./schema/client.js').projectSettingsTable, "projectSettings", import('@mapeo/schema').ProjectSettings, import('@mapeo/schema').ProjectSettingsValue>} ProjectDataType */ -/** @typedef {{ deviceId: string, name?: import('@mapeo/schema').DeviceInfo['name'], capabilities: import('./capabilities.js').Capability }} MemberInfo */ +/** @typedef {{ deviceId: string, name?: import('@mapeo/schema').DeviceInfo['name'], role: import('./roles.js').Role }} MemberInfo */ export class MemberApi extends TypedEmitter { #ownDeviceId - #capabilities + #roles #coreOwnership #encryptionKeys #projectKey @@ -19,7 +19,7 @@ export class MemberApi extends TypedEmitter { /** * @param {Object} opts * @param {string} opts.deviceId public key of this device as hex string - * @param {import('./capabilities.js').Capabilities} opts.capabilities + * @param {import('./roles.js').Roles} opts.roles * @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey @@ -30,7 +30,7 @@ export class MemberApi extends TypedEmitter { */ constructor({ deviceId, - capabilities, + roles, coreOwnership, encryptionKeys, projectKey, @@ -39,7 +39,7 @@ export class MemberApi extends TypedEmitter { }) { super() this.#ownDeviceId = deviceId - this.#capabilities = capabilities + this.#roles = roles this.#coreOwnership = coreOwnership this.#encryptionKeys = encryptionKeys this.#projectKey = projectKey @@ -51,7 +51,7 @@ export class MemberApi extends TypedEmitter { * @param {string} deviceId * * @param {Object} opts - * @param {import('./capabilities.js').RoleId} opts.roleId + * @param {import('./roles.js').RoleIdAssignableToOthers} opts.roleId * @param {string} [opts.roleName] * @param {string} [opts.roleDescription] * @param {number} [opts.timeout] @@ -59,7 +59,7 @@ export class MemberApi extends TypedEmitter { * @returns {Promise} */ async invite(deviceId, { roleId, roleName, roleDescription, timeout }) { - if (!DEFAULT_CAPABILITIES[roleId]) { + if (!ROLES[roleId]) { throw new Error('Invalid role id') } @@ -82,14 +82,14 @@ export class MemberApi extends TypedEmitter { projectKey: this.#projectKey, encryptionKeys: this.#encryptionKeys, projectInfo: { name: project.name }, - roleName: roleName || DEFAULT_CAPABILITIES[roleId].name, + roleName: roleName || ROLES[roleId].name, roleDescription, invitorName: deviceName, timeout, }) if (response === InviteResponse_Decision.ACCEPT) { - await this.#capabilities.assignRole(deviceId, roleId) + await this.#roles.assignRole(deviceId, roleId) } return response @@ -100,10 +100,10 @@ export class MemberApi extends TypedEmitter { * @returns {Promise} */ async getById(deviceId) { - const capabilities = await this.#capabilities.getCapabilities(deviceId) + const role = await this.#roles.getRole(deviceId) /** @type {MemberInfo} */ - const result = { deviceId, capabilities } + const result = { deviceId, role } try { const configCoreId = await this.#coreOwnership.getCoreId( @@ -129,15 +129,15 @@ export class MemberApi extends TypedEmitter { * @returns {Promise>} */ async getMany() { - const [allCapabilities, allDeviceInfo] = await Promise.all([ - this.#capabilities.getAll(), + const [allRoles, allDeviceInfo] = await Promise.all([ + this.#roles.getAll(), this.#dataTypes.deviceInfo.getMany(), ]) return Promise.all( - Object.entries(allCapabilities).map(async ([deviceId, capabilities]) => { + Object.entries(allRoles).map(async ([deviceId, role]) => { /** @type {MemberInfo} */ - const memberInfo = { deviceId, capabilities } + const memberInfo = { deviceId, role } try { const configCoreId = await this.#coreOwnership.getCoreId( @@ -163,10 +163,10 @@ export class MemberApi extends TypedEmitter { /** * @param {string} deviceId - * @param {import('./capabilities.js').RoleId} roleId + * @param {import('./roles.js').RoleIdAssignableToOthers} roleId * @returns {Promise} */ async assignRole(deviceId, roleId) { - return this.#capabilities.assignRole(deviceId, roleId) + return this.#roles.assignRole(deviceId, roleId) } } diff --git a/src/capabilities.js b/src/roles.js similarity index 69% rename from src/capabilities.js rename to src/roles.js index 97a6b136..7effe4a8 100644 --- a/src/capabilities.js +++ b/src/roles.js @@ -3,10 +3,12 @@ import mapObject from 'map-obj' import { kCreateWithDocId } from './datatype/index.js' // Randomly generated 8-byte encoded as hex +export const CREATOR_ROLE_ID = 'a12a6702b93bd7ff' export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855' export const MEMBER_ROLE_ID = '012fd2d431c0bf60' export const BLOCKED_ROLE_ID = '9e6d29263cba36c9' export const LEFT_ROLE_ID = '8ced989b1904606b' +export const NO_ROLE_ID = '08e4251e36f6e7ed' /** * @typedef {object} DocCapability @@ -17,25 +19,51 @@ export const LEFT_ROLE_ID = '8ced989b1904606b' */ /** - * @typedef {object} Capability + * @typedef {object} Role + * @property {RoleId} roleId * @property {string} name * @property {Record} docs - * @property {RoleId[]} roleAssignment + * @property {RoleIdAssignableToOthers[]} roleAssignment * @property {Record} sync */ /** - * @typedef {typeof COORDINATOR_ROLE_ID | typeof MEMBER_ROLE_ID | typeof BLOCKED_ROLE_ID | typeof LEFT_ROLE_ID} RoleId + * @typedef {( + * typeof CREATOR_ROLE_ID | + * typeof COORDINATOR_ROLE_ID | + * typeof MEMBER_ROLE_ID | + * typeof BLOCKED_ROLE_ID | + * typeof LEFT_ROLE_ID | + * typeof NO_ROLE_ID + * )} RoleId */ /** - * This is currently the same as 'Coordinator' capabilities, but defined - * separately because the creator should always have ALL capabilities, but we - * could edit 'Coordinator' capabilities in the future + * @typedef {Extract} RoleIdAssignableToOthers + */ + +/** + * @typedef {Extract} RoleIdAssignableToAnyone + */ + +/** + * This is currently the same as 'Coordinator' role, but defined separately + * because the creator should always have ALL powers, but we could edit the + * 'Coordinator' powers in the future. * - * @type {Capability} + * @type {Role} */ -export const CREATOR_CAPABILITIES = { +export const CREATOR_ROLE = { + roleId: CREATOR_ROLE_ID, name: 'Project Creator', docs: mapObject(currentSchemaVersions, (key) => { return [ @@ -54,16 +82,17 @@ export const CREATOR_CAPABILITIES = { } /** - * These are the capabilities assumed for a device when no capability record can - * be found. This can happen when an invited device did not manage to sync with - * the device that invited them, and they then try to sync with someone else. We - * want them to be able to sync the auth and config store, because that way they - * may be able to receive their role record, and they can get the project config - * so that they can start collecting data. + * This is the role assumed for a device when no role record can be found. This + * can happen when an invited device did not manage to sync with the device that + * invited them, and they then try to sync with someone else. We want them to be + * able to sync the auth and config store, because that way they may be able to + * receive their role record, and they can get the project config so that they + * can start collecting data. * - * @type {Capability} + * @type {Role} */ -export const NO_ROLE_CAPABILITIES = { +export const NO_ROLE = { + roleId: NO_ROLE_ID, name: 'No Role', docs: mapObject(currentSchemaVersions, (key) => { return [ @@ -81,9 +110,11 @@ export const NO_ROLE_CAPABILITIES = { }, } -/** @type {Record} */ -export const DEFAULT_CAPABILITIES = { +/** @type {Record} */ +export const ROLES = { + [CREATOR_ROLE_ID]: CREATOR_ROLE, [MEMBER_ROLE_ID]: { + roleId: MEMBER_ROLE_ID, name: 'Member', docs: mapObject(currentSchemaVersions, (key) => { return [ @@ -101,6 +132,7 @@ export const DEFAULT_CAPABILITIES = { }, }, [COORDINATOR_ROLE_ID]: { + roleId: COORDINATOR_ROLE_ID, name: 'Coordinator', docs: mapObject(currentSchemaVersions, (key) => { return [ @@ -118,6 +150,7 @@ export const DEFAULT_CAPABILITIES = { }, }, [BLOCKED_ROLE_ID]: { + roleId: BLOCKED_ROLE_ID, name: 'Blocked', docs: mapObject(currentSchemaVersions, (key) => { return [ @@ -140,6 +173,7 @@ export const DEFAULT_CAPABILITIES = { }, }, [LEFT_ROLE_ID]: { + roleId: LEFT_ROLE_ID, name: 'Left', docs: mapObject(currentSchemaVersions, (key) => { return [ @@ -161,16 +195,17 @@ export const DEFAULT_CAPABILITIES = { blob: 'blocked', }, }, + [NO_ROLE_ID]: NO_ROLE, } -export class Capabilities { +export class Roles { #dataType #coreOwnership #coreManager #projectCreatorAuthCoreId #ownDeviceId - static NO_ROLE_CAPABILITIES = NO_ROLE_CAPABILITIES + static NO_ROLE = NO_ROLE /** * @@ -196,54 +231,53 @@ export class Capabilities { } /** - * Get the capabilities for device `deviceId`. + * Get the role for device `deviceId`. * * @param {string} deviceId - * @returns {Promise} + * @returns {Promise} */ - async getCapabilities(deviceId) { + async getRole(deviceId) { let roleId try { const roleAssignment = await this.#dataType.getByDocId(deviceId) roleId = roleAssignment.roleId } catch (e) { - // The project creator will have all capabilities + // The project creator will have the creator role const authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth') if (authCoreId === this.#projectCreatorAuthCoreId) { - return CREATOR_CAPABILITIES + return CREATOR_ROLE } else { // When no role assignment exists, e.g. a newly added device which has // not yet synced role records. - return NO_ROLE_CAPABILITIES + return NO_ROLE } } if (!isKnownRoleId(roleId)) { - return DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID] + return ROLES[BLOCKED_ROLE_ID] } - const capabilities = DEFAULT_CAPABILITIES[roleId] - return capabilities + return ROLES[roleId] } /** - * Get capabilities of all devices in the project. For your own device, if you - * have not yet synced your own role record, the "no role" capabilties is - * returned. The project creator will have `CREATOR_CAPABILITIES` unless a - * different role has been assigned. + * Get roles of all devices in the project. For your own device, if you have + * not yet synced your own role record, the "no role" capabilties is + * returned. The project creator will have the creator role unless a + * different one has been assigned. * - * @returns {Promise>} Map of deviceId to Capability + * @returns {Promise>} Map of deviceId to Role */ async getAll() { const roles = await this.#dataType.getMany() - /** @type {Record} */ - const capabilities = {} + /** @type {Record} */ + const result = {} let projectCreatorDeviceId try { projectCreatorDeviceId = await this.#coreOwnership.getOwner( this.#projectCreatorAuthCoreId ) - // Default to creator capabilities, but can be overwritten if a different - // role is set below - capabilities[projectCreatorDeviceId] = CREATOR_CAPABILITIES + // Default to creator role, but can be overwritten if a different role is + // set below + result[projectCreatorDeviceId] = CREATOR_ROLE } catch (e) { // Not found, we don't know who the project creator is so we can't include // them in the returned map @@ -252,29 +286,28 @@ export class Capabilities { for (const role of roles) { const deviceId = role.docId if (!isKnownRoleId(role.roleId)) continue - capabilities[deviceId] = DEFAULT_CAPABILITIES[role.roleId] + result[deviceId] = ROLES[role.roleId] } - const includesSelf = Boolean(capabilities[this.#ownDeviceId]) + const includesSelf = Boolean(result[this.#ownDeviceId]) if (!includesSelf) { const isProjectCreator = this.#ownDeviceId === projectCreatorDeviceId if (isProjectCreator) { - capabilities[this.#ownDeviceId] = CREATOR_CAPABILITIES + result[this.#ownDeviceId] = CREATOR_ROLE } else { - capabilities[this.#ownDeviceId] = NO_ROLE_CAPABILITIES + result[this.#ownDeviceId] = NO_ROLE } } - return capabilities + return result } /** * Assign a role to the specified `deviceId`. Devices without an assigned role - * are unable to sync, except the project creator that defaults to having all - * capabilities. Only the project creator can assign their own role. Will - * throw if the device trying to assign the role lacks the `roleAssignment` - * capability for the given roleId + * are unable to sync, except the project creator who can do anything. Only + * the project creator can assign their own role. Will throw if the device's + * role cannot assign the role by consulting `roleAssignment`. * * @param {string} deviceId - * @param {keyof typeof DEFAULT_CAPABILITIES} roleId + * @param {RoleIdAssignableToAnyone} roleId */ async assignRole(deviceId, roleId) { let fromIndex = 0 @@ -299,15 +332,15 @@ export class Capabilities { "Only the project creator can assign the project creator's role" ) } - const ownCapabilities = await this.getCapabilities(this.#ownDeviceId) + const ownRole = await this.getRole(this.#ownDeviceId) if (roleId === LEFT_ROLE_ID) { if (deviceId !== this.#ownDeviceId) { throw new Error('Cannot assign LEFT role to another device') } } else { - if (!ownCapabilities.roleAssignment.includes(roleId)) { - throw new Error('No capability to assign role ' + roleId) + if (!ownRole.roleAssignment.includes(roleId)) { + throw new Error('Lacks permission to assign role ' + roleId) } } @@ -344,8 +377,8 @@ export class Capabilities { /** * * @param {string} roleId - * @returns {roleId is keyof DEFAULT_CAPABILITIES} + * @returns {roleId is keyof ROLES} */ function isKnownRoleId(roleId) { - return roleId in DEFAULT_CAPABILITIES + return roleId in ROLES } diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index ce8efda0..54bd9cc4 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -7,7 +7,7 @@ import { createMap } from '../utils.js' * @typedef {import('../core-manager/index.js').Namespace} Namespace */ /** - * @typedef {import('../capabilities.js').Capability['sync'][Namespace] | 'unknown'} SyncCapability + * @typedef {import('../roles.js').Role['sync'][Namespace] | 'unknown'} SyncCapability */ /** @type {Namespace[]} */ @@ -22,7 +22,7 @@ export class PeerSyncController { #enabledNamespaces = new Set() #coreManager #protomux - #capabilities + #roles /** @type {Record} */ #syncCapability = createNamespaceMap('unknown') #isDataSyncEnabled = false @@ -41,10 +41,10 @@ export class PeerSyncController { * @param {import("protomux")} opts.protomux * @param {import("../core-manager/index.js").CoreManager} opts.coreManager * @param {import("./sync-state.js").SyncState} opts.syncState - * @param {import("../capabilities.js").Capabilities} opts.capabilities + * @param {import('../roles.js').Roles} opts.roles * @param {Logger} [opts.logger] */ - constructor({ protomux, coreManager, syncState, capabilities, logger }) { + constructor({ protomux, coreManager, syncState, roles, logger }) { // @ts-ignore this.#log = (formatter, ...args) => { const log = Logger.create('peer', logger).log @@ -56,7 +56,7 @@ export class PeerSyncController { } this.#coreManager = coreManager this.#protomux = protomux - this.#capabilities = capabilities + this.#roles = roles // Always need to replicate the project creator core this.#replicateCore(coreManager.creatorCore) @@ -170,10 +170,10 @@ export class PeerSyncController { if (didUpdate.auth) { try { - const cap = await this.#capabilities.getCapabilities(this.peerId) + const cap = await this.#roles.getRole(this.peerId) this.#syncCapability = cap.sync } catch (e) { - this.#log('Error reading capability', e) + this.#log('Error reading role', e) // Any error, consider sync unknown this.#syncCapability = createNamespaceMap('unknown') } diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 3c448b5d..1f92577b 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -43,7 +43,7 @@ export const kSyncState = Symbol('sync state') */ export class SyncApi extends TypedEmitter { #coreManager - #capabilities + #roles /** @type {Map} */ #peerSyncControllers = new Map() /** @type {Set} */ @@ -58,15 +58,15 @@ export class SyncApi extends TypedEmitter { * * @param {object} opts * @param {import('../core-manager/index.js').CoreManager} opts.coreManager - * @param {import("../capabilities.js").Capabilities} opts.capabilities + * @param {import('../roles.js').Roles} opts.roles * @param {number} [opts.throttleMs] * @param {Logger} [opts.logger] */ - constructor({ coreManager, throttleMs = 200, capabilities, logger }) { + constructor({ coreManager, throttleMs = 200, roles, logger }) { super() this.#l = Logger.create('syncApi', logger) this.#coreManager = coreManager - this.#capabilities = capabilities + this.#roles = roles this[kSyncState] = new SyncState({ coreManager, throttleMs }) this[kSyncState].setMaxListeners(0) this[kSyncState].on('state', (namespaceSyncState) => { @@ -181,7 +181,7 @@ export class SyncApi extends TypedEmitter { protomux, coreManager: this.#coreManager, syncState: this[kSyncState], - capabilities: this.#capabilities, + roles: this.#roles, logger: this.#l, }) this.#peerSyncControllers.set(protomux, peerSyncController) diff --git a/src/types.ts b/src/types.ts index b0c04c74..ac2c3d37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,11 +104,6 @@ export type KeyPair = { publicKey: PublicKey secretKey: SecretKey } -export type RoleDetails = { - name: string - capabilities: string[] -} -export type AvailableRoles = RoleDetails[] export type Statement = { id: string diff --git a/test-e2e/manager-basic.js b/test-e2e/manager-basic.js index 14f46ac1..89f86dfc 100644 --- a/test-e2e/manager-basic.js +++ b/test-e2e/manager-basic.js @@ -289,7 +289,7 @@ test('Consistent storage folders', async (t) => { ) const project = await manager.getProject(projectId) // awaiting this ensures that indexing is done, which means that indexer storage is created - await project.$getOwnCapabilities() + await project.$getOwnRole() } // @ts-ignore snapshot() is missing from typedefs diff --git a/test-e2e/manager-invite.js b/test-e2e/manager-invite.js index 49b8450f..0f9a6ab2 100644 --- a/test-e2e/manager-invite.js +++ b/test-e2e/manager-invite.js @@ -7,7 +7,7 @@ import { disconnectPeers, waitForPeers, } from './utils.js' -import { COORDINATOR_ROLE_ID, MEMBER_ROLE_ID } from '../src/capabilities.js' +import { COORDINATOR_ROLE_ID, MEMBER_ROLE_ID } from '../src/roles.js' test('member invite accepted', async (t) => { const [creator, joiner] = await createManagers(2, t) @@ -112,7 +112,7 @@ test('chain of invites', async (t) => { await disconnectPeers(managers) }) -// TODO: Needs fix to inviteApi to check capabilities before sending invite +// TODO: Needs fix to inviteApi to check role before sending invite skip("member can't invite", async (t) => { const managers = await createManagers(3, t) const [creator, member, joiner] = managers diff --git a/test-e2e/members.js b/test-e2e/members.js index 71eba76a..3993897d 100644 --- a/test-e2e/members.js +++ b/test-e2e/members.js @@ -4,11 +4,11 @@ import { randomBytes } from 'crypto' import { COORDINATOR_ROLE_ID, - CREATOR_CAPABILITIES, - DEFAULT_CAPABILITIES, + CREATOR_ROLE, + ROLES, MEMBER_ROLE_ID, - NO_ROLE_CAPABILITIES, -} from '../src/capabilities.js' + NO_ROLE, +} from '../src/roles.js' import { connectPeers, createManagers, @@ -32,9 +32,9 @@ test('getting yourself after creating project', async (t) => { { deviceId: project.deviceId, name: deviceInfo.name, - capabilities: CREATOR_CAPABILITIES, + role: CREATOR_ROLE, }, - 'has expected member info with creator capabilities' + 'has expected member info with creator role' ) const members = await project.$member.getMany() @@ -45,9 +45,9 @@ test('getting yourself after creating project', async (t) => { { deviceId: project.deviceId, name: deviceInfo.name, - capabilities: CREATOR_CAPABILITIES, + role: CREATOR_ROLE, }, - 'has expected member info with creator capabilities' + 'has expected member info with creator role' ) }) @@ -72,9 +72,9 @@ test('getting yourself after adding project (but not yet synced)', async (t) => { deviceId: project.deviceId, name: deviceInfo.name, - capabilities: NO_ROLE_CAPABILITIES, + role: NO_ROLE, }, - 'has expected member info with no role capabilities' + 'has expected member info with no role' ) const members = await project.$member.getMany() @@ -85,9 +85,9 @@ test('getting yourself after adding project (but not yet synced)', async (t) => { deviceId: project.deviceId, name: deviceInfo.name, - capabilities: NO_ROLE_CAPABILITIES, + role: NO_ROLE, }, - 'has expected member info with no role capabilities' + 'has expected member info with no role' ) }) @@ -150,9 +150,9 @@ test('getting invited member after invite accepted', async (t) => { { deviceId: invitee.deviceId, name: inviteeName, - capabilities: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], + role: ROLES[MEMBER_ROLE_ID], }, - 'has expected member info with member capabilities' + 'has expected member info with member role' ) // TODO: Test that device info of invited member can be read from invitor after syncing @@ -195,7 +195,7 @@ test('invite uses default role name when not provided', async (t) => { invitee.invite.on('invite-received', ({ roleName }) => { t.is( roleName, - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + ROLES[MEMBER_ROLE_ID].name, '`roleName` should use the fallback by deriving `roleId`' ) }) @@ -210,32 +210,24 @@ test('invite uses default role name when not provided', async (t) => { await disconnectPeers(managers) }) -test('capabilities - creator capabilities and role assignment', async (t) => { +test('roles - creator role and role assignment', async (t) => { const [manager] = await createManagers(1, t) const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const ownCapabilities = await project.$getOwnCapabilities() + const ownRole = await project.$getOwnRole() - t.alike( - ownCapabilities, - CREATOR_CAPABILITIES, - 'Project creator has creator capabilities' - ) + t.alike(ownRole, CREATOR_ROLE, 'Project creator has creator role') const deviceId = randomBytes(32).toString('hex') await project.$member.assignRole(deviceId, MEMBER_ROLE_ID) const member = await project.$member.getById(deviceId) - t.alike( - member.capabilities, - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], - 'Can assign capabilities to device' - ) + t.alike(member.role, ROLES[MEMBER_ROLE_ID], 'Can assign role to device') }) -test('capabilities - new device without capabilities', async (t) => { +test('roles - new device without role', async (t) => { const [manager] = await createManagers(1, t) const projectId = await manager.addProject( @@ -248,10 +240,10 @@ test('capabilities - new device without capabilities', async (t) => { const project = await manager.getProject(projectId) - const ownCapabilities = await project.$getOwnCapabilities() + const ownRole = await project.$getOwnRole() t.alike( - ownCapabilities.sync, + ownRole.sync, { auth: 'allowed', config: 'allowed', @@ -264,23 +256,19 @@ test('capabilities - new device without capabilities', async (t) => { await t.exception(async () => { const deviceId = randomBytes(32).toString('hex') await project.$member.assignRole(deviceId, MEMBER_ROLE_ID) - }, 'Trying to assign a role without capabilities throws an error') + }, 'Trying to assign a role without the permission throws an error') }) -test('capabilities - getMany() on invitor device', async (t) => { +test('roles - getMany() on invitor device', async (t) => { const [manager] = await createManagers(1, t) const creatorDeviceId = manager.deviceId const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const ownCapabilities = await project.$getOwnCapabilities() + const ownRole = await project.$getOwnRole() - t.alike( - ownCapabilities, - CREATOR_CAPABILITIES, - 'Project creator has creator capabilities' - ) + t.alike(ownRole, CREATOR_ROLE, 'Project creator has creator role') const deviceId1 = randomBytes(32).toString('hex') const deviceId2 = randomBytes(32).toString('hex') @@ -288,24 +276,24 @@ test('capabilities - getMany() on invitor device', async (t) => { await project.$member.assignRole(deviceId2, COORDINATOR_ROLE_ID) const expected = { - [deviceId1]: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], - [deviceId2]: DEFAULT_CAPABILITIES[COORDINATOR_ROLE_ID], - [creatorDeviceId]: CREATOR_CAPABILITIES, + [deviceId1]: ROLES[MEMBER_ROLE_ID], + [deviceId2]: ROLES[COORDINATOR_ROLE_ID], + [creatorDeviceId]: CREATOR_ROLE, } const allMembers = await project.$member.getMany() - /** @type {Record} */ - const allMembersCapabilities = {} + /** @type {Record} */ + const actual = {} for (const member of allMembers) { - allMembersCapabilities[member.deviceId] = member.capabilities + actual[member.deviceId] = member.role } - t.alike(allMembersCapabilities, expected, 'expected capabilities') + t.alike(actual, expected, 'expected roles') }) -test('capabilities - getMany() on newly invited device before sync', async (t) => { +test('roles - getMany() on newly invited device before sync', async (t) => { const [manager] = await createManagers(1, t) const deviceId = manager.deviceId @@ -319,21 +307,21 @@ test('capabilities - getMany() on newly invited device before sync', async (t) = ) const project = await manager.getProject(projectId) - const expected = { [deviceId]: NO_ROLE_CAPABILITIES } + const expected = { [deviceId]: NO_ROLE } const allMembers = await project.$member.getMany() - /** @type {Record} */ - const allMembersCapabilities = {} + /** @type {Record} */ + const actual = {} for (const member of allMembers) { - allMembersCapabilities[member.deviceId] = member.capabilities + actual[member.deviceId] = member.role } - t.alike(allMembersCapabilities, expected, 'expected capabilities') + t.alike(actual, expected, 'expected role') }) -test('capabilities - assignRole()', async (t) => { +test('roles - assignRole()', async (t) => { const managers = await createManagers(2, t) const [invitor, invitee] = managers connectPeers(managers) @@ -355,15 +343,15 @@ test('capabilities - assignRole()', async (t) => { const [invitorProject, inviteeProject] = projects t.alike( - (await invitorProject.$member.getById(invitee.deviceId)).capabilities, - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], - 'invitee has member capabilities from invitor perspective' + (await invitorProject.$member.getById(invitee.deviceId)).role, + ROLES[MEMBER_ROLE_ID], + 'invitee has member role from invitor perspective' ) t.alike( - await inviteeProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], - 'invitee has member capabilities from invitee perspective' + await inviteeProject.$getOwnRole(), + ROLES[MEMBER_ROLE_ID], + 'invitee has member role from invitee perspective' ) await t.test('invitor updates invitee role to coordinator', async (st) => { @@ -390,15 +378,15 @@ test('capabilities - assignRole()', async (t) => { await waitForSync(projects, 'initial') st.alike( - (await invitorProject.$member.getById(invitee.deviceId)).capabilities, - DEFAULT_CAPABILITIES[COORDINATOR_ROLE_ID], - 'invitee now has coordinator capabilities from invitor perspective' + (await invitorProject.$member.getById(invitee.deviceId)).role, + ROLES[COORDINATOR_ROLE_ID], + 'invitee now has coordinator role from invitor perspective' ) st.alike( - await inviteeProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[COORDINATOR_ROLE_ID], - 'invitee now has coordinator capabilities from invitee perspective' + await inviteeProject.$getOwnRole(), + ROLES[COORDINATOR_ROLE_ID], + 'invitee now has coordinator role from invitee perspective' ) }) @@ -427,22 +415,22 @@ test('capabilities - assignRole()', async (t) => { await waitForSync(projects, 'initial') st.alike( - (await invitorProject.$member.getById(invitee.deviceId)).capabilities, - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], - 'invitee now has member capabilities from invitor perspective' + (await invitorProject.$member.getById(invitee.deviceId)).role, + ROLES[MEMBER_ROLE_ID], + 'invitee now has member role from invitor perspective' ) st.alike( - await inviteeProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], - 'invitee now has member capabilities from invitee perspective' + await inviteeProject.$getOwnRole(), + ROLES[MEMBER_ROLE_ID], + 'invitee now has member role from invitee perspective' ) }) await disconnectPeers(managers) }) -test('capabilities - assignRole() with forked role', async (t) => { +test('roles - assignRole() with forked role', async (t) => { const managers = await createManagers(3, t) const [invitor, invitee1, invitee2] = managers connectPeers(managers) @@ -468,7 +456,7 @@ test('capabilities - assignRole() with forked role', async (t) => { await disconnectPeers(managers) // 2. Create fork by two devices assigning a role to invitee2 while disconnected - // TODO: Assign different roles and test fork resolution prefers the role with least capability (code for this is not written yet) + // TODO: Assign different roles and test fork resolution prefers the role with least power (code for this is not written yet) await invitorProject.$member.assignRole(invitee2.deviceId, MEMBER_ROLE_ID) await invitee1Project.$member.assignRole(invitee2.deviceId, MEMBER_ROLE_ID) diff --git a/test-e2e/project-leave.js b/test-e2e/project-leave.js index f3042267..3905c70c 100644 --- a/test-e2e/project-leave.js +++ b/test-e2e/project-leave.js @@ -3,10 +3,10 @@ import { test } from 'brittle' import { BLOCKED_ROLE_ID, COORDINATOR_ROLE_ID, - DEFAULT_CAPABILITIES, + ROLES, LEFT_ROLE_ID, MEMBER_ROLE_ID, -} from '../src/capabilities.js' +} from '../src/roles.js' import { MapeoProject } from '../src/mapeo-project.js' import { connectPeers, @@ -89,8 +89,8 @@ test('Blocked member cannot leave project', async (t) => { const [creatorProject, memberProject] = projects t.alike( - await memberProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[MEMBER_ROLE_ID], + await memberProject.$getOwnRole(), + ROLES[MEMBER_ROLE_ID], 'Member is initially a member' ) @@ -99,8 +99,8 @@ test('Blocked member cannot leave project', async (t) => { await waitForSync(projects, 'initial') t.alike( - await memberProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[BLOCKED_ROLE_ID], + await memberProject.$getOwnRole(), + ROLES[BLOCKED_ROLE_ID], 'Member is now blocked' ) @@ -146,16 +146,16 @@ test('Creator can leave project if another coordinator exists', async (t) => { await creator.leaveProject(projectId) t.alike( - await creatorProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[LEFT_ROLE_ID], - 'creator now has LEFT role id and capabilities' + await creatorProject.$getOwnRole(), + ROLES[LEFT_ROLE_ID], + 'creator now has LEFT role' ) await waitForSync(projects, 'initial') t.is( - (await coordinatorProject.$member.getById(creator.deviceId)).capabilities, - DEFAULT_CAPABILITIES[LEFT_ROLE_ID], + (await coordinatorProject.$member.getById(creator.deviceId)).role, + ROLES[LEFT_ROLE_ID], 'coordinator can still retrieve info about creator who left' ) @@ -197,16 +197,16 @@ test('Member can leave project if creator exists', async (t) => { await member.leaveProject(projectId) t.alike( - await memberProject.$getOwnCapabilities(), - DEFAULT_CAPABILITIES[LEFT_ROLE_ID], - 'member now has LEFT role id and capabilities' + await memberProject.$getOwnRole(), + ROLES[LEFT_ROLE_ID], + 'member now has LEFT role' ) await waitForSync(projects, 'initial') t.is( - (await creatorProject.$member.getById(member.deviceId)).capabilities, - DEFAULT_CAPABILITIES[LEFT_ROLE_ID], + (await creatorProject.$member.getById(member.deviceId)).role, + ROLES[LEFT_ROLE_ID], 'creator can still retrieve info about member who left' ) diff --git a/test-e2e/sync.js b/test-e2e/sync.js index 0a8c7e90..ee86f309 100644 --- a/test-e2e/sync.js +++ b/test-e2e/sync.js @@ -14,7 +14,7 @@ import { PRESYNC_NAMESPACES } from '../src/sync/peer-sync-controller.js' import { generate } from '@mapeo/mock-data' import { valueOf } from '../src/utils.js' import pTimeout from 'p-timeout' -import { BLOCKED_ROLE_ID, COORDINATOR_ROLE_ID } from '../src/capabilities.js' +import { BLOCKED_ROLE_ID, COORDINATOR_ROLE_ID } from '../src/roles.js' import { kSyncState } from '../src/sync/sync-api.js' const SCHEMAS_INITIAL_SYNC = ['preset', 'field'] diff --git a/test-e2e/utils.js b/test-e2e/utils.js index 44453658..adf2a98a 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -11,7 +11,7 @@ import { valueOf } from '../src/utils.js' import { randomInt } from 'node:crypto' import { temporaryDirectory } from 'tempy' import fsPromises from 'node:fs/promises' -import { MEMBER_ROLE_ID } from '../src/capabilities.js' +import { MEMBER_ROLE_ID } from '../src/roles.js' import { kSyncState } from '../src/sync/sync-api.js' const FAST_TESTS = !!process.env.FAST_TESTS @@ -78,7 +78,7 @@ export function connectPeers(managers, { discovery = true } = {}) { * invitor: MapeoManager, * projectId: string, * invitees: MapeoManager[], - * roleId?: import('../src/capabilities.js').RoleId, + * roleId?: import('../src/roles.js').RoleIdAssignableToOthers, * roleName?: string * reject?: boolean * }} opts diff --git a/tests/invite-api.js b/tests/invite-api.js index 2b8d3371..0ac11ab9 100644 --- a/tests/invite-api.js +++ b/tests/invite-api.js @@ -7,7 +7,7 @@ import { projectKeyToPublicId } from '../src/utils.js' import { replicate } from './helpers/local-peers.js' import NoiseSecretStream from '@hyperswarm/secret-stream' import pDefer from 'p-defer' -import { DEFAULT_CAPABILITIES, MEMBER_ROLE_ID } from '../src/capabilities.js' +import { ROLES, MEMBER_ROLE_ID } from '../src/roles.js' test('invite-received event has expected payload', async (t) => { t.plan(7) @@ -43,7 +43,7 @@ test('invite-received event has expected payload', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } r1.invite(peers[0].deviceId, invite) @@ -55,7 +55,7 @@ test('invite-received event has expected payload', async (t) => { t.is(peerId, expectedInvitorPeerId) t.is(projectName, 'Mapeo') t.is(projectId, projectKeyToPublicId(projectKey)) - t.is(roleName, DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name) + t.is(roleName, ROLES[MEMBER_ROLE_ID].name) t.is(invitorName, 'device0') } ) @@ -92,7 +92,7 @@ test('Accept invite', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } const response = await r1.invite(peers[0].deviceId, invite) @@ -140,7 +140,7 @@ test('Reject invite', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } const response = await r1.invite(peers[0].deviceId, invite) @@ -186,7 +186,7 @@ test('Receiving invite for project that peer already belongs to', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } const response = await r1.invite(peers[0].deviceId, invite) @@ -233,7 +233,7 @@ test('Receiving invite for project that peer already belongs to', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } @@ -282,7 +282,7 @@ test('Receiving invite for project that peer already belongs to', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } const response1 = await r1.invite(peers[0].deviceId, invite) @@ -348,7 +348,7 @@ test('invitor disconnecting results in accept throwing', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } return r1.invite(peers[0].deviceId, invite) @@ -389,7 +389,7 @@ test('invitor disconnecting results in invite reject response not throwing', asy projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } return r1.invite(peers[0].deviceId, invite) @@ -433,7 +433,7 @@ test('invitor disconnecting results in invite already response not throwing', as projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } return r1.invite(peers[0].deviceId, invite) @@ -473,7 +473,7 @@ test('addProject throwing results in invite accept throwing', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } r1.invite(peers[0].deviceId, invite) @@ -538,7 +538,7 @@ test('Invite from multiple peers', async (t) => { projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } const response = await invitor.invite(peers[0].deviceId, invite) @@ -615,7 +615,7 @@ test.skip('Invite from multiple peers, first disconnects before accepted, receiv projectKey, encryptionKeys, projectInfo: { name: 'Mapeo' }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } const response = await invitor.invite(peers[0].deviceId, invite) diff --git a/tests/local-peers.js b/tests/local-peers.js index 921d957d..bb97da74 100644 --- a/tests/local-peers.js +++ b/tests/local-peers.js @@ -14,7 +14,7 @@ import { randomBytes } from 'node:crypto' import NoiseSecretStream from '@hyperswarm/secret-stream' import Protomux from 'protomux' import { setTimeout as delay } from 'timers/promises' -import { DEFAULT_CAPABILITIES, MEMBER_ROLE_ID } from '../src/capabilities.js' +import { ROLES, MEMBER_ROLE_ID } from '../src/roles.js' test('Send invite and accept', async (t) => { t.plan(3) @@ -28,7 +28,7 @@ test('Send invite and accept', async (t) => { const response = await r1.invite(peers[0].deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) t.is(response, LocalPeers.InviteResponse.ACCEPT) @@ -59,7 +59,7 @@ test('Send invite immediately', async (t) => { const responsePromise = r1.invite(kp2.publicKey.toString('hex'), { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) @@ -82,7 +82,7 @@ test('Send invite, duplicate connections', async (t) => { const invite = { projectKey: Buffer.allocUnsafe(32).fill(0), encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } @@ -149,7 +149,7 @@ test('Duplicate connections with immediate disconnect', async (t) => { const invite = { projectKey: Buffer.allocUnsafe(32).fill(0), encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } @@ -184,7 +184,7 @@ test('Send invite and reject', async (t) => { const response = await r1.invite(peers[0].deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) t.is(response, LocalPeers.InviteResponse.REJECT) @@ -214,7 +214,7 @@ test('Invite to unknown peer', async (t) => { r1.invite(unknownPeerId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), UnknownPeerError @@ -241,7 +241,7 @@ test('Send invite and already on project', async (t) => { const response = await r1.invite(peers[0].deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) t.is(response, LocalPeers.InviteResponse.ALREADY) @@ -274,7 +274,7 @@ test('Send invite with encryption key', async (t) => { const response = await r1.invite(peers[0].deviceId, { projectKey, encryptionKeys, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) t.is(response, LocalPeers.InviteResponse.ACCEPT) @@ -310,7 +310,7 @@ test('Send invite with project info', async (t) => { projectKey, projectInfo, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) t.is(response, LocalPeers.InviteResponse.ACCEPT) @@ -371,7 +371,7 @@ test('Disconnect results in rejected invite', async (t) => { const invite = r1.invite(peers[0].deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) await t.exception( @@ -408,7 +408,7 @@ test('Invite to multiple peers', async (t) => { r1.invite(peer.deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) ) @@ -447,7 +447,7 @@ test('Multiple invites to a peer, only one response', async (t) => { const projectKey = Buffer.allocUnsafe(32).fill(0) const inviteFields = { - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', } r1.on('peers', async (peers) => { @@ -499,7 +499,7 @@ test('Default: invites do not timeout', async (t) => { r1.invite(peers[0].deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }).then( () => t.fail('invite promise should not resolve'), @@ -529,7 +529,7 @@ test('Invite timeout', async (t) => { projectKey, timeout: 5000, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), TimeoutError @@ -551,7 +551,7 @@ test('Send invite to non-existent peer', async (t) => { projectKey, timeout: 1000, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }), UnknownPeerError @@ -586,7 +586,7 @@ test('Reconnect peer and send invite', async (t) => { const response = await r1.invite(peers[0].deviceId, { projectKey, encryptionKeys: { auth: randomBytes(32) }, - roleName: DEFAULT_CAPABILITIES[MEMBER_ROLE_ID].name, + roleName: ROLES[MEMBER_ROLE_ID].name, invitorName: 'device0', }) t.is(response, LocalPeers.InviteResponse.ACCEPT) From b8742e47508cdd69d24aeea067b38c7ee8ac3a91 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 6 Feb 2024 09:45:18 -0600 Subject: [PATCH 2/4] Improve types for `Role` Co-authored-by: Gregor MacLennan --- src/roles.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/roles.js b/src/roles.js index 7effe4a8..0c6b6161 100644 --- a/src/roles.js +++ b/src/roles.js @@ -19,8 +19,9 @@ export const NO_ROLE_ID = '08e4251e36f6e7ed' */ /** + * @template {RoleId} [T=RoleId] * @typedef {object} Role - * @property {RoleId} roleId + * @property {T} roleId * @property {string} name * @property {Record} docs * @property {RoleIdAssignableToOthers[]} roleAssignment @@ -110,7 +111,7 @@ export const NO_ROLE = { }, } -/** @type {Record} */ +/** @type {{ [K in RoleId]: Role }} */ export const ROLES = { [CREATOR_ROLE_ID]: CREATOR_ROLE, [MEMBER_ROLE_ID]: { From 885071e93f21a8316e6d0ec17dc34d55ef3024ef Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 6 Feb 2024 15:53:46 +0000 Subject: [PATCH 3/4] Tweak types so ROLES object works --- src/roles.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/roles.js b/src/roles.js index 0c6b6161..ee2c85b4 100644 --- a/src/roles.js +++ b/src/roles.js @@ -61,7 +61,7 @@ export const NO_ROLE_ID = '08e4251e36f6e7ed' * because the creator should always have ALL powers, but we could edit the * 'Coordinator' powers in the future. * - * @type {Role} + * @type {Role} */ export const CREATOR_ROLE = { roleId: CREATOR_ROLE_ID, @@ -90,7 +90,7 @@ export const CREATOR_ROLE = { * receive their role record, and they can get the project config so that they * can start collecting data. * - * @type {Role} + * @type {Role} */ export const NO_ROLE = { roleId: NO_ROLE_ID, From f24c85e3d807ce58e3f4b99a148f6ee1c9d345b6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 6 Feb 2024 17:58:41 +0000 Subject: [PATCH 4/4] Add runtime type checks --- src/member-api.js | 11 ++---- src/roles.js | 97 ++++++++++++++++++++++++++++------------------- src/utils.js | 31 +++++++++++++++ tests/utils.js | 14 +++++++ 4 files changed, 107 insertions(+), 46 deletions(-) create mode 100644 tests/utils.js diff --git a/src/member-api.js b/src/member-api.js index 8ba85f4e..b8f15e06 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -1,7 +1,7 @@ import { TypedEmitter } from 'tiny-typed-emitter' import { InviteResponse_Decision } from './generated/rpc.js' -import { projectKeyToId } from './utils.js' -import { ROLES } from './roles.js' +import { assert, projectKeyToId } from './utils.js' +import { ROLES, isRoleIdForNewInvite } from './roles.js' /** @typedef {import('./datatype/index.js').DataType, typeof import('./schema/project.js').deviceInfoTable, "deviceInfo", import('@mapeo/schema').DeviceInfo, import('@mapeo/schema').DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {import('./datatype/index.js').DataType, typeof import('./schema/client.js').projectSettingsTable, "projectSettings", import('@mapeo/schema').ProjectSettings, import('@mapeo/schema').ProjectSettingsValue>} ProjectDataType */ @@ -49,9 +49,8 @@ export class MemberApi extends TypedEmitter { /** * @param {string} deviceId - * * @param {Object} opts - * @param {import('./roles.js').RoleIdAssignableToOthers} opts.roleId + * @param {import('./roles.js').RoleIdForNewInvite} opts.roleId * @param {string} [opts.roleName] * @param {string} [opts.roleDescription] * @param {number} [opts.timeout] @@ -59,9 +58,7 @@ export class MemberApi extends TypedEmitter { * @returns {Promise} */ async invite(deviceId, { roleId, roleName, roleDescription, timeout }) { - if (!ROLES[roleId]) { - throw new Error('Invalid role id') - } + assert(isRoleIdForNewInvite(roleId), 'Invalid role ID for new invite') const { name: deviceName } = await this.getById(this.#ownDeviceId) diff --git a/src/roles.js b/src/roles.js index ee2c85b4..bc21cdbe 100644 --- a/src/roles.js +++ b/src/roles.js @@ -1,6 +1,7 @@ import { currentSchemaVersions } from '@mapeo/schema' import mapObject from 'map-obj' import { kCreateWithDocId } from './datatype/index.js' +import { assert, setHas } from './utils.js' // Randomly generated 8-byte encoded as hex export const CREATOR_ROLE_ID = 'a12a6702b93bd7ff' @@ -10,6 +11,47 @@ export const BLOCKED_ROLE_ID = '9e6d29263cba36c9' export const LEFT_ROLE_ID = '8ced989b1904606b' export const NO_ROLE_ID = '08e4251e36f6e7ed' +/** + * @typedef {T extends Iterable ? U : never} ElementOf + * @template T + */ + +/** @typedef {ElementOf} RoleId */ +const ROLE_IDS = new Set( + /** @type {const} */ ([ + CREATOR_ROLE_ID, + COORDINATOR_ROLE_ID, + MEMBER_ROLE_ID, + BLOCKED_ROLE_ID, + LEFT_ROLE_ID, + NO_ROLE_ID, + ]) +) +const isRoleId = setHas(ROLE_IDS) + +/** @typedef {ElementOf} RoleIdForNewInvite */ +const ROLE_IDS_FOR_NEW_INVITE = new Set( + /** @type {const} */ ([COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID]) +) +export const isRoleIdForNewInvite = setHas(ROLE_IDS_FOR_NEW_INVITE) + +/** @typedef {ElementOf} RoleIdAssignableToOthers */ +const ROLE_IDS_ASSIGNABLE_TO_OTHERS = new Set( + /** @type {const} */ ([COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, BLOCKED_ROLE_ID]) +) +export const isRoleIdAssignableToOthers = setHas(ROLE_IDS_ASSIGNABLE_TO_OTHERS) + +/** @typedef {ElementOf} RoleIdAssignableToAnyone */ +const ROLE_IDS_ASSIGNABLE_TO_ANYONE = new Set( + /** @type {const} */ ([ + COORDINATOR_ROLE_ID, + MEMBER_ROLE_ID, + BLOCKED_ROLE_ID, + LEFT_ROLE_ID, + ]) +) +const isRoleIdAssignableToAnyone = setHas(ROLE_IDS_ASSIGNABLE_TO_ANYONE) + /** * @typedef {object} DocCapability * @property {boolean} readOwn - can read own data @@ -28,34 +70,6 @@ export const NO_ROLE_ID = '08e4251e36f6e7ed' * @property {Record} sync */ -/** - * @typedef {( - * typeof CREATOR_ROLE_ID | - * typeof COORDINATOR_ROLE_ID | - * typeof MEMBER_ROLE_ID | - * typeof BLOCKED_ROLE_ID | - * typeof LEFT_ROLE_ID | - * typeof NO_ROLE_ID - * )} RoleId - */ - -/** - * @typedef {Extract} RoleIdAssignableToOthers - */ - -/** - * @typedef {Extract} RoleIdAssignableToAnyone - */ - /** * This is currently the same as 'Coordinator' role, but defined separately * because the creator should always have ALL powers, but we could edit the @@ -238,6 +252,7 @@ export class Roles { * @returns {Promise} */ async getRole(deviceId) { + /** @type {string} */ let roleId try { const roleAssignment = await this.#dataType.getByDocId(deviceId) @@ -253,7 +268,7 @@ export class Roles { return NO_ROLE } } - if (!isKnownRoleId(roleId)) { + if (!isRoleId(roleId)) { return ROLES[BLOCKED_ROLE_ID] } return ROLES[roleId] @@ -271,6 +286,7 @@ export class Roles { const roles = await this.#dataType.getMany() /** @type {Record} */ const result = {} + /** @type {undefined | string} */ let projectCreatorDeviceId try { projectCreatorDeviceId = await this.#coreOwnership.getOwner( @@ -285,8 +301,15 @@ export class Roles { } for (const role of roles) { + if (!isRoleId(role.roleId)) { + console.error("Found a value that wasn't a role ID") + continue + } + if (role.roleId === CREATOR_ROLE_ID) { + console.error('Unexpected creator role') + continue + } const deviceId = role.docId - if (!isKnownRoleId(role.roleId)) continue result[deviceId] = ROLES[role.roleId] } const includesSelf = Boolean(result[this.#ownDeviceId]) @@ -311,6 +334,11 @@ export class Roles { * @param {RoleIdAssignableToAnyone} roleId */ async assignRole(deviceId, roleId) { + assert( + isRoleIdAssignableToAnyone(roleId), + `Role ID should be assignable to anyone but got ${roleId}` + ) + let fromIndex = 0 let authCoreId try { @@ -374,12 +402,3 @@ export class Roles { return ownAuthCoreId === this.#projectCreatorAuthCoreId } } - -/** - * - * @param {string} roleId - * @returns {roleId is keyof ROLES} - */ -function isKnownRoleId(roleId) { - return roleId in ROLES -} diff --git a/src/utils.js b/src/utils.js index 9b4d052a..07eda336 100644 --- a/src/utils.js +++ b/src/utils.js @@ -66,6 +66,37 @@ export async function openedNoiseSecretStream(stream) { return /** @type {OpenedNoiseStream | DestroyedNoiseStream} */ (stream) } +/** + * @param {boolean} condition + * @param {string} message + * @returns {asserts condition} + */ +export function assert(condition, message) { + if (!condition) throw new Error(message) +} + +/** + * Return a function that itself returns whether a value is part of the set. + * + * Similar to binding `Set.prototype.has`, but (1) is shorter (2) refines the type. + * + * @template T + * @param {Readonly>} set + * @example + * const mySet = new Set([1, 2, 3]) + * const isInMySet = setHas(mySet) + * + * console.log(isInMySet(2)) + * // => true + */ +export function setHas(set) { + /** + * @param {unknown} value + * @returns {value is T} + */ + return (value) => set.has(/** @type {*} */ (value)) +} + /** * When reading from SQLite, any optional properties are set to `null`. This * converts `null` back to `undefined` to match the input types (e.g. the types diff --git a/tests/utils.js b/tests/utils.js new file mode 100644 index 00000000..2310fd14 --- /dev/null +++ b/tests/utils.js @@ -0,0 +1,14 @@ +// @ts-check +import test from 'brittle' +import { assert, setHas } from '../src/utils.js' + +test('assert()', (t) => { + t.execution(() => assert(true, 'should work')) + t.exception(() => assert(false, 'uh oh'), /uh oh/) +}) + +test('setHas()', (t) => { + const set = new Set([1, 2, 3]) + t.ok(setHas(set)(1)) + t.absent(setHas(set)(9)) +})