Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sharing spaces #1551

Merged
merged 4 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading