diff --git a/README.md b/README.md index 04f8417..9604f7b 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,11 @@ w3 open bafybeidluj5ub7okodgg5v6l4x3nytpivvcouuxgzuioa6vodg3xt2uqle/olizilla.png Print information about the current agent. -### `w3 space add ` +### `w3 space add ` -Add a space to the agent. The proof is a CAR encoded delegation to _this_ agent. +Add a space to the agent. The proof is a CAR encoded UCAN delegating capabilities over a space to _this_ agent. + +`proof` is a filesystem path to a CAR encoded UCAN, as generated by `w3 delegation create` _or_ a base64 identity CID string as created by `w3 delegation create --base64`. ### `w3 space create [name]` @@ -144,17 +146,14 @@ Create a delegation to the passed audience for the given abilities with the _cur - `--name` Human readable name for the audience receiving the delegation. - `--type` Type of the audience receiving the delegation, one of: device, app, service. - `--output` Path of file to write the exported delegation data to. +- `--base64` Format as base64 identity CID string. Useful when saving it as an environment variable. ```bash -# delegate space/info to did:key:z6MkrwtRceSo2bE6vAY4gi8xPNfNszSpvf8MpAHnxVfMYreN -w3 delegation create did:key:z6MkrwtRceSo2bE6vAY4gi8xPNfNszSpvf8MpAHnxVfMYreN --can space/info - -# delegate store/* and upload/* to did:key:z6MkrwtRceSo2bE6vAY4gi8xPNfNszSpvf8MpAHnxVfMYreN -w3 delegation create did:key:z6MkrwtRceSo2bE6vAY4gi8xPNfNszSpvf8MpAHnxVfMYreN --can 'store/*' --can 'upload/*' +# delegate space/info to did:key:z6M..., output as a CAR +w3 delegation create did:key:z6M... --can space/info --output ./info.ucan -# delegate all capabilities to did:key:z6MkrwtRceSo2bE6vAY4gi8xPNfNszSpvf8MpAHnxVfMYreN -# WARNING - this is bad practice and should generally only be done in testing and development -w3 delegation create did:key:z6MkrwtRceSo2bE6vAY4gi8xPNfNszSpvf8MpAHnxVfMYreN --can '*' +# delegate store/* and upload/* to did:key:z6M..., output as a string +w3 delegation create did:key:z6M... --can 'store/*' --can 'upload/*' --base64 ``` ### `w3 delegation ls` diff --git a/bin.js b/bin.js index 6ed89d1..fca4116 100755 --- a/bin.js +++ b/bin.js @@ -142,7 +142,7 @@ cli cli .command('space add ') .describe( - 'Add a space to the agent. The proof is a CAR encoded delegation to _this_ agent.' + 'Import a space from a proof: a CAR encoded UCAN delegating capabilities to this agent. proof is a filesystem path, or a base64 encoded cid string.' ) .action(addSpace) @@ -181,7 +181,7 @@ cli cli .command('delegation create ') .describe( - 'Create a delegation to the passed audience for the given abilities with the _current_ space as the resource.' + 'Output a CAR encoded UCAN that delegates capabilities to the audience for the current space.' ) .option('-c, --can', 'One or more abilities to delegate.') .option( @@ -201,6 +201,10 @@ cli '-o, --output', 'Path of file to write the exported delegation data to.' ) + .option( + '--base64', + 'Format as base64 identity CID string. Useful when saving it as an environment variable.' + ) .action(createDelegation) cli diff --git a/index.js b/index.js index da259fb..2cd8633 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ import fs from 'fs' import ora, { oraPromise } from 'ora' -import { Readable } from 'stream' +import { pipeline } from 'node:stream/promises' import { CID } from 'multiformats/cid' +import { base64 } from 'multiformats/bases/base64' +import { identity } from 'multiformats/hashes/identity' import * as DID from '@ipld/dag-ucan/did' import * as dagJSON from '@ipld/dag-json' import { CarWriter } from '@ipld/car' @@ -15,6 +17,7 @@ import { filesize, filesizeMB, readProof, + readProofFromBytes, uploadListResponseToString, startOfLastMonth, } from './lib.js' @@ -253,11 +256,33 @@ export async function createSpace(name) { } /** - * @param {string} proofPath + * @param {string} proofPathOrCid */ -export async function addSpace(proofPath) { +export async function addSpace(proofPathOrCid) { const client = await getClient() - const delegation = await readProof(proofPath) + + let cid + try { + cid = CID.parse(proofPathOrCid, base64) + } catch (/** @type {any} */ err) { + if (err?.message?.includes('Unexpected end of data')) { + console.error(`Error: failed to read proof. The string has been truncated.`) + process.exit(1) + } + /* otherwise, try as path */ + } + + let delegation + if (cid) { + if (cid.multihash.code !== identity.code) { + console.error(`Error: failed to read proof. Must be identity CID. Fetching of remote proof CARs not supported by this command yet`) + process.exit(1) + } + delegation = await readProofFromBytes(cid.multihash.digest) + } else { + delegation = await readProof(proofPathOrCid) + } + const space = await client.addSpace(delegation) console.log(space.did()) } @@ -339,6 +364,7 @@ Providers: ${providers || chalk.dim('none')}`) * @param {number} [opts.expiration] * @param {string} [opts.output] * @param {string} [opts.with] + * @param {boolean} [opts.base64] */ export async function createDelegation(audienceDID, opts) { const client = await getClient() @@ -367,7 +393,25 @@ export async function createDelegation(audienceDID, opts) { const { writer, out } = CarWriter.create() const dest = opts.output ? fs.createWriteStream(opts.output) : process.stdout - Readable.from(out).pipe(dest) + pipeline( + out, + async function* maybeBaseEncode(src) { + const chunks = [] + for await (const chunk of src) { + if (!opts.base64) { + yield chunk + } else { + chunks.push(chunk) + } + } + if (!opts.base64) return + const blob = new Blob(chunks) + const bytes = new Uint8Array(await blob.arrayBuffer()) + const idCid = CID.createV1(ucanto.CAR.code, identity.digest(bytes)) + yield idCid.toString(base64) + }, + dest + ) for (const block of delegation.export()) { // @ts-expect-error diff --git a/lib.js b/lib.js index 49b5cda..24cc359 100644 --- a/lib.js +++ b/lib.js @@ -139,16 +139,24 @@ export function getClient() { * @param {string} path Path to the proof file. */ export async function readProof(path) { + let bytes try { - await fs.promises.access(path, fs.constants.R_OK) + const buff = await fs.promises.readFile(path) + bytes = new Uint8Array(buff.buffer) } catch (/** @type {any} */ err) { console.error(`Error: failed to read proof: ${err.message}`) process.exit(1) } + return readProofFromBytes(bytes) +} +/** + * @param {Uint8Array} bytes Path to the proof file. + */ +export async function readProofFromBytes(bytes) { const blocks = [] try { - const reader = await CarReader.fromIterable(fs.createReadStream(path)) + const reader = await CarReader.fromBytes(bytes) for await (const block of reader.blocks()) { blocks.push(block) } @@ -156,7 +164,6 @@ export async function readProof(path) { console.error(`Error: failed to parse proof: ${err.message}`) process.exit(1) } - try { // @ts-expect-error return importDAG(blocks) diff --git a/test/bin.spec.js b/test/bin.spec.js index e498849..9479109 100644 --- a/test/bin.spec.js +++ b/test/bin.spec.js @@ -18,6 +18,7 @@ import { UCAN, Provider } from '@web3-storage/capabilities' import * as ED25519 from '@ucanto/principal/ed25519' import { sha256, delegate } from '@ucanto/core' import * as Result from '@web3-storage/w3up-client/result' +import { base64 } from 'multiformats/bases/base64' const w3 = Command.create('./bin.js') @@ -349,6 +350,34 @@ export const testSpace = { assert.ok(listSome.output.includes(spaceDID)) }), + 'w3 space add `base64 proof car`': test(async (assert, context) => { + const { env } = context + const spaceDID = await loginAndCreateSpace(context, { env: env.alice }) + const whosBob = await w3.args(['whoami']).env(env.bob).join() + const bobDID = SpaceDID.from(whosBob.output.trim()) + const res = await w3 + .args([ + 'delegation', + 'create', + bobDID, + '-c', + 'store/*', + 'upload/*', + '--base64' + ]) + .env(env.alice) + .join() + + const listNone = await w3.args(['space', 'ls']).env(env.bob).join() + assert.ok(!listNone.output.includes(spaceDID)) + + const add = await w3.args(['space', 'add', res.output]).env(env.bob).join() + assert.equal(add.output.trim(), spaceDID) + + const listSome = await w3.args(['space', 'ls']).env(env.bob).join() + assert.ok(listSome.output.includes(spaceDID)) + }), + 'w3 space add invalid/path': test(async (assert, context) => { const fail = await w3 .args(['space', 'add', 'djcvbii']) @@ -784,6 +813,44 @@ export const testDelegation = { assert.equal(delegate.status.success(), true) }), + 'w3 delegation create -c store/add -c upload/add --base64': test( + async (assert, context) => { + const env = context.env.alice + const { bob } = Test + const spaceDID = await loginAndCreateSpace(context) + const res = await w3 + .args([ + 'delegation', + 'create', + bob.did(), + '-c', + 'store/add', + '-c', + 'upload/add', + '--base64' + ]) + .env(env) + .join() + + assert.equal(res.status.success(), true) + + const identityCid = parseLink(res.output, base64) + const reader = await CarReader.fromBytes(identityCid.multihash.digest) + const blocks = [] + for await (const block of reader.blocks()) { + blocks.push(block) + } + + // @ts-expect-error + const delegation = importDAG(blocks) + assert.equal(delegation.audience.did(), bob.did()) + assert.equal(delegation.capabilities[0].can, 'store/add') + assert.equal(delegation.capabilities[0].with, spaceDID) + assert.equal(delegation.capabilities[1].can, 'upload/add') + assert.equal(delegation.capabilities[1].with, spaceDID) + } + ), + 'w3 delegation ls --json': test(async (assert, context) => { const { mallory } = Test