From 7deb9a47fa0e37da0cf12a8d89214e45cf09bcbb Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Fri, 20 Sep 2024 09:22:21 -0300 Subject: [PATCH] feat: sharing spaces (#1551) # Add `shareSpace()` Method to Allow Sharing Spaces with Other Accounts ## Summary This PR introduces a new `shareSpace()` method that allows users to delegate access to an existing space with another Storacha account via email. This feature enhances collaboration by enabling multiple accounts to share access to a space, making data sharing more flexible. By default, the following capabilities/permissions are set: - space/* - for managing space metadata - store/* - for managing stores - upload/*- for registering uploads - access/* - for re-delegating access to other devices - filecoin/* - for submitting to the filecoin pipeline - usage/* - for querying usage ## Changes ### New Feature: **Space Sharing** - **Added `shareSpace()` Method**: - The `shareSpace()` method allows users to share an existing space with another Storacha account by delegating access to the specified email address. - The method takes in the following options: - `space`: The space to be shared, identified by its DID. - `delegateEmail`: The email address of the account to share the space with. - The sharing process involves: 1. **Creating a delegation for the delegate account**: This ensures that the delegate has access to the space. 2. **Delegating access**: Space access is delegated to the provided email account, allowing the delegate to manage and access the space. - If the sharing process fails, the method throws an error detailing the issue. ## How to Test 1. Run the following commands: ```bash npm run build && npm run test ``` 2. Ensure all existing tests pass. 3. Verify that the new test cases for the `shareSpace()` method function correctly. ## Related Issues - [Issue #130](https://github.com/storacha/project-tracking/issues/130): Enable space sharing between accounts. ## Additional Notes This implementation opens the door for future enhancements, such as specifying different permission levels when sharing spaces. --- packages/w3up-client/src/client.js | 86 ++++++++++++++ packages/w3up-client/test/client.test.js | 136 ++++++++++++++++++++++- 2 files changed, 221 insertions(+), 1 deletion(-) diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index aa3c77122..89211ad7f 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -282,6 +282,92 @@ export class Client extends Base { return space } + /** + * Share an existing space with another Storacha account via email address delegation. + * Delegates access to the space to the specified email account with the following permissions: + * - space/* - for managing space metadata + * - blob/* - for managing blobs + * - store/* - for managing stores + * - upload/*- for registering uploads + * - access/* - for re-delegating access to other devices + * - filecoin/* - for submitting to the filecoin pipeline + * - usage/* - for querying usage + * The default expiration is set to infinity. + * + * @typedef {object} ShareOptions + * @property {import('./types.js').ServiceAbility[]} abilities - Abilities to delegate to the delegate account. + * @property {number} expiration - Expiration time in seconds. + + * @param {import("./types.js").EmailAddress} delegateEmail - Email of the account to share the space with. + * @param {import('./types.js').SpaceDID} spaceDID - The DID of the space to share. + * @param {ShareOptions} [options] - Options for the delegation. + * + * @returns {Promise>} Resolves with the AgentDelegation instance once the space is successfully shared. + * @throws {Error} - Throws an error if there is an issue delegating access to the space. + */ + async shareSpace( + delegateEmail, + spaceDID, + options = { + abilities: [ + 'space/*', + 'store/*', + 'upload/*', + 'access/*', + 'usage/*', + 'filecoin/offer', + 'filecoin/info', + 'filecoin/accept', + 'filecoin/submit', + ], + expiration: Infinity, + } + ) { + const { abilities, ...restOptions } = options + const currentSpace = this.agent.currentSpace() + + try { + // Make sure the agent is using the shared space before delegating + await this.agent.setCurrentSpace(spaceDID) + + // Delegate capabilities to the delegate account to access the **current space** + const { root, blocks } = await this.agent.delegate({ + ...restOptions, + abilities, + audience: { + did: () => DIDMailto.fromEmail(DIDMailto.email(delegateEmail)), + }, + // @ts-expect-error audienceMeta is not defined in ShareOptions + audienceMeta: options.audienceMeta ?? {}, + }) + + const delegation = new AgentDelegation(root, blocks, { + audience: delegateEmail, + }) + + const sharingResult = await this.capability.access.delegate({ + space: spaceDID, + delegations: [delegation], + }) + + if (sharingResult.error) { + throw new Error( + `failed to share space with ${delegateEmail}: ${sharingResult.error.message}`, + { + cause: sharingResult.error, + } + ) + } + + return delegation + } finally { + // Reset to the original space if it was different + if (currentSpace && currentSpace !== spaceDID) { + await this.agent.setCurrentSpace(currentSpace) + } + } + } + /* c8 ignore stop */ /** diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index 3cfb2d6cd..10da152c4 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -1,12 +1,20 @@ import assert from 'assert' import { parseLink } from '@ucanto/server' -import { AgentData } from '@web3-storage/access/agent' +import { + Agent, + AgentData, + claimAccess, + requestAccess, +} from '@web3-storage/access/agent' import { randomBytes, randomCAR } from './helpers/random.js' import { toCAR } from './helpers/car.js' import { File } from './helpers/shims.js' import { Client } from '../src/client.js' import * as Test from './test.js' import { receiptsEndpoint } from './helpers/utils.js' +import { Absentee } from '@ucanto/principal' +import { DIDMailto } from '../src/capability/access.js' +import { confirmConfirmationUrl } from '../../upload-api/test/helpers/utils.js' /** @type {Test.Suite} */ export const testClient = { @@ -393,6 +401,132 @@ export const testClient = { client.capability.access.delegate = originalDelegate }, }), + shareSpace: Test.withContext({ + 'should share the space with another account': async ( + assert, + { client: aliceClient, mail, grantAccess, connection } + ) => { + // Step 1: Create a client for Alice and login + const aliceEmail = 'alice@web.mail' + const aliceLogin = aliceClient.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + await grantAccess(message) + const aliceAccount = await aliceLogin + + // Step 2: Alice creates a space + const space = await aliceClient.createSpace('share-space-test', { + account: aliceAccount, + }) + assert.ok(space) + + // Step 3: Alice shares the space with Bob + const bobEmail = 'bob@web.mail' + await aliceClient.shareSpace(bobEmail, space.did()) + + // Step 4: Bob access his device and his device gets authorized + const bobAccount = Absentee.from({ id: DIDMailto.fromEmail(bobEmail) }) + const bobAgentData = await AgentData.create() + const bobClient = await Agent.create(bobAgentData, { + connection, + }) + + // Authorization + await requestAccess(bobClient, bobAccount, [{ can: '*' }]) + await confirmConfirmationUrl(bobClient.connection, await mail.take()) + + // Step 5: Claim Access to the shared space + await claimAccess(bobClient, bobClient.issuer.did(), { + addProofs: true, + }) + + // Step 6: Bob verifies access to the space + const spaceInfo = await bobClient.getSpaceInfo(space.did()) + assert.ok(spaceInfo) + assert.equal(spaceInfo.did, space.did()) + + // Step 7: The shared space should be part of Bob's spaces + const spaces = bobClient.spaces + assert.equal(spaces.size, 1) + assert.equal(spaces.get(space.did())?.name, space.name) + + // Step 8: Make sure Alice and Bob's clients/devices are different + assert.notEqual(aliceClient.did(), bobClient.did()) + }, + + 'should fail to share the space if the delegate call returns an error': + async (assert, { client, mail, grantAccess }) => { + // Step 1: Create a client for Alice and login + const aliceEmail = 'alice@web.mail' + const aliceLogin = client.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + await grantAccess(message) + const aliceAccount = await aliceLogin + + // Step 2: Alice creates a space + const space = await client.createSpace( + 'share-space-delegate-fail-test', + { + account: aliceAccount, + } + ) + assert.ok(space) + + // Step 3: Mock the delegate call to return an error + const originalDelegate = client.capability.access.delegate + // @ts-ignore + client.capability.access.delegate = async () => { + return { error: { message: 'Delegate failed' } } + } + + // Step 4: Attempt to share the space with Bob and expect failure + const bobEmail = 'bob@web.mail' + await assert.rejects(client.shareSpace(bobEmail, space.did()), { + message: `failed to share space with ${bobEmail}: Delegate failed`, + }) + + // Restore the original delegate method + client.capability.access.delegate = originalDelegate + }, + + 'should reset current space when sharing': async ( + assert, + { client, mail, grantAccess } + ) => { + // Step 1: Create a client for Alice and login + const aliceEmail = 'alice@web.mail' + const aliceLogin = client.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + await grantAccess(message) + const aliceAccount = await aliceLogin + + // Step 2: Alice creates a space + const spaceA = await client.createSpace('test-space-a', { + account: aliceAccount, + }) + assert.ok(spaceA) + + // Step 3: Alice creates another space to share with a friend + const spaceB = await client.createSpace('test-space-b', { + account: aliceAccount, + }) + assert.ok(spaceB) + + // Step 4: Alice set the current space to space A and shares the space B with Bob + await client.setCurrentSpace(spaceA.did()) + await client.shareSpace('bob@web.mail', spaceB.did()) + + // Step 5: Check that current space from Alice is still space A + const currentSpace = client.currentSpace() + assert.equal( + currentSpace?.did(), + spaceA.did(), + 'current space is not space A' + ) + }, + }), proofs: { 'should get proofs': async (assert) => { const alice = new Client(await AgentData.create())