Skip to content

Commit

Permalink
feat: sharing spaces (#1551)
Browse files Browse the repository at this point in the history
# 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](storacha/project-tracking#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.
  • Loading branch information
fforbeck authored Sep 20, 2024
1 parent e02ddf3 commit 7deb9a4
Show file tree
Hide file tree
Showing 2 changed files with 221 additions and 1 deletion.
86 changes: 86 additions & 0 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<import('./delegation.js').AgentDelegation<any>>} 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 */

/**
Expand Down
136 changes: 135 additions & 1 deletion packages/w3up-client/test/client.test.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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 = '[email protected]'
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 = '[email protected]'
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 = '[email protected]'
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 = '[email protected]'
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 = '[email protected]'
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('[email protected]', 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())
Expand Down

0 comments on commit 7deb9a4

Please sign in to comment.