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: archive/extract api for delegations #287

Merged
merged 6 commits into from
May 2, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 83 additions & 5 deletions packages/core/src/delegation.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import * as UCAN from '@ipld/dag-ucan'
import * as API from '@ucanto/interface'
import * as Link from './link.js'
import * as DAG from './dag.js'
import * as CAR from './car.js'
import * as CBOR from './cbor.js'
import * as Schema from './schema.js'
import { ok, error } from './result.js'

/**
* @deprecated
Expand Down Expand Up @@ -212,6 +216,13 @@ export class Delegation {
return exportDAG(this.root, this.blocks, this.attachedLinks)
}

/**
* @returns {API.Await<API.Result<Uint8Array, Error>>}
*/
archive() {
return archive(this)
}

iterateIPLDBlocks() {
return exportDAG(this.root, this.blocks, this.attachedLinks)
}
Expand Down Expand Up @@ -304,6 +315,70 @@ export class Delegation {
}
}

/**
* Writes given `Delegation` chain into a content addressed archive (CAR)
* buffer and returns it.
*
* @param {API.Delegation} delegation}
* @returns {Promise<API.Result<Uint8Array, Error>>}
*/
export const archive = async delegation => {
try {
// Iterate over all of the blocks in the DAG and add them to the
// block store.
const store = new Map()
for (const block of delegation.iterateIPLDBlocks()) {
store.set(`${block.cid}`, block)
}

// Then we we create a descriptor block to describe what this DAG represents
// and it to the block store as well.
const variant = await CBOR.write({
[`ucan@${delegation.version}`]: delegation.root.cid,
})
store.set(`${variant.cid}`, variant)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like in CAR.encode the root blocks are included (i.e. I think you might get the root block twice in the encoded CAR).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No you won't and I plan on changing CAR codec so that roots are no longer blocks but rather CIDs. Current approach of roots been blocks are kind of falling apart if you get a car from someone that has a root that isn't inside the car


// And finally we encode the whole thing into a CAR.
const bytes = CAR.encode({
roots: [variant],
blocks: store,
})

return ok(bytes)
} catch (cause) {
return error(/** @type {Error} */ (cause))
}
}

export const ArchiveSchema = Schema.variant({
'[email protected]': /** @type {Schema.Schema<API.UCANLink>} */ (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this version number be a constant somewhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the UCAN.VERSION, however at some point we'll probably upgrade it to next version, yet version here should remain the same. I'm not sure there is a value in defining a new constant somewhere if this is the only thing referring to it though.

Schema.link({ version: 1 })
),
})

/**
* Extracts a `Delegation` chain from a given content addressed archive (CAR)
* buffer. Assumes that the CAR contains a single root block corresponding to
* the delegation variant.
*
* @param {Uint8Array} archive
*/
export const extract = async archive => {
try {
const { roots, blocks } = CAR.decode(archive)
const [root] = roots
if (root == null) {
return Schema.error('CAR archive does not contain a root block')
}
const { bytes } = root
const variant = CBOR.decode(bytes)
const [, link] = ArchiveSchema.match(variant)
return ok(view({ root: link, blocks }))
} catch (cause) {
return error(/** @type {Error} */ (cause))
}
}

/**
* @param {API.Delegation} delegation
* @returns {IterableIterator<API.Delegation>}
Expand Down Expand Up @@ -345,7 +420,7 @@ const decode = ({ bytes }) => {
*/

