From 8a578ae403f7270fc741f8aef07f1d3621fb29f9 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Mon, 13 Feb 2023 19:26:19 -0800 Subject: [PATCH] feat!: Use schema stuff in the capabilities instead of custom parsing (#220) * feat: .partial for schema with map representation * feat: switch capability to use schema * chore: remove dead code * add a comment * chore: cover remaining line * fix: remaining problems * chore: undo obsolete change * chore: remove some obsolete code --- packages/interface/src/capability.ts | 109 +++----- packages/server/src/api.ts | 6 +- packages/server/src/handler.js | 8 +- packages/server/test/server.spec.js | 9 +- packages/server/test/service/store.js | 9 +- packages/validator/src/capability.js | 264 ++++++++---------- packages/validator/src/lib.js | 36 +-- packages/validator/src/schema/schema.js | 24 +- packages/validator/src/schema/type.ts | 32 ++- .../validator/test/capability-access.spec.js | 8 +- packages/validator/test/capability.spec.js | 57 ++-- packages/validator/test/delegate.spec.js | 24 +- packages/validator/test/inference.spec.js | 58 ++-- packages/validator/test/lib.spec.js | 6 +- packages/validator/test/mailto.spec.js | 4 +- packages/validator/test/map-schema.spec.js | 47 ++++ packages/validator/test/util.js | 4 +- packages/validator/test/voucher.js | 10 +- 18 files changed, 386 insertions(+), 329 deletions(-) create mode 100644 packages/validator/test/map-schema.spec.js diff --git a/packages/interface/src/capability.ts b/packages/interface/src/capability.ts index bee368a1..be8fad31 100644 --- a/packages/interface/src/capability.ts +++ b/packages/interface/src/capability.ts @@ -12,13 +12,11 @@ import { Await, IssuedInvocationView, UCANOptions, - DIDKey, Verifier, - API, } from './lib.js' export interface Source { - capability: Capability + capability: { can: Ability; with: URI; nb?: Caveats } delegation: Delegation index: number } @@ -67,7 +65,7 @@ export interface Reader< } export interface Caveats { - [key: string]: Reader + [key: string]: unknown } export type MatchResult = Result @@ -80,17 +78,12 @@ export type InvalidCapability = UnknownCapability | MalformedCapability export interface DerivedMatch extends Match> {} -export interface DeriveSelector { - to: TheCapabilityParser> - derives: Derives, ToDeriveProof> -} - /** * Utility type is used to infer the type of the capability passed into - * `derives` handler. It simply makes all `nb` fileds optional because + * `derives` handler. It simply makes all `nb` fields optional because * in delegation all `nb` fields could be left out implying no restrictions. */ -export type ToDeriveClaim = +type ToDeriveClaim = | T | ParsedCapability> @@ -101,18 +94,18 @@ export type ToDeriveClaim = * all `nb` fields optional, because in delegation all `nb` fields could be * left out implying no restrictions. */ -export type ToDeriveProof = T extends ParsedCapability +export type InferDeriveProof = T extends ParsedCapability ? // If it a capability we just make `nb` partial - ToDeriveClaim + InferDelegatedCapability : // otherwise we need to map tuple - ToDeriveProofs + InferDeriveProofs /** * Another helper type which is equivalent of `ToDeriveClaim` except it works * on tuple of capabilities. */ -type ToDeriveProofs = T extends [infer U, ...infer E] - ? [ToDeriveClaim, ...ToDeriveProofs] +type InferDeriveProofs = T extends [infer U, ...infer E] + ? [ToDeriveClaim, ...InferDeriveProofs] : T extends never[] ? [] : never @@ -154,33 +147,27 @@ export interface View extends Matcher, Selector { * }) * ``` */ - derive( - options: DeriveSelector - ): TheCapabilityParser> + derive(options: { + to: TheCapabilityParser> + derives: Derives> + }): TheCapabilityParser> } -export type InferCaveatParams = keyof T extends never - ? never | undefined - : { - [K in keyof T]: T[K] extends { toJSON(): infer U } ? U : T[K] - } - export interface TheCapabilityParser> extends CapabilityParser { readonly can: M['value']['can'] create( input: InferCreateOptions - ): M['value'] + ): InferCapability /** * Creates an invocation of this capability. Function throws exception if * non-optional fields are omitted. */ - invoke( options: InferInvokeOptions - ): IssuedInvocationView + ): IssuedInvocationView> /** * Creates a delegation of this capability. Please note that all the @@ -189,12 +176,31 @@ export interface TheCapabilityParser> */ delegate( options: InferDelegationOptions - ): Promise]>> + ): Promise]>> } +/** + * When normalize capabilities by removing `nb` if it is a `{}`. This type + * does that normalization at the type level. + */ +export type InferCapability = + keyof T['nb'] extends never + ? { can: T['can']; with: T['with'] } + : { can: T['can']; with: T['with']; nb: T['nb'] } + +/** + * In delegation capability all the `nb` fields are optional. This type maps + * capability type (as it would be in the invocation) to the form it will be + * in the delegation. + */ +export type InferDelegatedCapability = + keyof T['nb'] extends never + ? { can: T['can']; with: T['with'] } + : { can: T['can']; with: T['with']; nb: Partial } + export type InferCreateOptions = // If capability has no NB we want to prevent passing it into - // .create funciton so we make `nb` as optional `never` type so + // .create function so we make `nb` as optional `never` type so // it can not be satisfied keyof C extends never ? { with: R; nb?: never } : { with: R; nb: C } @@ -213,22 +219,13 @@ export type InferDelegationOptions< } export type EmptyObject = { [key: string | number | symbol]: never } -type Optionalize = InferRequried & InferOptional - -type InferOptional = { - [K in keyof T as T[K] | undefined extends T[K] ? K : never]?: T[K] -} - -type InferRequried = { - [K in keyof T as T[K] | undefined extends T[K] ? never : K]: T[K] -} export interface CapabilityParser extends View { /** * Defines capability that is either `this` or the the given `other`. This * allows you to compose multiple capabilities into one so that you could * validate any of one of them without having to maintain list of supported - * capabilities. It is especially useful when dealiving with derived + * capabilities. It is especially useful when dealing with derived * capability chains when you might derive capability from either one or the * other. */ @@ -282,7 +279,7 @@ export interface CapabilitiesParser extends View> { /** * Creates new capability group containing capabilities from this group and - * provedid `other` capability. This method complements `and` method on + * provided `other` capability. This method complements `and` method on * `Capability` to allow chaining e.g. `read.and(write).and(modify)`. */ and(other: MatchSelector): CapabilitiesParser<[...M, W]> @@ -312,39 +309,21 @@ export type InferMatch = Members extends [] ? [M, ...InferMatch] : never -export type ParsedCapability< +export interface ParsedCapability< Can extends Ability = Ability, Resource extends URI = URI, - C extends object = {} -> = keyof C extends never - ? { can: Can; with: Resource; nb?: C } - : { can: Can; with: Resource; nb: C } - -export type InferCaveats = Optionalize<{ - [K in keyof C]: C[K] extends Reader ? T : never -}> - -export interface Descriptor< - A extends Ability, - R extends URI, - C extends Caveats + C extends Caveats = {} > { - can: A - with: Reader - - nb?: C - - derives?: Derives< - ToDeriveClaim>>, - ToDeriveClaim>> - > + can: Can + with: Resource + nb: C } export interface CapabilityMatch< A extends Ability, R extends URI, C extends Caveats -> extends DirectMatch>> {} +> extends DirectMatch> {} export interface CanIssue { /** diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index 40287dad..6b027ccf 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -1,5 +1,5 @@ import * as API from '@ucanto/interface' -import { InferCaveats, CanIssue, ParsedCapability } from '@ucanto/interface' +import { CanIssue, ParsedCapability } from '@ucanto/interface' export * from '@ucanto/interface' @@ -23,8 +23,8 @@ export interface ProviderContext< R extends API.URI = API.URI, C extends API.Caveats = API.Caveats > { - capability: API.ParsedCapability> - invocation: API.Invocation>> + capability: API.ParsedCapability + invocation: API.Invocation> context: API.InvocationContext } diff --git a/packages/server/src/handler.js b/packages/server/src/handler.js index 5f1bcbf3..b0a1d945 100644 --- a/packages/server/src/handler.js +++ b/packages/server/src/handler.js @@ -6,15 +6,15 @@ import { access } from '@ucanto/validator' * @template {API.URI} R * @template {API.Caveats} C * @template {unknown} U - * @param {API.CapabilityParser>>>} capability - * @param {(input:API.ProviderInput>>) => API.Await} handler - * @returns {API.ServiceMethod>, Exclude, Exclude>>} + * @param {API.CapabilityParser>>} capability + * @param {(input:API.ProviderInput>) => API.Await} handler + * @returns {API.ServiceMethod, Exclude, Exclude>>} */ export const provide = (capability, handler) => /** - * @param {API.Invocation>>} invocation + * @param {API.Invocation>} invocation * @param {API.InvocationContext} options */ async (invocation, options) => { diff --git a/packages/server/test/server.spec.js b/packages/server/test/server.spec.js index b2bd53cb..cf518dac 100644 --- a/packages/server/test/server.spec.js +++ b/packages/server/test/server.spec.js @@ -5,13 +5,14 @@ import * as CBOR from '@ucanto/transport/cbor' import { alice, bob, mallory, service as w3 } from './fixtures.js' import * as Service from '../../client/test/service.js' import { test, assert } from './test.js' +import { Schema } from '@ucanto/validator' const storeAdd = Server.capability({ can: 'store/add', with: Server.URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ link: Server.Link.match().optional(), - }, + }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { return new Server.Failure( @@ -34,9 +35,9 @@ const storeAdd = Server.capability({ const storeRemove = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ link: Server.Link.match().optional(), - }, + }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { return new Server.Failure( diff --git a/packages/server/test/service/store.js b/packages/server/test/service/store.js index 75f8c5d4..e67f0f15 100644 --- a/packages/server/test/service/store.js +++ b/packages/server/test/service/store.js @@ -4,13 +4,14 @@ import { provide } from '../../src/handler.js' import * as API from './api.js' import * as Access from './access.js' import { service as issuer } from '../fixtures.js' +import { Schema } from '@ucanto/validator/src/lib.js' const addCapability = Server.capability({ can: 'store/add', with: Server.URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ link: Server.Link.match().optional(), - }, + }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { return new Server.Failure( @@ -34,9 +35,9 @@ const addCapability = Server.capability({ const removeCapability = Server.capability({ can: 'store/remove', with: Server.URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ link: Server.Link.match().optional(), - }, + }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { return new Server.Failure( diff --git a/packages/validator/src/capability.js b/packages/validator/src/capability.js index b32009f6..b6c03182 100644 --- a/packages/validator/src/capability.js +++ b/packages/validator/src/capability.js @@ -8,15 +8,37 @@ import { Failure, } from './error.js' import { invoke, delegate } from '@ucanto/core' +import * as Schema from './schema.js' + +/** + * @template {API.Ability} A + * @template {API.URI} R + * @template {API.Caveats} C + * @typedef {{ + * can: A + * with: API.Reader + * nb?: Schema.MapRepresentation + * derives?: (claim: {can:A, with: R, nb: C}, proof:{can:A, with:R, nb:C}) => API.Result + * }} Descriptor + */ /** * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} [C={}] - * @param {API.Descriptor} descriptor + * @param {Descriptor} descriptor + * @returns {API.TheCapabilityParser>} */ -export const capability = descriptor => new Capability(descriptor) +export const capability = ({ + derives = defaultDerives, + nb = defaultNBSchema, + ...etc +}) => new Capability({ derives, nb, ...etc }) + +const defaultNBSchema = + /** @type {Schema.MapRepresentation} */ + (Schema.struct({})) /** * @template {API.Match} M @@ -37,7 +59,11 @@ export const and = (...selectors) => new And(selectors) /** * @template {API.Match} M * @template {API.ParsedCapability} T - * @param {API.DeriveSelector & { from: API.MatchSelector }} options + * @param {object} source + * @param {API.MatchSelector} source.from + * @param {API.TheCapabilityParser>} source.to + * @param {API.Derives>} source.derives + * @returns {API.TheCapabilityParser>} */ export const derive = ({ from, to, derives }) => new Derive(from, to, derives) @@ -65,7 +91,9 @@ class View { /** * @template {API.ParsedCapability} U - * @param {API.DeriveSelector} options + * @param {object} source + * @param {API.TheCapabilityParser>} source.to + * @param {API.Derives>} source.derives * @returns {API.TheCapabilityParser>} */ derive({ derives, to }) { @@ -102,25 +130,30 @@ class Unit extends View { * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} C - * @implements {API.TheCapabilityParser>>>} - * @extends {Unit>>>} + * @implements {API.TheCapabilityParser>>} + * @extends {Unit>>} */ class Capability extends Unit { /** - * @param {API.Descriptor} descriptor + * @param {Required>} descriptor */ constructor(descriptor) { super() - this.descriptor = { derives, ...descriptor } + this.descriptor = descriptor + this.schema = Schema.struct({ + can: Schema.literal(descriptor.can), + with: descriptor.with, + nb: descriptor.nb, + }) } /** - * @param {API.InferCreateOptions>} options + * @param {API.InferCreateOptions} options */ create(options) { const { descriptor, can } = this const decoders = descriptor.nb - const data = /** @type {API.InferCaveats} */ (options.nb || {}) + const data = /** @type {C} */ (options.nb || {}) const resource = descriptor.with.read(options.with) if (resource.error) { @@ -129,51 +162,36 @@ class Capability extends Unit { }) } - const capabality = - /** @type {API.ParsedCapability>} */ - ({ can, with: resource }) - - for (const [name, decoder] of Object.entries(decoders || {})) { - const key = /** @type {keyof data & string} */ (name) - const value = decoder.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) - } + const nb = descriptor.nb.read(data) + if (nb.error) { + throw Object.assign(new Error(`Invalid 'nb' - ${nb.message}`), { + cause: nb, + }) } - return capabality + return createCapability({ can, with: resource, nb }) } /** - * @param {API.InferInvokeOptions>} options + * @param {API.InferInvokeOptions} options */ invoke({ with: with_, nb, ...options }) { return invoke({ ...options, capability: this.create( - /** @type {API.InferCreateOptions>} */ + /** @type {API.InferCreateOptions} */ ({ with: with_, nb }) ), }) } /** - * @param {API.InferDelegationOptions>} options + * @param {API.InferDelegationOptions} options + * @returns {Promise>]>>} */ - async delegate({ with: with_, nb, ...options }) { + async delegate({ nb: input = {}, with: with_, ...options }) { const { descriptor, can } = this const readers = descriptor.nb - const data = /** @type {API.InferCaveats} */ (nb || {}) const resource = descriptor.with.read(with_) if (resource.error) { @@ -182,32 +200,15 @@ class Capability extends Unit { }) } - 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) - } + const nb = descriptor.nb.partial().read(input) + if (nb.error) { + throw Object.assign(new Error(`Invalid 'nb' - ${nb.message}`), { + cause: nb, + }) } - return await delegate({ - capabilities: [capabality], + return delegate({ + capabilities: [createCapability({ can, with: resource, nb })], ...options, }) } @@ -218,10 +219,10 @@ class Capability extends Unit { /** * @param {API.Source} source - * @returns {API.MatchResult>>>} + * @returns {API.MatchResult>>} */ match(source) { - const result = parseCapability(this, source) + const result = parseCapability(this.descriptor, source) return result.error ? result : new Match(source, result, this.descriptor) } toString() { @@ -229,6 +230,31 @@ class Capability extends Unit { } } +/** + * Normalizes capability by removing empty nb field. + * + * @template {API.ParsedCapability} T + * @param {T} source + */ + +const createCapability = ({ can, with: with_, nb }) => + /** @type {API.InferCapability} */ ({ + can, + with: with_, + ...(isEmpty(nb) ? {} : { nb }), + }) + +/** + * @param {object} object + * @returns {object is {}} + */ +const isEmpty = object => { + for (const _ in object) { + return false + } + return true +} + /** * @template {API.Match} M * @template {API.Match} W @@ -335,7 +361,7 @@ class Derive extends Unit { /** * @param {API.MatchSelector} from * @param {API.TheCapabilityParser>} to - * @param {API.Derives, API.ToDeriveProof>} derives + * @param {API.Derives>} derives */ constructor(from, to, derives) { super() @@ -386,18 +412,18 @@ class Derive extends Unit { * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} C - * @implements {API.DirectMatch>>} + * @implements {API.DirectMatch>} */ class Match { /** * @param {API.Source} source - * @param {API.ParsedCapability>} value - * @param {API.Descriptor} descriptor + * @param {API.ParsedCapability} value + * @param {Required>} descriptor */ constructor(source, value, descriptor) { this.source = [source] this.value = value - this.descriptor = { derives, ...descriptor } + this.descriptor = descriptor } get can() { return this.value.can @@ -413,7 +439,7 @@ class Match { /** * @param {API.CanIssue} context - * @returns {API.DirectMatch>>|null} + * @returns {API.DirectMatch>|null} */ prune(context) { if (context.canIssue(this.value, this.source[0].delegation.issuer.did())) { @@ -425,14 +451,14 @@ class Match { /** * @param {API.Source[]} capabilities - * @returns {API.Select>>>} + * @returns {API.Select>>} */ select(capabilities) { const unknown = [] const errors = [] const matches = [] for (const capability of capabilities) { - const result = resolveCapability(this, this.value, capability) + const result = resolveCapability(this.descriptor, this.value, capability) if (!result.error) { const claim = this.descriptor.derives(this.value, result) if (claim.error) { @@ -479,7 +505,7 @@ class DerivedMatch { /** * @param {API.DirectMatch} selected * @param {API.MatchSelector} from - * @param {API.Derives, API.ToDeriveProof>} derives + * @param {API.Derives>} derives */ constructor(selected, from, derives) { this.selected = selected @@ -703,37 +729,29 @@ const resolveResource = (source, uri, fallback) => { * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} C - * @param {{descriptor: API.Descriptor}} parser + * @param {Required>} descriptor * @param {API.Source} source - * @returns {API.Result>, API.InvalidCapability>} + * @returns {API.Result, API.InvalidCapability>} */ -const parseCapability = (parser, source) => { - const { can, with: withReader, nb: readers } = parser.descriptor +const parseCapability = (descriptor, source) => { const { delegation } = source - const capability = /** @type {API.Capability>} */ ( - source.capability - ) + const capability = /** @type {API.Capability} */ (source.capability) - if (can !== capability.can) { + if (descriptor.can !== capability.can) { return new UnknownCapability(capability) } - const uri = withReader.read(capability.with) + const uri = descriptor.with.read(capability.with) if (uri.error) { return new MalformedCapability(capability, uri) } - const nb = parseNB(capability, readers) + const nb = descriptor.nb.read(capability.nb || {}) if (nb.error) { - return nb + return new MalformedCapability(capability, nb) } - return new CapabilityView( - can, - uri, - /** @type {API.InferCaveats} */ (nb), - delegation - ) + return new CapabilityView(descriptor.can, uri, nb, delegation) } /** @@ -747,17 +765,13 @@ const parseCapability = (parser, source) => { * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} C - * @param {{descriptor: API.Descriptor}} parser - * @param {API.ParsedCapability>} claimed + * @param {Required>} descriptor + * @param {API.ParsedCapability} claimed * @param {API.Source} source - * @returns {API.Result>, API.InvalidCapability>} + * @returns {API.Result, API.InvalidCapability>} */ -const resolveCapability = ( - { descriptor: schema }, - claimed, - { capability, delegation } -) => { +const resolveCapability = (descriptor, claimed, { capability, delegation }) => { const can = resolveAbility(capability.can, claimed.can, null) if (can == null) { return new UnknownCapability(capability) @@ -768,59 +782,21 @@ const resolveCapability = ( claimed.with, capability.with ) - const uri = schema.with.read(resource) + const uri = descriptor.with.read(resource) if (uri.error) { return new MalformedCapability(capability, uri) } - const nb = parseNB(capability, schema.nb, { ...claimed.nb }) - if (nb.error) { - return nb - } - - return new CapabilityView( - can, - uri, - /** @type {API.InferCaveats} */ (nb), - delegation - ) -} + const nb = descriptor.nb.read({ + ...claimed.nb, + ...capability.nb, + }) -/** - * Parses `nb` field of the provided `capability` with given set of `readers`. - * If `implicit` argument is provided it will treat all fields as optional and - * fall back to an implicit field. If `implicit` is not provided it will fail - * if any non-optional field is missing. - * - * @template {API.Ability} A - * @template {API.URI} R - * @template {API.Caveats} C - * @param {API.Capability} capability - * @param {C|undefined} readers - * @param {Partial>} [implicit] - * @returns {API.Result, API.MalformedCapability>} - */ -const parseNB = (capability, readers, implicit) => { - const nb = /** @type {API.InferCaveats} */ ({}) - if (readers) { - /** @type {Partial>} */ - const caveats = capability.nb || {} - for (const [name, reader] of entries(readers)) { - const key = /** @type {keyof caveats & keyof nb & string} */ (name) - if (key in caveats || !implicit) { - const result = reader.read(caveats[key]) - if (result?.error) { - return new MalformedCapability(capability, result) - } else if (result != null) { - nb[key] = /** @type {any} */ (result) - } - } else if (key in implicit) { - nb[key] = /** @type {nb[key]} */ (implicit[key]) - } - } + if (nb.error) { + return new MalformedCapability(capability, nb) } - return nb + return new CapabilityView(can, uri, nb, delegation) } /** @@ -832,7 +808,7 @@ class CapabilityView { /** * @param {A} can * @param {R} with_ - * @param {API.InferCaveats} nb + * @param {C} nb * @param {API.Delegation} delegation */ constructor(can, with_, nb, delegation) { @@ -913,7 +889,7 @@ const selectGroup = (self, capabilities) => { * @param {U} delegated * @return {API.Result} */ -const derives = (claimed, delegated) => { +const defaultDerives = (claimed, delegated) => { if (delegated.with.endsWith('*')) { if (!claimed.with.startsWith(delegated.with.slice(0, -1))) { return new Failure( diff --git a/packages/validator/src/lib.js b/packages/validator/src/lib.js index 1c09eaab..f261a89f 100644 --- a/packages/validator/src/lib.js +++ b/packages/validator/src/lib.js @@ -130,11 +130,13 @@ const resolveSources = async ({ delegation }, config) => { // otherwise create source objects for it's capabilities, so we could // track which proof in which capability the are from. for (const capability of proof.capabilities) { - sources.push({ - capability, - delegation: proof, - index, - }) + sources.push( + /** @type {API.Source} */ ({ + capability, + delegation: proof, + index, + }) + ) } } } @@ -160,9 +162,9 @@ const isSelfIssued = (capability, issuer) => capability.with === issuer * @template {API.URI} R * @template {R} URI * @template {API.Caveats} C - * @param {API.Invocation>>} invocation - * @param {API.ValidationOptions>>} options - * @returns {Promise>>, API.Unauthorized>>} + * @param {API.Invocation>} invocation + * @param {API.ValidationOptions>} options + * @returns {Promise>, API.Unauthorized>>} */ export const access = async (invocation, { capability, ...config }) => claim(capability, [invocation], config) @@ -176,10 +178,10 @@ export const access = async (invocation, { capability, ...config }) => * @template {API.Ability} A * @template {API.URI} R * @template {API.Caveats} C - * @param {API.CapabilityParser>>>} capability + * @param {API.CapabilityParser>>} capability * @param {API.Proof[]} proofs * @param {API.ClaimOptions} config - * @returns {Promise>>, API.Unauthorized>>} + * @returns {Promise>, API.Unauthorized>>} */ export const claim = async ( capability, @@ -210,11 +212,13 @@ export const claim = async ( if (!delegation.error) { for (const [index, capability] of delegation.capabilities.entries()) { - sources.push({ - capability, - delegation, - index, - }) + sources.push( + /** @type {API.Source} */ ({ + capability, + delegation, + index, + }) + ) } } else { invalidProofs.push(delegation) @@ -518,7 +522,7 @@ const resolveDIDFromProofs = async (did, delegation, config) => { to: capability({ with: Schema.literal(config.authority.did()), can: './update', - nb: { key: DID.match({ method: 'key' }) }, + nb: Schema.struct({ key: DID.match({ method: 'key' }) }), }), derives: equalWith, }) diff --git a/packages/validator/src/schema/schema.js b/packages/validator/src/schema/schema.js index e9bbfa93..7f07712e 100644 --- a/packages/validator/src/schema/schema.js +++ b/packages/validator/src/schema/schema.js @@ -435,7 +435,10 @@ class Dictionary extends API { return memberError({ at: k, cause: valueResult }) } - dict[keyResult] = valueResult + // skip undefined because they mess up CBOR and are generally useless. + if (valueResult !== undefined) { + dict[keyResult] = valueResult + } } return dict @@ -446,6 +449,14 @@ class Dictionary extends API { get value() { return this.settings.value } + + partial() { + const { key, value } = this.settings + return new Dictionary({ + key, + value: optional(value), + }) + } toString() { return `dictionary(${this.settings})` } @@ -1071,6 +1082,17 @@ class Struct extends API { return struct } + /** + * @returns {Schema.MapRepresentation>> & Schema.StructSchema} + */ + partial() { + return new Struct( + Object.fromEntries( + Object.entries(this.shape).map(([key, value]) => [key, optional(value)]) + ) + ) + } + /** @type {U} */ get shape() { // @ts-ignore - We declared `settings` private but we access it here diff --git a/packages/validator/src/schema/type.ts b/packages/validator/src/schema/type.ts index 412dfa29..d26058fb 100644 --- a/packages/validator/src/schema/type.ts +++ b/packages/validator/src/schema/type.ts @@ -44,10 +44,28 @@ export interface ArraySchema extends Schema { element: Reader } +/** + * In IPLD Schema types may have different [representation]s, this interface + * represents all the types that have a map representation and defines + * extensions relevant to such types. + * [representation]: https://ipld.io/docs/schemas/features/representation-strategies/ + */ +export interface MapRepresentation< + V extends Record, + I = unknown +> extends Schema { + /** + * Returns equivalent schema in which all of the fields are optional. + */ + partial(): MapRepresentation, I> +} + export interface DictionarySchema - extends Schema, I> { + extends MapRepresentation, I> { key: Reader value: Reader + + partial(): DictionarySchema } export type Dictionary< @@ -78,15 +96,25 @@ export interface NumberSchema< export interface StructSchema< U extends { [key: string]: Reader } = {}, I extends unknown = unknown -> extends Schema, I> { +> extends MapRepresentation, I> { shape: U create(input: MarkEmptyOptional>): InferStruct extend( extension: E ): StructSchema + + partial(): MapRepresentation>, I> & StructSchema +} + +export type InferOptionalStructShape = { + [K in keyof U]: InferOptionalReader } +export type InferOptionalReader = R extends Reader + ? Reader + : R + export interface StringSchema extends Schema { startsWith( diff --git a/packages/validator/test/capability-access.spec.js b/packages/validator/test/capability-access.spec.js index 7e324c77..baa37371 100644 --- a/packages/validator/test/capability-access.spec.js +++ b/packages/validator/test/capability-access.spec.js @@ -14,10 +14,10 @@ const capabilities = { add: capability({ can: 'store/add', with: DID, - nb: { + nb: Schema.struct({ link: Link, size: Schema.integer().optional(), - }, + }), derives: (claim, proof) => { if (claim.with !== proof.with) { return new Failure('with field does not match') @@ -38,9 +38,9 @@ const capabilities = { ping: capability({ can: 'dev/ping', with: DID, - nb: { + nb: Schema.struct({ message: Schema.string(), - }, + }), }), }, } diff --git a/packages/validator/test/capability.spec.js b/packages/validator/test/capability.spec.js index d5e69cdc..7d886d09 100644 --- a/packages/validator/test/capability.spec.js +++ b/packages/validator/test/capability.spec.js @@ -797,9 +797,9 @@ test('parse with nb', () => { const storeAdd = capability({ can: 'store/add', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ link: Link.match().optional(), - }, + }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { return new Failure( @@ -842,7 +842,10 @@ test('parse with nb', () => { nb: { link: 5 }, }, cause: { - message: 'Expected link to be a CID instead of 5', + name: 'FieldError', + cause: { + message: 'Expected link to be a CID instead of 5', + }, }, }, ], @@ -1201,9 +1204,9 @@ test('capability create with nb', () => { const echo = capability({ can: 'test/echo', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), - }, + }), }) assert.throws(() => { @@ -1222,7 +1225,7 @@ test('capability create with nb', () => { echo.create({ with: alice.did(), }) - }, /Invalid 'nb.message' - Expected URI but got undefined/) + }, /Expected URI but got undefined/) assert.throws(() => { echo.create({ @@ -1232,7 +1235,7 @@ test('capability create with nb', () => { message: 'echo:foo', }, }) - }, /Invalid 'nb.message' - Expected data: URI instead got echo:foo/) + }, /Expected data: URI instead got echo:foo/) assert.deepEqual( echo.create({ with: alice.did(), nb: { message: 'data:hello' } }), @@ -1421,9 +1424,9 @@ test('invoke capability (with nb)', () => { const echo = capability({ can: 'test/echo', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), - }, + }), }) assert.throws(() => { @@ -1445,7 +1448,7 @@ test('invoke capability (with nb)', () => { audience: w3, with: alice.did(), }) - }, /Invalid 'nb.message' - Expected URI but got undefined/) + }, /Expected URI but got undefined/) assert.throws(() => { echo.create({ @@ -1455,7 +1458,7 @@ test('invoke capability (with nb)', () => { message: 'echo:foo', }, }) - }, /Invalid 'nb.message' - Expected data: URI instead got echo:foo/) + }, /Expected data: URI instead got echo:foo/) assert.deepEqual( echo.invoke({ @@ -1503,10 +1506,10 @@ test('capability with optional caveats', async () => { const Echo = capability({ can: 'test/echo', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), meta: Link.match().optional(), - }, + }), }) const echo = await Echo.invoke({ @@ -1619,17 +1622,17 @@ test('.and(...).match', () => { const A = capability({ can: 'test/ab', with: URI, - nb: { + nb: Schema.struct({ a: Schema.Text, - }, + }), }) const B = capability({ can: 'test/ab', with: URI, - nb: { + nb: Schema.struct({ b: Schema.Text, - }, + }), }) const AB = A.and(B) @@ -1731,17 +1734,17 @@ test('and with diff nb', () => { const A = capability({ can: 'test/me', with: URI, - nb: { + nb: Schema.struct({ a: Schema.Text, - }, + }), }) const B = capability({ can: 'test/me', with: URI, - nb: { + nb: Schema.struct({ b: Schema.Text, - }, + }), }) const AB = A.and(B) @@ -1793,8 +1796,6 @@ test('derived capability DSL', () => { b.with === a.with ? true : new Failure(`with don't match`), }) - assert.equal(AA.can, 'derive/a') - assert.deepEqual( AA.create({ with: 'data:a', @@ -1855,9 +1856,9 @@ test('capability match', () => { const echo = capability({ can: 'test/echo', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), - }, + }), }) const m3 = echo.match( @@ -1906,6 +1907,8 @@ test('derived capability match & select', () => { b.with === a.with ? true : new Failure(`with don't match`), }) + assert.equal(AA.can, 'derive/a') + const proof = { issuer: alice, fake: { thing: 'thing' }, @@ -2021,9 +2024,9 @@ test('default derive with nb', () => { const Profile = capability({ can: 'profile/set', with: Schema.URI.match({ protocol: 'file:' }), - nb: { + nb: Schema.struct({ mime: Schema.Text, - }, + }), }) const pic = Profile.match( diff --git a/packages/validator/test/delegate.spec.js b/packages/validator/test/delegate.spec.js index 16ff551e..518cfa46 100644 --- a/packages/validator/test/delegate.spec.js +++ b/packages/validator/test/delegate.spec.js @@ -10,9 +10,9 @@ import { alice, bob, mallory, service as w3 } from './fixtures.js' const echo = capability({ can: 'test/echo', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), - }, + }), }) const expiration = UCAN.now() + 100 @@ -48,6 +48,13 @@ test('delegate can omit constraints', async () => { }) test('delegate can specify constraints', async () => { + const t1 = await echo.delegate({ + with: alice.did(), + issuer: alice, + audience: w3, + expiration, + }) + assert.deepEqual( await echo.delegate({ with: alice.did(), @@ -172,10 +179,7 @@ test('errors on invalid nb', async () => { }) assert.fail('must fail') } catch (error) { - assert.match( - String(error), - /Invalid 'nb.message' - Expected data: URI instead got echo:foo/ - ) + assert.match(String(error), /Expected data: URI instead got echo:foo/) } }) @@ -183,10 +187,10 @@ test('capability with optional caveats', async () => { const Echo = capability({ can: 'test/echo', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ message: URI.match({ protocol: 'data:' }), meta: Link.match().optional(), - }, + }), }) const echo = await Echo.delegate({ @@ -241,9 +245,9 @@ const nbchild = parent.derive({ to: capability({ can: 'test/child', with: Schema.DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ limit: Schema.integer(), - }, + }), }), derives: (b, a) => b.with === a.with ? true : new Failure(`with don't match`), diff --git a/packages/validator/test/inference.spec.js b/packages/validator/test/inference.spec.js index df23a0fb..f7c28655 100644 --- a/packages/validator/test/inference.spec.js +++ b/packages/validator/test/inference.spec.js @@ -4,7 +4,7 @@ import { alice, bob, mallory, service as w3 } from './fixtures.js' import { capability, URI, Link, DID, Failure, Schema } from '../src/lib.js' import * as API from './types.js' -test('execute capabilty', () => +test('execute capability', () => /** * @param {API.ConnectionView} connection */ @@ -98,19 +98,17 @@ test('infers nb fields optional', () => { capability({ can: 'test/nb', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ msg: URI.match({ protocol: 'data:' }), - }, + }), derives: (claim, proof) => { /** @type {string} */ - // @ts-expect-error - may be undefined const _1 = claim.nb.msg /** @type {API.URI<"data:">|undefined} */ const _2 = claim.nb.msg /** @type {string} */ - // @ts-expect-error - may be undefined const _3 = proof.nb.msg /** @type {API.URI<"data:">|undefined} */ @@ -125,25 +123,22 @@ test('infers nb fields in derived capability', () => { capability({ can: 'test/base', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ msg: URI.match({ protocol: 'data:' }), - }, + }), }).derive({ to: capability({ can: 'test/derived', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ bar: URI.match({ protocol: 'data:' }), - }, + }), }), derives: (claim, proof) => { /** @type {string} */ - // @ts-expect-error - may be undefined + /** @type {API.URI<"data:">} */ const _1 = claim.nb.bar - /** @type {API.URI<"data:">|undefined} */ - const _2 = claim.nb.bar - /** @type {string} */ // @ts-expect-error - may be undefined const _3 = proof.nb.msg @@ -160,33 +155,29 @@ test('infers nb fields in derived capability', () => { const A = capability({ can: 'test/a', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ a: URI.match({ protocol: 'data:' }), - }, + }), }) const B = capability({ can: 'test/b', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ b: URI.match({ protocol: 'data:' }), - }, + }), }) A.and(B).derive({ to: capability({ can: 'test/c', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ c: URI.match({ protocol: 'data:' }), - }, + }), }), derives: (claim, [a, b]) => { - /** @type {string} */ - // @ts-expect-error - may be undefined - const _1 = claim.nb.c - - /** @type {API.URI<"data:">|undefined} */ + /** @type {API.URI<"data:">} */ const _2 = claim.nb.c /** @type {string} */ @@ -211,9 +202,9 @@ test('infers nb fields in derived capability', () => { const A = capability({ can: 'test/a', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ a: URI.match({ protocol: 'data:' }), - }, + }), }) const a = await A.delegate({ @@ -248,29 +239,30 @@ test('can create derived capability with dict schema in nb', () => { * @param {{ with: string }} claim * @param {{ with: string }} proof */ - const equalWith = (claim, proof) => claim.with === proof.with || new Failure(`claim.with is not proven`); + const equalWith = (claim, proof) => + claim.with === proof.with || new Failure(`claim.with is not proven`) const top = capability({ can: '*', with: URI.match({ protocol: 'did:' }), derives: equalWith, - }); + }) const delegate = top.derive({ to: capability({ can: 'access/delegate', with: URI, - nb: { + nb: Schema.struct({ delegations: Schema.dictionary({ value: Schema.Link.match(), }), - }, + }), derives: (claim, proof) => { // the motivation for this test was that tsc would previously complain at these assignments // and the only workaround was a type assertion https://github.com/web3-storage/w3protocol/pull/420/commits/4f1f2931cecff1d1d1d29e889c4fdfb63ff3b327#diff-e434cc6c1a699df311a0b2faed199a2ff6b6b291d30f95e20b2ea5abfa7da3d9R125 /** @type {Schema.Dictionary|undefined} */ - const claimDelegations = claim.nb.delegations; + const claimDelegations = claim.nb.delegations /** @type {Schema.Dictionary|undefined} */ - const proofDelegations = proof.nb.delegations; - return equalWith(claim, proof); + const proofDelegations = proof.nb.delegations + return equalWith(claim, proof) }, }), derives: equalWith, diff --git a/packages/validator/test/lib.spec.js b/packages/validator/test/lib.spec.js index 3fd741d3..9f239cf1 100644 --- a/packages/validator/test/lib.spec.js +++ b/packages/validator/test/lib.spec.js @@ -1,5 +1,5 @@ import { test, assert } from './test.js' -import { access, claim } from '../src/lib.js' +import { access, claim, Schema } from '../src/lib.js' import { capability, URI, Link } from '../src/lib.js' import { Failure } from '../src/error.js' import { Verifier } from '@ucanto/principal' @@ -12,10 +12,10 @@ import { UnavailableProof } from '../src/error.js' const storeAdd = capability({ can: 'store/add', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ link: Link, origin: Link.optional(), - }, + }), derives: (claimed, delegated) => { if (claimed.with !== delegated.with) { return new Failure( diff --git a/packages/validator/test/mailto.spec.js b/packages/validator/test/mailto.spec.js index 2a9f22e9..34dade05 100644 --- a/packages/validator/test/mailto.spec.js +++ b/packages/validator/test/mailto.spec.js @@ -17,9 +17,9 @@ const claim = capability({ const update = capability({ can: './update', with: DID, - nb: { + nb: Schema.struct({ key: DID.match({ method: 'key' }), - }, + }), }) test('validate mailto', async () => { diff --git a/packages/validator/test/map-schema.spec.js b/packages/validator/test/map-schema.spec.js new file mode 100644 index 00000000..00aa8b80 --- /dev/null +++ b/packages/validator/test/map-schema.spec.js @@ -0,0 +1,47 @@ +import * as Schema from '../src/schema.js' +import { test, assert } from './test.js' + +test('.partial on structs', () => { + const Point = Schema.struct({ + x: Schema.integer(), + y: Schema.integer(), + }) + + const PartialPoint = Point.partial() + + assert.deepEqual(PartialPoint.shape, { + x: Schema.integer().optional(), + y: Schema.integer().optional(), + }) + + assert.deepEqual(PartialPoint.from({}), {}) + assert.deepEqual(PartialPoint.from({ x: 1 }), { x: 1 }) + assert.deepEqual(PartialPoint.from({ x: 1, y: 2 }), { x: 1, y: 2 }) + assert.deepEqual(PartialPoint.from({ y: 2 }), { y: 2 }) + assert.deepEqual(PartialPoint.from({ x: undefined }), {}) + assert.deepEqual(PartialPoint.from({ x: undefined, y: 2 }), { y: 2 }) + + assert.throws( + () => + // @ts-expect-error - should complain about no argument + Point.create(), + /invalid field/ + ) + + assert.deepEqual(PartialPoint.create(), {}) +}) + +test('.partial on dicts', () => { + const Ints = Schema.dictionary({ value: Schema.integer() }) + const IntsMaybe = Ints.partial() + + assert.equal(IntsMaybe.key, Ints.key) + assert.deepEqual(IntsMaybe.value, Schema.integer().optional()) + + assert.deepEqual(IntsMaybe.from({}), {}) + assert.deepEqual(IntsMaybe.from({ x: 1 }), { x: 1 }) + assert.deepEqual(IntsMaybe.from({ x: 1, y: 2 }), { x: 1, y: 2 }) + assert.deepEqual(IntsMaybe.from({ y: 2 }), { y: 2 }) + assert.deepEqual(IntsMaybe.from({ x: undefined }), {}) + assert.deepEqual(IntsMaybe.from({ x: undefined, y: 2 }), { y: 2 }) +}) diff --git a/packages/validator/test/util.js b/packages/validator/test/util.js index b7874a71..95480e7f 100644 --- a/packages/validator/test/util.js +++ b/packages/validator/test/util.js @@ -47,8 +47,8 @@ export const canDelegateLink = (child, parent) => { * Checks that `with` on claimed capability is the same as `with` * in delegated capability. Note this will ignore `can` field. * - * @param {API.ParsedCapability} child - * @param {API.ParsedCapability} parent + * @param {{can: API.Ability, with: string}} child + * @param {{can: API.Ability, with: string}} parent */ export function equalWith(child, parent) { return ( diff --git a/packages/validator/test/voucher.js b/packages/validator/test/voucher.js index e0228b26..8f1af9e2 100644 --- a/packages/validator/test/voucher.js +++ b/packages/validator/test/voucher.js @@ -1,5 +1,5 @@ import { equalWith, canDelegateURI, canDelegateLink, fail } from './util.js' -import { capability, URI, Text, Link, DID } from '../src/lib.js' +import { capability, URI, Text, Link, DID, Schema } from '../src/lib.js' export const Voucher = capability({ can: 'voucher/*', @@ -10,11 +10,11 @@ export const Claim = Voucher.derive({ to: capability({ can: 'voucher/claim', with: DID.match({ method: 'key' }), - nb: { + nb: Schema.struct({ product: Link, identity: URI.match({ protocol: 'mailto:' }), service: DID, - }, + }), derives: (child, parent) => { return ( fail(equalWith(child, parent)) || @@ -31,9 +31,9 @@ export const Claim = Voucher.derive({ export const Redeem = capability({ can: 'voucher/redeem', with: URI.match({ protocol: 'did:' }), - nb: { + nb: Schema.struct({ product: Text, identity: Text, account: URI.match({ protocol: 'did:' }), - }, + }), })