Skip to content

Commit

Permalink
feat: add project.leave() (#410)
Browse files Browse the repository at this point in the history
  • Loading branch information
achou11 authored Jan 11, 2024
1 parent 8dee5f0 commit 42c90e4
Show file tree
Hide file tree
Showing 6 changed files with 472 additions and 16 deletions.
36 changes: 33 additions & 3 deletions src/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { kCreateWithDocId } from './datatype/index.js'
export const COORDINATOR_ROLE_ID = 'f7c150f5a3a9a855'
export const MEMBER_ROLE_ID = '012fd2d431c0bf60'
export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
export const LEFT_ROLE_ID = '8ced989b1904606b'

/**
* @typedef {object} DocCapability
Expand All @@ -24,7 +25,7 @@ export const BLOCKED_ROLE_ID = '9e6d29263cba36c9'
*/

/**
* @typedef {typeof COORDINATOR_ROLE_ID | typeof MEMBER_ROLE_ID | typeof BLOCKED_ROLE_ID} RoleId
* @typedef {typeof COORDINATOR_ROLE_ID | typeof MEMBER_ROLE_ID | typeof BLOCKED_ROLE_ID | typeof LEFT_ROLE_ID} RoleId
*/

/**
Expand Down Expand Up @@ -138,6 +139,28 @@ export const DEFAULT_CAPABILITIES = {
blob: 'blocked',
},
},
[LEFT_ROLE_ID]: {
name: 'Left',
docs: mapObject(currentSchemaVersions, (key) => {
return [
key,
{
readOwn: false,
writeOwn: false,
readOthers: false,
writeOthers: false,
},
]
}),
roleAssignment: [],
sync: {
auth: 'allowed',
config: 'blocked',
data: 'blocked',
blobIndex: 'blocked',
blob: 'blocked',
},
},
}

export class Capabilities {
Expand Down Expand Up @@ -277,8 +300,15 @@ export class Capabilities {
)
}
const ownCapabilities = await this.getCapabilities(this.#ownDeviceId)
if (!ownCapabilities.roleAssignment.includes(roleId)) {
throw new Error('No capability to assign role ' + roleId)

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)
}
}

const existingRoleDoc = await this.#dataType
Expand Down
1 change: 0 additions & 1 deletion src/core-manager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ export class CoreManager extends TypedEmitter {
* Get an array of all cores in the given namespace
*
* @param {Namespace} namespace
* @returns
*/
getCores(namespace) {
return this.#coreIndex.getByNamespace(namespace)
Expand Down
82 changes: 73 additions & 9 deletions src/mapeo-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import Hypercore from 'hypercore'
import { TypedEmitter } from 'tiny-typed-emitter'

import { IndexWriter } from './index-writer/index.js'
import { MapeoProject, kSetOwnDeviceInfo } from './mapeo-project.js'
import {
MapeoProject,
kProjectLeave,
kSetOwnDeviceInfo,
} from './mapeo-project.js'
import {
localDeviceInfoTable,
projectKeysTable,
Expand All @@ -36,6 +40,7 @@ import { Logger } from './logger.js'
import { kSyncState } from './sync/sync-api.js'

/** @typedef {import("@mapeo/schema").ProjectSettingsValue} ProjectValue */
/** @typedef {import('type-fest').SetNonNullable<ProjectKeys, 'encryptionKeys'>} ValidatedProjectKeys */

const CLIENT_SQLITE_FILE_NAME = 'client.db'

Expand Down Expand Up @@ -258,16 +263,17 @@ export class MapeoManager extends TypedEmitter {
const encoded = ProjectKeys.encode(projectKeys).finish()
const nonce = projectIdToNonce(projectId)

const keysCipher = this.#keyManager.encryptLocalMessage(
Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength),
nonce
)

this.#db
.insert(projectKeysTable)
.values({
projectId,
projectPublicId,
keysCipher: this.#keyManager.encryptLocalMessage(
Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength),
nonce
),
projectInfo,
.values({ projectId, projectPublicId, keysCipher, projectInfo })
.onConflictDoUpdate({
target: projectKeysTable.projectId,
set: { projectPublicId, keysCipher, projectInfo },
})
.run()
}
Expand Down Expand Up @@ -386,6 +392,7 @@ export class MapeoManager extends TypedEmitter {

/** @param {ProjectKeys} projectKeys */
#createProjectInstance(projectKeys) {
validateProjectKeys(projectKeys)
const projectId = keyToId(projectKeys.projectKey)
return new MapeoProject({
...this.#projectStorage(projectId),
Expand Down Expand Up @@ -673,6 +680,53 @@ export class MapeoManager extends TypedEmitter {
async listLocalPeers() {
return omitPeerProtomux(this.#localPeers.peers)
}

/**
* @param {string} projectPublicId
*/
async leaveProject(projectPublicId) {
const project = await this.getProject(projectPublicId)

await project[kProjectLeave]()

const row = this.#db
.select({
keysCipher: projectKeysTable.keysCipher,
projectId: projectKeysTable.projectId,
projectInfo: projectKeysTable.projectInfo,
})
.from(projectKeysTable)
.where(eq(projectKeysTable.projectPublicId, projectPublicId))
.get()

if (!row) {
throw new Error(`NotFound: project ID ${projectPublicId} not found`)
}

const { keysCipher, projectId, projectInfo } = row

const projectKeys = this.#decodeProjectKeysCipher(keysCipher, projectId)

const updatedEncryptionKeys = projectKeys.encryptionKeys
? // Delete all encryption keys except for auth
{ auth: projectKeys.encryptionKeys.auth }
: undefined

this.#saveToProjectKeysTable({
projectId,
projectPublicId,
projectInfo,
projectKeys: {
...projectKeys,
encryptionKeys: updatedEncryptionKeys,
},
})

this.#db
.delete(projectSettingsTable)
.where(eq(projectSettingsTable.docId, projectId))
.run()
}
}

// We use the `protomux` property of connected peers internally, but we don't
Expand All @@ -699,3 +753,13 @@ function omitPeerProtomux(peers) {
}
)
}

/**
* @param {ProjectKeys} projectKeys
* @returns {asserts projectKeys is ValidatedProjectKeys}
*/
function validateProjectKeys(projectKeys) {
if (!projectKeys.encryptionKeys) {
throw new Error('encryptionKeys should not be undefined')
}
}
88 changes: 85 additions & 3 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import {
getWinner,
mapAndValidateCoreOwnership,
} from './core-ownership.js'
import { Capabilities } from './capabilities.js'
import {
BLOCKED_ROLE_ID,
COORDINATOR_ROLE_ID,
Capabilities,
LEFT_ROLE_ID,
} from './capabilities.js'
import {
getDeviceId,
projectKeyToId,
Expand All @@ -51,6 +56,8 @@ export const kSetOwnDeviceInfo = Symbol('kSetOwnDeviceInfo')
export const kBlobStore = Symbol('blobStore')
export const kProjectReplicate = Symbol('replicate project')
export const kDataTypes = Symbol('dataTypes')
export const kProjectLeave = Symbol('leave project')

const EMPTY_PROJECT_SETTINGS = Object.freeze({})

/**
Expand Down Expand Up @@ -82,7 +89,7 @@ export class MapeoProject extends TypedEmitter {
* @param {import('@mapeo/crypto').KeyManager} opts.keyManager mapeo/crypto KeyManager instance
* @param {Buffer} opts.projectKey 32-byte public key of the project creator core
* @param {Buffer} [opts.projectSecretKey] 32-byte secret key of the project creator core
* @param {Partial<Record<import('./core-manager/index.js').Namespace, Buffer>>} [opts.encryptionKeys] Encryption keys for each namespace
* @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys Encryption keys for each namespace
* @param {import('drizzle-orm/better-sqlite3').BetterSQLite3Database} opts.sharedDb
* @param {IndexWriter} opts.sharedIndexWriter
* @param {import('./types.js').CoreStorage} opts.coreStorage Folder to store all hypercore data
Expand Down Expand Up @@ -251,7 +258,6 @@ export class MapeoProject extends TypedEmitter {
deviceId: this.#deviceId,
capabilities: this.#capabilities,
coreOwnership: this.#coreOwnership,
// @ts-expect-error
encryptionKeys,
projectKey,
rpc: localPeers,
Expand Down Expand Up @@ -549,6 +555,82 @@ export class MapeoProject extends TypedEmitter {
get $icons() {
return this.#iconApi
}

async [kProjectLeave]() {
// 1. Check that the device can leave the project
const roleDocs = await this.#dataTypes.role.getMany()

// 1.1 Check that we are not blocked in the project
const ownRole = roleDocs.find(({ docId }) => this.#deviceId === docId)

if (ownRole?.roleId === BLOCKED_ROLE_ID) {
throw new Error('Cannot leave a project as a blocked device')
}

const knownDevices = Object.keys(await this.#capabilities.getAll())
const projectCreatorDeviceId = await this.#coreOwnership.getOwner(
this.#projectId
)

// 1.2 Check that we are not the only device in the project
if (knownDevices.length === 1) {
throw new Error('Cannot leave a project as the only device')
}

// 1.3 Check if there are other known devices that are either the project creator or a coordinator
let otherCreatorOrCoordinatorExists = false

for (const deviceId of knownDevices) {
// Skip self (see 1.1 and 1.2 for relevant checks)
if (deviceId === this.#deviceId) continue

// Check if the device is the project creator first because
// it is a derived role that is not stored in the role docs explicitly
if (deviceId === projectCreatorDeviceId) {
otherCreatorOrCoordinatorExists = true
break
}

// Determine if the the device is a coordinator based on the role docs
const isCoordinator = roleDocs.some(
(doc) => doc.docId === deviceId && doc.roleId == COORDINATOR_ROLE_ID
)

if (isCoordinator) {
otherCreatorOrCoordinatorExists = true
break
}
}

if (!otherCreatorOrCoordinatorExists) {
throw new Error(
'Cannot leave a project that does not have an external creator or another coordinator'
)
}

// 2. Clear data from cores
// TODO: only clear synced data
const namespacesWithoutAuth =
/** @satisfies {Exclude<import('./core-manager/index.js').Namespace, 'auth'>[]} */ ([
'config',
'data',
'blob',
'blobIndex',
])

await Promise.all(
namespacesWithoutAuth.map((namespace) =>
this.#coreManager.deleteData(namespace, { deleteOwn: true })
)
)

// TODO: 3. Clear data from indexes
// 3.1 Reset multi-core indexer state
// 3.2 Clear indexed data

// 4. Assign LEFT role for device
await this.#capabilities.assignRole(this.#deviceId, LEFT_ROLE_ID)
}
}

/**
Expand Down
Loading

0 comments on commit 42c90e4

Please sign in to comment.