diff --git a/packages/interface/src/capability.ts b/packages/interface/src/capability.ts index d9a655ad..2f2dba50 100644 --- a/packages/interface/src/capability.ts +++ b/packages/interface/src/capability.ts @@ -136,9 +136,23 @@ export interface TheCapabilityParser> input: InferCreateOptions ): M['value'] + /** + * Creates an invocation of this capability. Function throws exception if + * non-optional fields are omitted. + */ + invoke( options: InferInvokeOptions ): IssuedInvocationView + + /** + * Creates a delegation of this capability. Please note that all the + * `nb` fields are optional in delegation and only provided ones will + * be validated. + */ + delegate( + options: InferDelegationOptions + ): Promise> } export type InferCreateOptions = @@ -152,6 +166,15 @@ export type InferInvokeOptions< C extends {} | undefined > = UCANOptions & { issuer: Signer } & InferCreateOptions +export type InferDelegationOptions< + R extends Resource, + C extends {} | undefined +> = UCANOptions & { + issuer: Signer + with: R + nb?: Partial['nb']> +} + export type EmptyObject = { [key: string | number | symbol]: never } type Optionalize = InferRequried & InferOptional diff --git a/packages/validator/src/capability.js b/packages/validator/src/capability.js index 7bdb70d6..070f8024 100644 --- a/packages/validator/src/capability.js +++ b/packages/validator/src/capability.js @@ -7,7 +7,7 @@ import { DelegationError as MatchError, Failure, } from './error.js' -import { invoke } from '@ucanto/core' +import { invoke, delegate } from '@ucanto/core' /** * @template {API.Ability} A @@ -167,6 +167,51 @@ class Capability extends Unit { }) } + /** + * @param {API.InferDelegationOptions>} options + */ + async delegate({ with: with_, nb, ...options }) { + const { descriptor, can } = this + const readers = descriptor.nb + const data = /** @type {API.InferCaveats} */ (nb || {}) + + const resource = descriptor.with.read(with_) + if (resource.error) { + throw Object.assign(new Error(`Invalid 'with' - ${resource.message}`), { + cause: resource, + }) + } + + const capabality = + /** @type {API.ParsedCapability>} */ + ({ can, with: resource }) + + for (const [name, reader] of Object.entries(readers || {})) { + const key = /** @type {keyof data & string} */ (name) + const source = data[key] + // omit undefined fields in the delegation + const value = source === undefined ? source : reader.read(data[key]) + if (value?.error) { + throw Object.assign( + new Error(`Invalid 'nb.${key}' - ${value.message}`), + { cause: value } + ) + } else if (value !== undefined) { + const nb = + capabality.nb || + (capabality.nb = /** @type {API.InferCaveats} */ ({})) + + const key = /** @type {keyof nb} */ (name) + nb[key] = /** @type {typeof nb[key]} */ (value) + } + } + + return await delegate({ + capabilities: [capabality], + ...options, + }) + } + get can() { return this.descriptor.can } @@ -311,6 +356,12 @@ class Derive extends Unit { invoke(options) { return this.to.invoke(options) } + /** + * @type {typeof this.to['delegate']} + */ + delegate(options) { + return this.to.delegate(options) + } get can() { return this.to.can } diff --git a/packages/validator/test/delegate.spec.js b/packages/validator/test/delegate.spec.js new file mode 100644 index 00000000..f2c0c41e --- /dev/null +++ b/packages/validator/test/delegate.spec.js @@ -0,0 +1,309 @@ +import { capability, DID, URI, Link, unknown, Schema } from '../src/lib.js' +import { invoke, parseLink, delegate } from '@ucanto/core' +import * as API from '@ucanto/interface' +import { Failure } from '../src/error.js' +import { the } from '../src/util.js' +import { CID } from 'multiformats' +import { test, assert } from './test.js' +import { alice, bob, mallory, service as w3 } from './fixtures.js' + +const echo = capability({ + can: 'test/echo', + with: DID.match({ method: 'key' }), + nb: { + message: URI.match({ protocol: 'data:' }), + }, +}) + +test('delegate can omit constraints', async () => { + assert.deepEqual( + await echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + message: 'data:1', + }, + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'test/echo', + nb: { + message: 'data:1', + }, + }, + ], + }) + ) +}) + +test('delegate can specify constraints', async () => { + assert.deepEqual( + await echo.delegate({ + with: alice.did(), + issuer: alice, + audience: w3, + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'test/echo', + }, + ], + }) + ) +}) + +test('delegate fails on wrong nb', async () => { + try { + await echo.delegate({ + issuer: alice, + audience: w3, + // @ts-expect-error - not assignable to did: + with: 'file://gozala/path', + }) + assert.fail('must fail') + } catch (error) { + assert.match( + String(error), + /Invalid 'with' - Expected a did:key: but got "file:/ + ) + } +}) + +test('omits unknown nb fields', async () => { + assert.deepEqual( + await echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + // @ts-expect-error - no x expected + x: 1, + }, + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'test/echo', + }, + ], + }) + ) +}) + +test('can pass undefined nb', async () => { + assert.deepEqual( + await echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: undefined, + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'test/echo', + }, + ], + }) + ) +}) + +test('can pass empty nb', async () => { + assert.deepEqual( + await echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: {}, + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + with: alice.did(), + can: 'test/echo', + }, + ], + }) + ) +}) + +test('errors on invalid nb', async () => { + try { + await echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + // @ts-expect-error - not a data URI + message: 'echo:foo', + }, + }) + assert.fail('must fail') + } catch (error) { + assert.match( + String(error), + /Invalid 'nb.message' - Expected data: URI instead got echo:foo/ + ) + } +}) + +test('capability with optional caveats', async () => { + const Echo = capability({ + can: 'test/echo', + with: URI.match({ protocol: 'did:' }), + nb: { + message: URI.match({ protocol: 'data:' }), + meta: Link.match().optional(), + }, + }) + + const echo = await Echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + message: 'data:hello', + }, + }) + + assert.deepEqual(echo.capabilities, [ + { + can: 'test/echo', + with: alice.did(), + nb: { + message: 'data:hello', + }, + }, + ]) + + const link = parseLink('bafkqaaa') + const out = await Echo.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + message: 'data:hello', + meta: link, + }, + }) + + assert.deepEqual(out.capabilities, [ + { + can: 'test/echo', + with: alice.did(), + nb: { + message: 'data:hello', + meta: link, + }, + }, + ]) +}) + +const parent = capability({ + can: 'test/parent', + with: Schema.DID.match({ method: 'key' }), +}) + +const nbchild = parent.derive({ + to: capability({ + can: 'test/child', + with: Schema.DID.match({ method: 'key' }), + nb: { + limit: Schema.integer(), + }, + }), + derives: (b, a) => + b.with === a.with ? true : new Failure(`with don't match`), +}) + +const child = parent.derive({ + to: capability({ + can: 'test/child', + with: Schema.DID.match({ method: 'key' }), + }), + derives: (b, a) => + b.with === a.with ? true : new Failure(`with don't match`), +}) + +test('delegate derived capability', async () => { + assert.deepEqual( + await child.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'test/child', + with: alice.did(), + }, + ], + }) + ) +}) + +test('delegate derived capability omitting nb', async () => { + assert.deepEqual( + await nbchild.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'test/child', + with: alice.did(), + }, + ], + }) + ) +}) + +test('delegate derived capability with nb', async () => { + assert.deepEqual( + await nbchild.delegate({ + issuer: alice, + audience: w3, + with: alice.did(), + nb: { + limit: 5, + }, + }), + await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'test/child', + with: alice.did(), + nb: { + limit: 5, + }, + }, + ], + }) + ) +}) diff --git a/packages/validator/test/lib.spec.js b/packages/validator/test/lib.spec.js index 87f72676..dab031a4 100644 --- a/packages/validator/test/lib.spec.js +++ b/packages/validator/test/lib.spec.js @@ -13,7 +13,7 @@ const storeAdd = capability({ can: 'store/add', with: URI.match({ protocol: 'did:' }), nb: { - link: Link.optional(), + link: Link.match().optional(), }, derives: (claimed, delegated) => { if (claimed.with !== delegated.with) {