export const delegate = async (
{ issuer, audience, proofs = [], attachedBlocks = new Map, ...input },
{ issuer, audience, proofs = [], attachedBlocks = new Map(), ...input },
options
) => {
const links = []
Expand Down Expand Up @@ -466,16 +541,19 @@ export const create = ({ root, blocks }) => new Delegation(root, blocks)

/**
* @template {API.Capabilities} C
* @template [T=undefined]
* @template [E=never]
* @param {object} dag
* @param {API.UCANLink<C>} dag.root
* @param {DAG.BlockStore} dag.blocks
* @param {T} [fallback]
* @returns {API.Delegation<C>|T}
* @param {E} [fallback]
* @returns {API.Delegation<C>|E}
*/
export const view = ({ root, blocks }, fallback) => {
const block = DAG.get(root, blocks, null)
return block ? create({ root: block, blocks }) : /** @type {T} */ (fallback)
if (block == null) {
return fallback !== undefined ? fallback : DAG.notFound(root)
}
return create({ root: block, blocks })
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as API from '@ucanto/interface'
*
* @template {{}|string|boolean|number} T
* @param {T} value
* @returns {{ok: T, value?:undefined}}
* @returns {{ok: T, error?:undefined}}
*/
export const ok = value => {
if (value == null) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,7 @@ export const variant = variants => new Variant(variants)

/**
* @param {string} message
* @returns {{error: Schema.Error}}
* @returns {{error: Schema.Error, ok?: undefined}}
*/
export const error = message => ({ error: new SchemaError(message) })

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export interface StringSchema<O extends string, I = unknown>
endsWith<Suffix extends string>(
suffix: Suffix
): StringSchema<O & `${string}${Suffix}`, I>
refine<T extends string>(schema: Reader<T, O>): StringSchema<O & T, I>
refine<T>(schema: Reader<T, O>): StringSchema<O & T, I>
}

declare const Marker: unique symbol
Expand Down
139 changes: 138 additions & 1 deletion packages/core/test/delegation.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assert, test } from './test.js'
import { Delegation, UCAN, delegate, parseLink } from '../src/lib.js'
import { CAR, CBOR, delegate, Delegation, parseLink, UCAN } from '../src/lib.js'
import { alice, bob, mallory, service as w3 } from './fixtures.js'
import { base64 } from 'multiformats/bases/base64'
import { getBlock } from './utils.js'
Expand Down Expand Up @@ -290,6 +290,143 @@ test('.buildIPLDView() return same value', async () => {
assert.equal(ucan.buildIPLDView(), ucan)
})

test('delegation archive', async () => {
const ucan = await delegate({
issuer: alice,
audience: w3,
capabilities: [
{
with: alice.did(),
can: 'test/echo',
nb: {
message: 'data:1',
},
},
],
})

const archive = await ucan.archive()
if (archive.error) {
return assert.fail(archive.error.message)
}

const extract = await Delegation.extract(archive.ok)
if (extract.error) {
return assert.fail(extract.error.message)
}

assert.deepEqual(extract.ok, ucan)
})

test('fail to extract wrong version', async () => {
const ucan = await delegate({
issuer: alice,
audience: w3,
capabilities: [
{
with: alice.did(),
can: 'test/echo',
nb: {
message: 'data:1',
},
},
],
})

const root = await CBOR.write({
['[email protected]']: ucan.root.cid,
})
ucan.blocks.set(`${root.cid}`, root)

const bytes = CAR.encode({
roots: [root],
blocks: ucan.blocks,
})

const result = await Delegation.extract(bytes)
if (result.ok) {
return assert.fail('should not be ok')
}

assert.match(
result.error.message,
/[email protected] instead got object with key [email protected]/
)

const badbytes = await Delegation.extract(new Uint8Array([0, 1, 1]))
assert.match(badbytes.error?.message || '', /invalid car/i)

const noroot = CAR.encode({ blocks: ucan.blocks })
const noRootResult = await Delegation.extract(noroot)
assert.match(noRootResult.error?.message || '', /does not contain a root/i)

const nonvariantroot = await Delegation.extract(
CAR.encode({
roots: [ucan.root],
blocks: ucan.blocks,
})
)

assert.match(nonvariantroot.error?.message || '', /object with a single key/i)

const okroot = await CBOR.write({
['[email protected]']: ucan.root.cid,
})

const missingblocks = await Delegation.extract(
CAR.encode({ roots: [okroot] })
)

assert.match(missingblocks.error?.message || '', /Block .* not found/i)
})

test('fail archive with bad input', async () => {
const result = await Delegation.archive(
// @ts-expect-error
{}
)
assert.equal(result.error instanceof Error, true)
})

test('archive delegation chain', async () => {
const proof = await Delegation.delegate({
issuer: alice,
audience: bob,
capabilities: [
{
can: 'store/add',
with: alice.did(),
},
],
})

const ucan = await Delegation.delegate({
issuer: bob,
audience: mallory,
capabilities: [
{
can: 'store/add',
with: alice.did(),
root: link,
},
],
proofs: [proof],
})

const archive = await ucan.archive()
if (archive.error) {
return assert.fail(archive.error.message)
}

const extract = await Delegation.extract(archive.ok)
if (extract.error) {
return assert.fail(extract.error.message)
}

assert.deepEqual(extract.ok, ucan)
assert.deepEqual(extract.ok.proofs[0], proof)
})

test('delegation.attach', async () => {
const ucan = await Delegation.delegate({
issuer: alice,
Expand Down
2 changes: 2 additions & 0 deletions packages/interface/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ export interface Delegation<C extends Capabilities = Capabilities>
toJSON(): DelegationJSON<this>
delegate(): Await<Delegation<C>>

archive(): Await<Result<Uint8Array, Error>>

attach(block: Block): void
}

Expand Down