Skip to content

Commit

Permalink
feat(client): optional account recovery (#1546)
Browse files Browse the repository at this point in the history
Issue storacha/project-tracking#124

# Current Workflow (CLI)

### `w3cli` Project
The [`space.create(name,
options)`](https://github.com/storacha-network/w3cli/blob/3f59da7b096a63f14def9946682160fefacd0702/space.js#L24)
function in `space.js` allows the user to pass the following options:
- **recovery**: `false` (default)
- **caution**: `false` (default)
- **customer**: `Email | false`
- **account**: `string | false`

If the `account` attribute is provided, the following steps are
triggered:
1. `space.createdRecovery` is called.
2. Then, `client.capabilities.access.delegate` is executed.

This process occurs during **space creation**, not during provisioning.
Provisioning only happens when the `customer` attribute is provided in
the `options` argument.

---

# Issue (JS Client)

### `w3up` Project
The
[`client.createSpace(name)`](https://github.com/storacha-network/w3up/blob/fb8b8677c4c633cdf8c259db55357a1794eed3ab/packages/w3up-client/src/client.js#L239)
function in the `w3up` project currently does **not** accept any
options, which means the recovery account must be created manually
**after** space creation and provisioning. This introduces a risk of
forgetting the manual step and potentially losing access to the space.

---

# Expected Outcome

We propose a small modification to the `client.createSpace(name)`
function to support optional parameters, allowing clients to pass an
`account` directly, automating the recovery setup.

### Solution:
- Clients can now call `client.createSpace(name, { account })` to
include the `account` in the creation process.
- This ensures that the recovery account is created immediately after
the space is created, provisioned, and saved, eliminating the need for a
manual step.

### Benefits:
- **Backward Compatibility**: The change does not break existing client
implementations since the `account` parameter is optional, keeping calls
like `client.createSpace(name)` intact.
- **Simplified Workflow**: By simply passing the `account` when calling
`createSpace`, the recovery account setup is handled automatically
during space creation.
- **Flexibility**: If needed in the future, the `account` attribute can
be made required to enforce stricter recovery account management.

---------

Signed-off-by: Felipe Forbeck <[email protected]>
Co-authored-by: Alan Shaw <[email protected]>
  • Loading branch information
fforbeck and Alan Shaw authored Sep 16, 2024
1 parent fb8b867 commit ea02adb
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 35 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/w3up-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ jobs:
uses: actions/checkout@v3
- name: Install
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup
uses: actions/setup-node@v3
with:
Expand Down
36 changes: 9 additions & 27 deletions packages/w3up-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,55 +126,37 @@ A [`Space`][docs-Space] acts as a namespace for your uploads, and what your Agen
const account = await client.login('[email protected]')
```

If your account does not yet have a payment plan, you'll be prompted to choose one after your email address has been verified. You will need a payment plan in order to provision your space. You can use the following code to wait for a payment plan to be selected:
If your account doesn't have a payment plan yet, you'll be prompted to select one after verifying your email. A payment plan is required to provision a space. You can use the following loop to wait until a payment plan is selected:

```js
// wait for payment plan to be selected
while (true) {
const res = await account.plan.get()
if (res.ok) break
console.log('Waiting for payment plan to be selected...')
await new Promise(resolve => setTimeout(resolve, 1000))
await new Promise((resolve) => setTimeout(resolve, 1000))
}
```

Spaces can be created using the [`createSpace` client method][docs-client#createSpace]:

```js
const space = await client.createSpace('my-awesome-space')
const space = await client.createSpace('my-awesome-space', { account })
```

or using the w3cli's [`w3 space create`](https://github.com/web3-storage/w3cli#w3-space-create-name).
Alternatively, you can use the w3cli command [`w3 space create`](https://github.com/web3-storage/w3cli#w3-space-create-name).

The name parameter is optional. If provided, it will be stored in your client's local state store and can be used to provide a friendly name for user interfaces.
The `name` parameter is optional. If provided, it will be stored in your client's local state store and can be used to provide a friendly name for user interfaces.

Before anything can be stored with a space using web3.storage, the space must also be provisioned by a specific account that is responsible for the stored data. Note: after this succeeds, `account`'s payment method will be charged for data stored in `space`.
If an `account` is provided in the options, a delegated recovery account is automatically created and provisioned, allowing you to store data and delegate access to the recovery account. This means you can access your space from other devices, as long as you have access to your account.

```js
await account.provision(space.did())
```

If provisioning succeeds, you're ready to use the Space. Save your space to your agent's state store:

```js
await space.save()
```

If your agent has no other spaces, saving the space will set it as the "current space" in your agent. If you already have other spaces, you may want to set it as the current:
If this is your Agent's first space, it will automatically be set as the "current space." If you already have spaces and want to set the new one as current, you can do so manually:

```js
await client.setCurrentSpace(space.did())
```

One last thing - now that you've saved your space locally, it's a good idea to setup recovery, so that when you move to a different device you can still access your space:

```js
const recovery = await space.createRecovery(account.did())
await client.capability.access.delegate({
space: space.did(),
delegations: [recovery],
})
```
ℹ️ Note: If you do not create the space passing the account parameter you run the risk of losing access to your space!

#### Delegating from Space to Agent

Expand Down Expand Up @@ -578,7 +560,7 @@ Spaces available to this agent.
### `createSpace`

```ts
async function createSpace (name?: string): Promise<Space>
async function createSpace(name?: string, options?: {account: Account}): Promise<Space>
```

Create a new space with an optional name.
Expand Down
51 changes: 47 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,53 @@ export class Client extends Base {

/**
* Create a new space with a given name.
* If an account is not provided, the space is created without any delegation and is not saved, hence it is a temporary space.
* When an account is provided in the options argument, then it creates a delegated recovery account
* by provisioning the space, saving it 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 = {}) {
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.message}`,
{ cause: provisionResult.error }
)
}

// Save the space to authorize the client to use the space
await space.save()

// 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.message}`,
{ cause: result.error }
)
}
}
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
137 changes: 135 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,140 @@ 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 = '[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 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)
},

