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(client): optional account recovery #1546

Merged
merged 12 commits into from
Sep 16, 2024
48 changes: 44 additions & 4 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Upload as UploadCapabilities,
Filecoin as FilecoinCapabilities,
} from '@web3-storage/capabilities'
import * as DIDMailto from '@web3-storage/did-mailto'
import { Base } from './base.js'
import * as Account from './account.js'
import { Space } from './space.js'
Expand Down Expand Up @@ -98,8 +99,9 @@ export class Client extends Base {
/* c8 ignore stop */

/**
* List all accounts that agent has stored access to. Returns a dictionary
* of accounts keyed by their `did:mailto` identifier.
* List all accounts that agent has stored access to.
*
* @returns {Record<DIDMailto, Account>} A dictionary with `did:mailto` as keys and `Account` instances as values.
*/
accounts() {
return Account.list(this)
Expand Down Expand Up @@ -233,12 +235,50 @@ export class Client extends Base {

/**
* Create a new space with a given name.
* If an account is provided in the options argument, then it creates a delegated recovery account
* by provisioning the space and then delegating access to the recovery account.
*
* @typedef {object} CreateOptions
* @property {Account.Account} [account]
*
* @param {string} name
* @param {CreateOptions} options
* @returns {Promise<import("./space.js").OwnedSpace>} The created space owned by the agent.
*/
async createSpace(name) {
return await this._agent.createSpace(name)
async createSpace(name, options = {}) {
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
const space = await this._agent.createSpace(name)

const account = options.account
if (account) {
// Provision the account with the space
const provisionResult = await account.provision(space.did())
if (provisionResult.error) {
throw new Error(
`⚠️ Failed to provision account: ${provisionResult.error.name}:${provisionResult.error.message}`
)
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
}

// Save the space to authorize the client to use the space
await space.save()
fforbeck marked this conversation as resolved.
Show resolved Hide resolved

// Create a recovery for the account
const recovery = await space.createRecovery(account.did())

// Delegate space access to the recovery
const result = await this.capability.access.delegate({
space: space.did(),
delegations: [recovery],
})

if (result.error) {
throw new Error(
`⚠️ Failed to authorize recovery account: ${result.error.name}:${result.error.message}`
)
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
}
}
return space
}

/* c8 ignore stop */

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/w3up-client/test/client-accounts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ export const testClientAccounts = Test.withContext({
const accounts = client.accounts()

assert.deepEqual(Object.values(accounts).length, 1)
// @ts-ignore FIXME (fforbeck)
assert.ok(accounts[Account.fromEmail(email)])

// @ts-ignore FIXME (fforbeck)
const account = accounts[Account.fromEmail(email)]
assert.equal(account.toEmail(), email)
assert.equal(account.did(), Account.fromEmail(email))
Expand Down
39 changes: 37 additions & 2 deletions packages/w3up-client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export const testClient = {
assert.equal(current1?.did(), space.did())
},
},
spaces: {
spaces: Test.withContext({
'should get agent spaces': async (assert) => {
const alice = new Client(await AgentData.create())

Expand Down Expand Up @@ -259,7 +259,42 @@ export const testClient = {
assert.equal(spaces.length, 1)
assert.equal(spaces[0].did(), space.did())
},
},

'should create a space with recovery account': async (
assert,
{ client, mail, connect, 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 with her account as the recovery account
const space = await client.createSpace('recovery-space-test', {
account: aliceAccount, // The account is the recovery account
})
assert.ok(space)

// Step 3: Verify the recovery account by connecting to a new device
const secondClient = await connect()
const secondLogin = secondClient.login(aliceEmail)
const secondMessage = await mail.take()
assert.deepEqual(secondMessage.to, aliceEmail)
await grantAccess(secondMessage)
const aliceAccount2 = await secondLogin
await secondClient.addSpace(
await space.createAuthorization(aliceAccount2)
)
await secondClient.setCurrentSpace(space.did())

// Step 4: Verify the space is accessible from the new device
const spaceInfo = await secondClient.capability.space.info(space.did())
assert.ok(spaceInfo)
},
}),
proofs: {
'should get proofs': async (assert) => {
const alice = new Client(await AgentData.create())
Expand Down
Loading