@ucanto/validator
Version:
UCAN RPC validators
928 lines (843 loc) • 22.7 kB
JavaScript
import * as API from '@ucanto/interface'
import { entries, combine, intersection } from './util.js'
import {
EscalatedCapability,
MalformedCapability,
UnknownCapability,
DelegationError as MatchError,
} from './error.js'
import { invoke, delegate, Schema } from '@ucanto/core'
/**
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @typedef {{
* can: A
* with: API.Reader<R, API.Resource, API.Failure>
* nb?: Schema.MapRepresentation<C, unknown>
* derives?: (claim: {can:A, with: R, nb: C}, proof:{can:A, with:R, nb:C}) => API.Result<{}, API.Failure>
* }} Descriptor
*/
/**
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} [C={}]
* @param {Descriptor<A, R, C>} descriptor
* @returns {API.TheCapabilityParser<API.CapabilityMatch<A, R, C>>}
*/
export const capability = ({
derives = defaultDerives,
nb = defaultNBSchema,
...etc
}) => new Capability({ derives, nb, ...etc })
const defaultNBSchema =
/** @type {Schema.MapRepresentation<any>} */
(Schema.struct({}))
/**
* @template {API.Match} M
* @template {API.Match} W
* @param {API.Matcher<M>} left
* @param {API.Matcher<W>} right
* @returns {API.CapabilityParser<M|W>}
*/
export const or = (left, right) => new Or(left, right)
/**
* @template {API.MatchSelector<API.Match>[]} Selectors
* @param {Selectors} selectors
* @returns {API.CapabilitiesParser<API.InferMembers<Selectors>>}
*/
export const and = (...selectors) => new And(selectors)
/**
* @template {API.Match} M
* @template {API.ParsedCapability} T
* @param {object} source
* @param {API.MatchSelector<M>} source.from
* @param {API.TheCapabilityParser<API.DirectMatch<T>>} source.to
* @param {API.Derives<T, API.InferDeriveProof<M['value']>>} source.derives
* @returns {API.TheCapabilityParser<API.DerivedMatch<T, M>>}
*/
export const derive = ({ from, to, derives }) => new Derive(from, to, derives)
/**
* @template {API.Match} M
* @implements {API.View<M>}
*/
class View {
/**
* @param {API.Source} source
* @returns {API.MatchResult<M>}
*/
/* c8 ignore next 3 */
match(source) {
return { error: new UnknownCapability(source.capability) }
}
/**
* @param {API.Source[]} capabilities
* @returns {API.Select<M>}
*/
select(capabilities) {
return select(this, capabilities)
}
/**
* @template {API.ParsedCapability} U
* @param {object} source
* @param {API.TheCapabilityParser<API.DirectMatch<U>>} source.to
* @param {API.Derives<U, API.InferDeriveProof<M['value']>>} source.derives
* @returns {API.TheCapabilityParser<API.DerivedMatch<U, M>>}
*/
derive({ derives, to }) {
return derive({ derives, to, from: this })
}
}
/**
* @template {API.Match} M
* @implements {API.CapabilityParser<M>}
* @extends {View<M>}
*/
class Unit extends View {
/**
* @template {API.Match} W
* @param {API.MatchSelector<W>} other
* @returns {API.CapabilityParser<M | W>}
*/
or(other) {
return or(this, other)
}
/**
* @template {API.Match} W
* @param {API.CapabilityParser<W>} other
* @returns {API.CapabilitiesParser<[M, W]>}
*/
and(other) {
return and(/** @type {API.CapabilityParser<M>} */ (this), other)
}
}
/**
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @implements {API.TheCapabilityParser<API.DirectMatch<API.ParsedCapability<A, R, C>>>}
* @extends {Unit<API.DirectMatch<API.ParsedCapability<A, R, C>>>}
*/
class Capability extends Unit {
/**
* @param {Required<Descriptor<A, R, C>>} descriptor
*/
constructor(descriptor) {
super()
this.descriptor = descriptor
this.schema = Schema.struct({
can: Schema.literal(descriptor.can),
with: descriptor.with,
nb: descriptor.nb,
})
}
/**
* @param {API.InferCreateOptions<R, C>} options
*/
create(options) {
const { descriptor, can } = this
const decoders = descriptor.nb
const data = /** @type {C} */ (options.nb || {})
const resource = descriptor.with.read(options.with)
if (resource.error) {
throw Object.assign(
new Error(`Invalid 'with' - ${resource.error.message}`),
{
cause: resource,
}
)
}
const nb = descriptor.nb.read(data)
if (nb.error) {
throw Object.assign(new Error(`Invalid 'nb' - ${nb.error.message}`), {
cause: nb,
})
}
return createCapability({ can, with: resource.ok, nb: nb.ok })
}
/**
* @param {API.InferInvokeOptions<R, C>} options
*/
invoke({ with: with_, nb, ...options }) {
return invoke({
...options,
capability: this.create(
/** @type {API.InferCreateOptions<R, C>} */
({ with: with_, nb })
),
})
}
/**
* @param {API.InferDelegationOptions<R, C>} options
* @returns {Promise<API.Delegation<[API.InferDelegatedCapability<API.ParsedCapability<A, R, C>>]>>}
*/
async delegate({ nb: input = {}, with: with_, ...options }) {
const { descriptor, can } = this
const readers = descriptor.nb
const resource = descriptor.with.read(with_)
if (resource.error) {
throw Object.assign(
new Error(`Invalid 'with' - ${resource.error.message}`),
{
cause: resource,
}
)
}
const nb = descriptor.nb.partial().read(input)
if (nb.error) {
throw Object.assign(new Error(`Invalid 'nb' - ${nb.error.message}`), {
cause: nb,
})
}
return delegate({
capabilities: [createCapability({ can, with: resource.ok, nb: nb.ok })],
...options,
})
}
get can() {
return this.descriptor.can
}
/**
* @param {API.Source} source
* @returns {API.MatchResult<API.DirectMatch<API.ParsedCapability<A, R, C>>>}
*/
match(source) {
const result = parseCapability(this.descriptor, source)
return result.error
? result
: { ok: new Match(source, result.ok, this.descriptor) }
}
toString() {
return JSON.stringify({ can: this.descriptor.can })
}
}
/**
* Normalizes capability by removing empty nb field.
*
* @template {API.ParsedCapability} T
* @param {T} source
*/
const createCapability = ({ can, with: with_, nb }) =>
/** @type {API.InferCapability<T>} */ ({
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
* @implements {API.CapabilityParser<M|W>}
* @extends {Unit<M|W>}
*/
class Or extends Unit {
/**
* @param {API.Matcher<M>} left
* @param {API.Matcher<W>} right
*/
constructor(left, right) {
super()
this.left = left
this.right = right
}
/**
* @param {API.Source} capability
* @return {API.MatchResult<M|W>}
*/
match(capability) {
const left = this.left.match(capability)
if (left.error) {
const right = this.right.match(capability)
if (right.error) {
return right.error.name === 'MalformedCapability'
? //
right
: //
left
} else {
return right
}
} else {
return left
}
}
toString() {
return `${this.left.toString()}|${this.right.toString()}`
}
}
/**
* @template {API.MatchSelector<API.Match>[]} Selectors
* @implements {API.CapabilitiesParser<API.InferMembers<Selectors>>}
* @extends {View<API.Amplify<API.InferMembers<Selectors>>>}
*/
class And extends View {
/**
* @param {Selectors} selectors
*/
constructor(selectors) {
super()
this.selectors = selectors
}
/**
* @param {API.Source} capability
* @returns {API.MatchResult<API.Amplify<API.InferMembers<Selectors>>>}
*/
match(capability) {
const group = []
for (const selector of this.selectors) {
const result = selector.match(capability)
if (result.error) {
return result
} else {
group.push(result.ok)
}
}
return {
ok: new AndMatch(/** @type {API.InferMembers<Selectors>} */ (group)),
}
}
/**
* @param {API.Source[]} capabilities
*/
select(capabilities) {
return selectGroup(this, capabilities)
}
/**
* @template E
* @template {API.Match} X
* @param {API.MatchSelector<API.Match<E, X>>} other
* @returns {API.CapabilitiesParser<[...API.InferMembers<Selectors>, API.Match<E, X>]>}
*/
and(other) {
return new And([...this.selectors, other])
}
toString() {
return `[${this.selectors.map(String).join(', ')}]`
}
}
/**
* @template {API.ParsedCapability} T
* @template {API.Match} M
* @implements {API.TheCapabilityParser<API.DerivedMatch<T, M>>}
* @extends {Unit<API.DerivedMatch<T, M>>}
*/
class Derive extends Unit {
/**
* @param {API.MatchSelector<M>} from
* @param {API.TheCapabilityParser<API.DirectMatch<T>>} to
* @param {API.Derives<T, API.InferDeriveProof<M['value']>>} derives
*/
constructor(from, to, derives) {
super()
this.from = from
this.to = to
this.derives = derives
}
/**
* @type {typeof this.to['create']}
*/
create(options) {
return this.to.create(options)
}
/**
* @type {typeof this.to['invoke']}
*/
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
}
/**
* @param {API.Source} capability
* @returns {API.MatchResult<API.DerivedMatch<T, M>>}
*/
match(capability) {
const match = this.to.match(capability)
if (match.error) {
return match
} else {
return { ok: new DerivedMatch(match.ok, this.from, this.derives) }
}
}
toString() {
return this.to.toString()
}
}
/**
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @implements {API.DirectMatch<API.ParsedCapability<A, R, C>>}
*/
class Match {
/**
* @param {API.Source} source
* @param {API.ParsedCapability<A, R, C>} value
* @param {Required<Descriptor<A, R, C>>} descriptor
*/
constructor(source, value, descriptor) {
this.source = [source]
this.value = value
this.descriptor = descriptor
}
get can() {
return this.value.can
}
get proofs() {
const proofs = [this.source[0].delegation]
Object.defineProperties(this, {
proofs: { value: proofs },
})
return proofs
}
/**
* @param {API.CanIssue} context
* @returns {API.DirectMatch<API.ParsedCapability<A, R, C>>|null}
*/
prune(context) {
if (context.canIssue(this.value, this.source[0].delegation.issuer.did())) {
return null
} else {
return this
}
}
/**
* @param {API.Source[]} capabilities
* @returns {API.Select<API.DirectMatch<API.ParsedCapability<A, R, C>>>}
*/
select(capabilities) {
const unknown = []
const errors = []
const matches = []
for (const capability of capabilities) {
const result = resolveCapability(this.descriptor, this.value, capability)
if (result.ok) {
const claim = this.descriptor.derives(this.value, result.ok)
if (claim.error) {
errors.push(
new MatchError(
[new EscalatedCapability(this.value, result.ok, claim.error)],
this
)
)
} else {
matches.push(new Match(capability, result.ok, this.descriptor))
}
} else {
switch (result.error.name) {
case 'UnknownCapability':
unknown.push(result.error.capability)
break
case 'MalformedCapability':
default:
errors.push(new MatchError([result.error], this))
}
}
}
return { matches, unknown, errors }
}
toString() {
const { nb } = this.value
return JSON.stringify({
can: this.descriptor.can,
with: this.value.with,
nb: nb && Object.keys(nb).length > 0 ? nb : undefined,
})
}
}
/**
* @template {API.ParsedCapability} T
* @template {API.Match} M
* @implements {API.DerivedMatch<T, M>}
*/
class DerivedMatch {
/**
* @param {API.DirectMatch<T>} selected
* @param {API.MatchSelector<M>} from
* @param {API.Derives<T, API.InferDeriveProof<M['value']>>} derives
*/
constructor(selected, from, derives) {
this.selected = selected
this.from = from
this.derives = derives
}
get can() {
return this.value.can
}
get source() {
return this.selected.source
}
get proofs() {
const proofs = []
for (const { delegation } of this.selected.source) {
proofs.push(delegation)
}
Object.defineProperties(this, { proofs: { value: proofs } })
return proofs
}
get value() {
return this.selected.value
}
/**
* @param {API.CanIssue} context
*/
prune(context) {
const selected =
/** @type {API.DirectMatch<T>|null} */
(this.selected.prune(context))
return selected ? new DerivedMatch(selected, this.from, this.derives) : null
}
/**
* @param {API.Source[]} capabilities
*/
select(capabilities) {
const { derives, selected, from } = this
const { value } = selected
const direct = selected.select(capabilities)
const derived = from.select(capabilities)
const matches = []
const errors = []
for (const match of derived.matches) {
// If capability can not be derived it escalates
const result = derives(value, match.value)
if (result.error) {
errors.push(
new MatchError(
[new EscalatedCapability(value, match.value, result.error)],
this
)
)
} else {
matches.push(match)
}
}
return {
unknown: intersection(direct.unknown, derived.unknown),
errors: [
...errors,
...direct.errors,
...derived.errors.map(error => new MatchError([error], this)),
],
matches: [
...direct.matches.map(match => new DerivedMatch(match, from, derives)),
...matches,
],
}
}
toString() {
return this.selected.toString()
}
}
/**
* @template {API.MatchSelector<API.Match>[]} Selectors
* @implements {API.Amplify<API.InferMembers<Selectors>>}
*/
class AndMatch {
/**
* @param {API.Match[]} matches
*/
constructor(matches) {
this.matches = matches
}
get selectors() {
return this.matches
}
/**
* @returns {API.Source[]}
*/
get source() {
const source = []
for (const match of this.matches) {
source.push(...match.source)
}
Object.defineProperties(this, { source: { value: source } })
return source
}
/**
* @param {API.CanIssue} context
*/
prune(context) {
const matches = []
for (const match of this.matches) {
const pruned = match.prune(context)
if (pruned) {
matches.push(pruned)
}
}
return matches.length === 0 ? null : new AndMatch(matches)
}
get proofs() {
const proofs = []
for (const { delegation } of this.source) {
proofs.push(delegation)
}
Object.defineProperties(this, { proofs: { value: proofs } })
return proofs
}
/**
* @type {API.InferValue<API.InferMembers<Selectors>>}
*/
get value() {
const value = []
for (const match of this.matches) {
value.push(match.value)
}
Object.defineProperties(this, { value: { value } })
return /** @type {any} */ (value)
}
/**
* @param {API.Source[]} capabilities
*/
select(capabilities) {
return selectGroup(this, capabilities)
}
toString() {
return `[${this.matches.map(match => match.toString()).join(', ')}]`
}
}
/**
* Resolves ability `pattern` of the delegated capability from the ability
* of the claimed capability. If pattern matches returns claimed ability
* otherwise returns given `fallback`.
*
* @example
* ```js
* resolveAbility('*', 'store/add', null) // => 'store/add'
* resolveAbility('store/*', 'store/add', null) // => 'store/add'
* resolveAbility('store/add', 'store/add', null) // => 'store/add'
* resolveAbility('store/', 'store/add', null) // => null
* resolveAbility('store/a*', 'store/add', null) // => null
* resolveAbility('store/list', 'store/add', null) // => null
* ```
*
* @template {API.Ability} T
* @template U
* @param {string} pattern
* @param {T} can
* @param {U} fallback
* @returns {T|U}
*/
const resolveAbility = (pattern, can, fallback) => {
switch (pattern) {
case can:
case '*':
return can
default:
return pattern.endsWith('/*') && can.startsWith(pattern.slice(0, -1))
? can
: fallback
}
}
/**
* Resolves `source` resource of the delegated capability from the resource
* `uri` of the claimed capability. If `source` is `"ucan:*""` or matches `uri`
* then it returns `uri` back otherwise it returns `fallback`.
*
* @example
* ```js
* resolveResource('ucan:*', 'did:key:zAlice', null) // => 'did:key:zAlice'
* resolveAbility('ucan:*', 'https://example.com', null) // => 'https://example.com'
* resolveAbility('did:*', 'did:key:zAlice', null) // => null
* resolveAbility('did:key:zAlice', 'did:key:zAlice', null) // => did:key:zAlice
* ```
* @template {string} T
* @template U
* @param {T} uri
* @param {string} source
* @param {U} fallback
* @returns {T|U}
*/
const resolveResource = (source, uri, fallback) => {
switch (source) {
case uri:
case 'ucan:*':
return uri
default:
return fallback
}
}
/**
* Parses capability from the `source` using a provided `parser`.
*
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @param {Required<Descriptor<A, R, C>>} descriptor
* @param {API.Source} source
* @returns {API.Result<API.ParsedCapability<A, R, C>, API.InvalidCapability>}
*/
const parseCapability = (descriptor, source) => {
const { delegation } = source
const capability = /** @type {API.Capability<A, R, C>} */ (source.capability)
if (descriptor.can !== capability.can) {
return { error: new UnknownCapability(capability) }
}
const uri = descriptor.with.read(capability.with)
if (uri.error) {
return { error: new MalformedCapability(capability, uri.error) }
}
const nb = descriptor.nb.read(capability.nb || {})
if (nb.error) {
return { error: new MalformedCapability(capability, nb.error) }
}
return { ok: new CapabilityView(descriptor.can, uri.ok, nb.ok, delegation) }
}
/**
* Resolves delegated capability `source` from the `claimed` capability using
* provided capability `parser`. It is similar to `parseCapability` except
* `source` here is treated as capability pattern which is matched against the
* `claimed` capability. This means we resolve `can` and `with` fields from the
* `claimed` capability and inherit all missing `nb` fields from the claimed
* capability.
*
* @template {API.Ability} A
* @template {API.URI} R
* @template {API.Caveats} C
* @param {Required<Descriptor<A, R, C>>} descriptor
* @param {API.ParsedCapability<A, R, C>} claimed
* @param {API.Source} source
* @returns {API.Result<API.ParsedCapability<A, R, C>, API.InvalidCapability>}
*/
const resolveCapability = (descriptor, claimed, { capability, delegation }) => {
const can = resolveAbility(capability.can, claimed.can, null)
if (can == null) {
return { error: new UnknownCapability(capability) }
}
const resource = resolveResource(
capability.with,
claimed.with,
capability.with
)
const uri = descriptor.with.read(resource)
if (uri.error) {
return { error: new MalformedCapability(capability, uri.error) }
}
const nb = descriptor.nb.read({
...claimed.nb,
...capability.nb,
})
if (nb.error) {
return { error: new MalformedCapability(capability, nb.error) }
}
return { ok: new CapabilityView(can, uri.ok, nb.ok, delegation) }
}
/**
* @template {API.Ability} A
* @template {API.URI} R
* @template C
*/
class CapabilityView {
/**
* @param {A} can
* @param {R} with_
* @param {C} nb
* @param {API.Delegation} delegation
*/
constructor(can, with_, nb, delegation) {
this.can = can
this.with = with_
this.delegation = delegation
this.nb = nb
}
}
/**
* @template {API.Match} M
* @param {API.Matcher<M>} matcher
* @param {API.Source[]} capabilities
* @returns {API.Select<M>}
*/
const select = (matcher, capabilities) => {
const unknown = []
const matches = []
const errors = []
for (const capability of capabilities) {
const result = matcher.match(capability)
if (result.error) {
switch (result.error.name) {
case 'UnknownCapability':
unknown.push(result.error.capability)
break
case 'MalformedCapability':
default:
errors.push(new MatchError([result.error], result.error.capability))
}
} else {
matches.push(result.ok)
}
}
return { matches, errors, unknown }
}
/**
* @template {API.Selector<API.Match>[]} S
* @param {{selectors:S}} self
* @param {API.Source[]} capabilities
*/
const selectGroup = (self, capabilities) => {
let unknown
const data = []
const errors = []
for (const selector of self.selectors) {
const selected = selector.select(capabilities)
unknown = unknown
? intersection(unknown, selected.unknown)
: selected.unknown
for (const error of selected.errors) {
errors.push(new MatchError([error], self))
}
data.push(selected.matches)
}
const matches = combine(data).map(group => new AndMatch(group))
return {
unknown:
/* c8 ignore next */
unknown || [],
errors,
matches,
}
}
/**
* @template {API.ParsedCapability} T
* @template {API.ParsedCapability} U
* @param {T} claimed
* @param {U} delegated
* @return {API.Result<true, API.Failure>}
*/
const defaultDerives = (claimed, delegated) => {
if (delegated.with.endsWith('*')) {
if (!claimed.with.startsWith(delegated.with.slice(0, -1))) {
return Schema.error(
`Resource ${claimed.with} does not match delegated ${delegated.with} `
)
}
} else if (delegated.with !== claimed.with) {
return Schema.error(
`Resource ${claimed.with} is not contained by ${delegated.with}`
)
}
/* c8 ignore next 2 */
const caveats = delegated.nb || {}
const nb = claimed.nb || {}
const kv = entries(caveats)
for (const [name, value] of kv) {
if (nb[name] != value) {
return Schema.error(`${String(name)}: ${nb[name]} violates ${value}`)
}
}
return { ok: true }
}