Skip to content

Commit

Permalink
feat: utility exports for better UX (#1505)
Browse files Browse the repository at this point in the history
This PR re-exports some ucanto exports and a utility function to parse a
proof (in any current or legacy format). This should make working with
the client easier as all the things you need are available (no
additional deps to install), makes the docs much more succinct and
easier to follow, and actually allows you to import a base64 encoded
delegation successfully from any period in time you obtained it.

Here's one of many code snippets from the docs site that will be
improved by this PR:

### Before

```js
import * as Client from '@web3-storage/w3up-client'
import { StoreMemory } from '@web3-storage/w3up-client/stores/memory'
import { importDAG } from '@ucanto/core/delegation'
import { CarReader } from '@ipld/car'
import * as Signer from '@ucanto/principal/ed25519'
 
async function main () {
  // Load client with specific private key
  const principal = Signer.parse(process.env.KEY)
  const store = new StoreMemory()
  const client = await Client.create({ principal, store })
  // Add proof that this agent has been delegated capabilities on the space
  const proof = await parseProof(process.env.PROOF)
  const space = await client.addSpace(proof)
  await client.setCurrentSpace(space.did())
  // READY to go!
}
 
/** @param {string} data Base64 encoded CAR file */
async function parseProof (data) {
  const blocks = []
  const reader = await CarReader.fromBytes(Buffer.from(data, 'base64'))
  for await (const block of reader.blocks()) {
    blocks.push(block)
  }
  return importDAG(blocks)
}
```

### After

```js
import * as Client from '@web3-storage/w3up-client'
import { Signer } from '@web3-storage/w3up-client/principal/ed25519'
import { StoreMemory } from '@web3-storage/w3up-client/stores/memory'
import * as Proof from '@web3-storage/w3up-client/proof'
 
async function main () {
  // Load client with specific private key
  const principal = Signer.parse(process.env.KEY)
  const store = new StoreMemory()
  const client = await Client.create({ principal, store })
  // Add proof that this agent has been delegated capabilities on the space
  const proof = await Proof.parse(process.env.PROOF)
  const space = await client.addSpace(proof)
  await client.setCurrentSpace(space.did())
  // READY to go!
}
```
  • Loading branch information
Alan Shaw authored Jun 13, 2024
1 parent 02e3bca commit 54b0d93
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 4 deletions.
20 changes: 20 additions & 0 deletions packages/w3up-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@
"types": "./dist/src/account.d.ts",
"import": "./dist/src/account.js"
},
"./delegation": {
"types": "./dist/src/delegation.d.ts",
"import": "./dist/src/delegation.js"
},
"./principal": {
"types": "./dist/src/principal/index.d.ts",
"import": "./dist/src/principal/index.js"
},
"./principal/ed25519": {
"types": "./dist/src/principal/ed25519.d.ts",
"import": "./dist/src/principal/ed25519.js"
},
"./principal/rsa": {
"types": "./dist/src/principal/rsa.d.ts",
"import": "./dist/src/principal/rsa.js"
},
"./proof": {
"types": "./dist/src/proof.d.ts",
"import": "./dist/src/proof.js"
},
"./space": {
"types": "./dist/src/space.d.ts",
"import": "./dist/src/space.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { Base } from './base.js'
import * as Account from './account.js'
import { Space } from './space.js'
import { Delegation as AgentDelegation } from './delegation.js'
import { AgentDelegation } from './delegation.js'
import { BlobClient } from './capability/blob.js'
import { IndexClient } from './capability/index.js'
import { StoreClient } from './capability/store.js'
Expand Down
8 changes: 5 additions & 3 deletions packages/w3up-client/src/delegation.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Delegation as CoreDelegation } from '@ucanto/core/delegation'
import { Delegation } from '@ucanto/core/delegation'

export * from '@ucanto/core/delegation'

