-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(client): optional account recovery (#1546)
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
Showing
5 changed files
with
193 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
@@ -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. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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()) | ||
|
||
|
@@ -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()) | ||
|