'should create a space without recovery account and fail access from another device':
async (assert, { client, mail, connect, 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)
await aliceLogin

// Step 2: Alice creates a space without providing a recovery account
const space = await client.createSpace('no-recovery-space-test')
assert.ok(space)

// Step 3: Attempt to access the space from 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

// Step 4: Add the space to the new device and set it as current space
await secondClient.addSpace(
await space.createAuthorization(aliceAccount2)
)
await secondClient.setCurrentSpace(space.did())

// Step 5: Verify the space is accessible from the new device
await assert.rejects(secondClient.capability.space.info(space.did()), {
message: `no proofs available for resource ${space.did()} and ability space/info`,
})
},

'should fail to create a space due to provisioning 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: Mock the provisioning to fail
const originalProvision = aliceAccount.provision
aliceAccount.provision = async () => ({
error: { name: 'ProvisionError', message: 'Provisioning failed' },
})

// Step 3: Attempt to create a space with the account
await assert.rejects(
client.createSpace('provision-fail-space-test', {
account: aliceAccount,
}),
{
message: 'failed to provision account: Provisioning failed',
}
)

// Restore the original provision method
aliceAccount.provision = originalProvision
},

'should fail to create a space due to delegate access error': async (
assert,
{ client, mail, connect, 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: Mock the delegate access to fail
const originalDelegate = client.capability.access.delegate
client.capability.access.delegate = async () => ({
error: { name: 'DelegateError', message: 'Delegation failed' },
})

// Step 3: Attempt to create a space with the account
await assert.rejects(
client.createSpace('delegate-fail-space-test', {
account: aliceAccount,
}),
{
message: 'failed to authorize recovery account: Delegation failed',
}
)

// Restore the original delegate method
client.capability.access.delegate = originalDelegate
},
}),
proofs: {
'should get proofs': async (assert) => {
const alice = new Client(await AgentData.create())
Expand Down

0 comments on commit ea02adb

Please sign in to comment.