/* c8 ignore start */
/**
* @template {import('./types.js').Capabilities} C
* @extends {CoreDelegation<C>}
* @extends {Delegation<C>}
*/
export class Delegation extends CoreDelegation {
export class AgentDelegation extends Delegation {
/* c8 ignore stop */
/** @type {Record<string, any>} */
#meta
Expand Down
1 change: 1 addition & 0 deletions packages/w3up-client/src/principal/ed25519.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@ucanto/principal/ed25519'
1 change: 1 addition & 0 deletions packages/w3up-client/src/principal/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@ucanto/principal'
1 change: 1 addition & 0 deletions packages/w3up-client/src/principal/rsa.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@ucanto/principal/rsa'
54 changes: 54 additions & 0 deletions packages/w3up-client/src/proof.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { importDAG, extract } from '@ucanto/core/delegation'
import * as CAR from '@ucanto/transport/car'
import { CarReader } from '@ipld/car'
import * as Link from 'multiformats/link'
import { base64 } from 'multiformats/bases/base64'
import { identity } from 'multiformats/hashes/identity'

/**
* Parses a base64 encoded CIDv1 CAR of proofs (delegations).
*
* @param {string} str Base64 encoded CAR file.
*/
export const parse = async (str) => {
try {
const cid = Link.parse(str, base64)
if (cid.code !== CAR.codec.code) {
throw new Error(`non CAR codec found: 0x${cid.code.toString(16)}`)
}
if (cid.multihash.code !== identity.code) {
throw new Error(
`non identity multihash: 0x${cid.multihash.code.toString(16)}`
)
}

try {
const { ok, error } = await extract(cid.multihash.digest)
if (error)
throw new Error('failed to extract delegation', { cause: error })
return ok
} catch {
// Before `delegation.archive()` we used `delegation.export()` to create
// a plain CAR file of blocks.
return legacyExtract(cid.multihash.digest)
}
} catch {
// At one point we recommended piping output directly to base64 encoder:
// `w3 delegation create did:key... --can 'store/add' | base64`
return legacyExtract(base64.baseDecode(str))
}
}

/**
* Reads a plain CAR file, assuming the last block is the delegation root.
*
* @param {Uint8Array} bytes
*/
const legacyExtract = async (bytes) => {
const blocks = []
const reader = await CarReader.fromBytes(bytes)
for await (const block of reader.blocks()) {
blocks.push(block)
}
return importDAG(blocks)
}
125 changes: 125 additions & 0 deletions packages/w3up-client/test/proof.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as Test from './test.js'
import * as CAR from '@ucanto/transport/car'
import * as Link from 'multiformats/link'
import { base64 } from 'multiformats/bases/base64'
import { identity } from 'multiformats/hashes/identity'
import { sha256 } from 'multiformats/hashes/sha2'
import { Signer } from '../src/principal/ed25519.js'
import { delegate } from '../src/delegation.js'
import { parse } from '../src/proof.js'
import * as Result from '../src/result.js'

/**
* @type {Test.Suite}
*/
export const testProof = {
'should parse a base64 encoded CIDv1 "proof"': async (assert) => {
const alice = await Signer.generate()
const bob = await Signer.generate()
const delegation = await delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: 'test/thing', with: alice.did() }],
})

const bytes = Result.unwrap(await delegation.archive())
const str = Link.create(CAR.codec.code, identity.digest(bytes)).toString(
base64
)

const proof = await parse(str)
assert.equal(proof.issuer.did(), delegation.issuer.did())
assert.equal(proof.audience.did(), delegation.audience.did())
assert.equal(proof.capabilities[0].can, delegation.capabilities[0].can)
assert.equal(proof.capabilities[0].with, delegation.capabilities[0].with)
},

'should fail to parse if CID is not CAR codec': async (assert) => {
const alice = await Signer.generate()
const bob = await Signer.generate()
const delegation = await delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: 'test/thing', with: alice.did() }],
})

const bytes = Result.unwrap(await delegation.archive())
const str = Link.create(12345, identity.digest(bytes)).toString(base64)

await assert.rejects(parse(str))
},

'should fail to parse if multihash is not identity hash': async (assert) => {
const alice = await Signer.generate()
const bob = await Signer.generate()
const delegation = await delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: 'test/thing', with: alice.did() }],
})

const bytes = Result.unwrap(await delegation.archive())
const str = Link.create(
CAR.codec.code,
await sha256.digest(bytes)
).toString(base64)

await assert.rejects(parse(str))
},

'should parse a base64 encoded CIDv1 "proof" as plain CAR (legacy)': async (
assert
) => {
const alice = await Signer.generate()
const bob = await Signer.generate()
const delegation = await delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: 'test/thing', with: alice.did() }],
})

const blocks = new Map()
for (const block of delegation.export()) {
blocks.set(block.cid.toString(), block)
}

const bytes = CAR.codec.encode({ blocks })
const str = Link.create(CAR.codec.code, identity.digest(bytes)).toString(
base64
)

const proof = await parse(str)
assert.equal(proof.issuer.did(), delegation.issuer.did())
assert.equal(proof.audience.did(), delegation.audience.did())
assert.equal(proof.capabilities[0].can, delegation.capabilities[0].can)
assert.equal(proof.capabilities[0].with, delegation.capabilities[0].with)
},

'should parse a base64 encoded "proof" as plain CAR (legacy)': async (
assert
) => {
const alice = await Signer.generate()
const bob = await Signer.generate()
const delegation = await delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: 'test/thing', with: alice.did() }],
})

const blocks = new Map()
for (const block of delegation.export()) {
blocks.set(block.cid.toString(), block)
}

const bytes = CAR.codec.encode({ blocks })
const str = base64.baseEncode(bytes)

const proof = await parse(str)
assert.equal(proof.issuer.did(), delegation.issuer.did())
assert.equal(proof.audience.did(), delegation.audience.did())
assert.equal(proof.capabilities[0].can, delegation.capabilities[0].can)
assert.equal(proof.capabilities[0].with, delegation.capabilities[0].with)
},
}

Test.test({ Proof: testProof })

0 comments on commit 54b0d93

Please sign in to comment.