diff --git a/packages/core/src/delegation.js b/packages/core/src/delegation.js index 54b577c9..8f91b62b 100644 --- a/packages/core/src/delegation.js +++ b/packages/core/src/delegation.js @@ -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 @@ -245,6 +249,13 @@ export class Delegation { return exportDAG(this.root, this.blocks, this.attachedLinks) } + /** + * @returns {API.Await>} + */ + archive() { + return archive(this) + } + iterateIPLDBlocks() { return exportDAG(this.root, this.blocks, this.attachedLinks) } @@ -337,6 +348,70 @@ export class Delegation { } } +/** + * Writes given `Delegation` chain into a content addressed archive (CAR) + * buffer and returns it. + * + * @param {API.Delegation} delegation} + * @returns {Promise>} + */ +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) + + // 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({ + 'ucan@0.9.1': /** @type {Schema.Schema} */ ( + 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} @@ -497,16 +572,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} dag.root * @param {DAG.BlockStore} dag.blocks - * @param {T} [fallback] - * @returns {API.Delegation|T} + * @param {E} [fallback] + * @returns {API.Delegation|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 }) } /** diff --git a/packages/core/src/result.js b/packages/core/src/result.js index 0f4f4c9f..b2b11afc 100644 --- a/packages/core/src/result.js +++ b/packages/core/src/result.js @@ -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) { diff --git a/packages/core/src/schema/schema.js b/packages/core/src/schema/schema.js index d8d1e39f..da1affd1 100644 --- a/packages/core/src/schema/schema.js +++ b/packages/core/src/schema/schema.js @@ -1342,7 +1342,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) }) diff --git a/packages/core/src/schema/type.ts b/packages/core/src/schema/type.ts index 2e51e6e0..09a27dbe 100644 --- a/packages/core/src/schema/type.ts +++ b/packages/core/src/schema/type.ts @@ -207,7 +207,7 @@ export interface StringSchema endsWith( suffix: Suffix ): StringSchema - refine(schema: Reader): StringSchema + refine(schema: Reader): StringSchema } declare const Marker: unique symbol diff --git a/packages/core/test/delegation.spec.js b/packages/core/test/delegation.spec.js index af75f9e6..84576149 100644 --- a/packages/core/test/delegation.spec.js +++ b/packages/core/test/delegation.spec.js @@ -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' @@ -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({ + ['ucan@0.8.0']: 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, + /ucan@0.9.1 instead got object with key ucan@0.8.0/ + ) + + 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({ + ['ucan@0.9.1']: 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 block in capabiliy', async () => { const block = await getBlock({ test: 'inlineBlock' }) const ucan = await Delegation.delegate({ diff --git a/packages/interface/src/lib.ts b/packages/interface/src/lib.ts index 9d9a3950..12c840e6 100644 --- a/packages/interface/src/lib.ts +++ b/packages/interface/src/lib.ts @@ -246,6 +246,8 @@ export interface Delegation toJSON(): DelegationJSON delegate(): Await> + archive(): Await> + attach(block: Block): void }