diff --git a/packages/access/src/capabilities/types.ts b/packages/access/src/capabilities/types.ts index f37dfb0ba..c2334e1a7 100644 --- a/packages/access/src/capabilities/types.ts +++ b/packages/access/src/capabilities/types.ts @@ -1,4 +1,6 @@ -import type { Capability, IPLDLink, DID } from '@ipld/dag-ucan' +import type { Capability, IPLDLink, DID, ToString } from '@ipld/dag-ucan' +import type { Block as IPLDBlock } from '@ucanto/interface' +import { codec as CARCodec } from '@ucanto/transport/car' type AccountDID = DID type AgentDID = DID @@ -56,3 +58,48 @@ export interface StoreRemove extends Capability<'store/remove', DID> { } export interface StoreList extends Capability<'store/list', DID> {} + +/** + * Logical represenatation of the CAR. + */ +export interface CAR { + roots: IPLDLink[] + blocks: Map, IPLDBlock> +} + +export type CARLink = IPLDLink + +/** + * Capability to add arbitrary CID into an account's upload listing. + */ +export interface UploadAdd extends Capability<'upload/add', AccountDID> { + /** + * CID of the file / directory / DAG root that is uploaded. + */ + root: IPLDLink + /** + * List of CAR links which MAY contain contents of this upload. Please + * note that there is no guarantee that linked CARs actually contain + * content related to this upload, it is whatever user deemed semantically + * relevant. + */ + shards: CARLink[] +} + +/** + * Capability to list CIDs in the account's upload list. + */ +export interface UploadList extends Capability<'upload/list', AccountDID> { + // ⚠️ We will likely add more fields here to support paging etc... but that + // will come in the future. +} + +/** + * Capability to remove arbitrary CID from the account's upload list. + */ +export interface UploadRemove extends Capability<'upload/remove', AccountDID> { + /** + * CID of the file / directory / DAG root to be removed from the upload list. + */ + root: IPLDLink +} diff --git a/packages/access/src/capabilities/upload.js b/packages/access/src/capabilities/upload.js new file mode 100644 index 000000000..4649d29d5 --- /dev/null +++ b/packages/access/src/capabilities/upload.js @@ -0,0 +1,76 @@ +import { capability, Link, URI } from '@ucanto/server' +import { codec } from '@ucanto/transport/car' +import { equalWith, List, fail, equal } from './utils.js' +import { any } from './any.js' + +/** + * All the `upload/*` capabilities which can also be derived + * from `any` (a.k.a `*`) capability. + */ +export const upload = any.derive({ + to: capability({ + can: 'upload/*', + with: URI.match({ protocol: 'did:' }), + derives: equalWith, + }), + derives: equalWith, +}) + +// Right now ucanto does not yet has native `*` support, which means +// `store/add` can not be derived from `*` event though it can be +// derived from `store/*`. As a workaround we just define base capability +// here so all store capabilities could be derived from either `*` or +// `store/*`. +const base = any.or(upload) + +const CARLink = Link.match({ code: codec.code, version: 1 }) + +/** + * `store/add` can be derived from the `store/*` capability + * as long as with fields match. + */ +export const add = base.derive({ + to: capability({ + can: 'upload/add', + with: URI.match({ protocol: 'did:' }), + caveats: { + root: Link.optional(), + shards: List.of(CARLink).optional(), + }, + derives: (self, from) => { + return ( + fail(equalWith(self, from)) || + fail(equal(self.caveats.root, from.caveats.root, 'root')) || + fail(equal(self.caveats.shards, from.caveats.shards, 'shards')) || + true + ) + }, + }), + derives: equalWith, +}) + +export const remove = base.derive({ + to: capability({ + can: 'upload/remove', + with: URI.match({ protocol: 'did:' }), + caveats: { + root: Link.optional(), + }, + derives: (self, from) => { + return ( + fail(equalWith(self, from)) || + fail(equal(self.caveats.root, from.caveats.root, 'root')) || + true + ) + }, + }), + derives: equalWith, +}) + +export const list = base.derive({ + to: capability({ + can: 'upload/list', + with: URI.match({ protocol: 'did:' }), + }), + derives: equalWith, +}) diff --git a/packages/access/src/capabilities/utils.js b/packages/access/src/capabilities/utils.js index 236ae5c9f..126f895c4 100644 --- a/packages/access/src/capabilities/utils.js +++ b/packages/access/src/capabilities/utils.js @@ -36,6 +36,24 @@ export function equalWith(child, parent) { ) } +/** + * @param {unknown} child + * @param {unknown} parent + * @param {string} constraint + */ + +export function equal(child, parent, constraint) { + if (parent === undefined || parent === '*') { + return true + } else if (String(child) !== String(parent)) { + return new Failure( + `Contastraint vilation: ${child} violates imposed ${constraint} constraint ${parent}` + ) + } else { + return true + } +} + /** * @template {Types.ParsedCapability<"store/add"|"store/remove", Types.URI<'did:'>, {link?: Types.Link}>} T * @param {T} claimed @@ -67,3 +85,41 @@ export const derives = (claimed, delegated) => { export function fail(value) { return value === true ? undefined : value } + +export const List = { + /** + * @template T + * @param {Types.Decoder} decoder + * @returns {Types.Decoder & { optional(): Types.Decoder>}} + */ + of: (decoder) => ({ + decode: (input) => { + if (!Array.isArray(input)) { + return new Failure(`Expected to be an array instead got ${input} `) + } + /** @type {T[]} */ + const results = [] + for (const item of input) { + const result = decoder.decode(item) + if (result?.error) { + return new Failure( + `Array containts invalid element: ${result.message}` + ) + } else { + results.push(result) + } + } + return results + }, + optional: () => optional(List.of(decoder)), + }), +} + +/** + * @template T + * @param {Types.Decoder} decoder + * @returns {Types.Decoder} + */ +export const optional = (decoder) => ({ + decode: (input) => (input === undefined ? input : decoder.decode(input)), +}) diff --git a/packages/access/src/types.ts b/packages/access/src/types.ts index 1260aa9cb..6cb528352 100644 --- a/packages/access/src/types.ts +++ b/packages/access/src/types.ts @@ -11,6 +11,7 @@ import type { Failure, Phantom, Capabilities, + Link as IPLDLink, } from '@ucanto/interface' import * as UCAN from '@ipld/dag-ucan' @@ -18,6 +19,9 @@ import type { IdentityIdentify, IdentityRegister, IdentityValidate, + UploadAdd, + UploadList, + UploadRemove, } from './capabilities/types' import { VoucherClaim, VoucherRedeem } from './capabilities/types.js' @@ -47,6 +51,21 @@ export interface Service { > redeem: ServiceMethod } + upload: { + add: ServiceMethod + /** + * Upload list has no defined failure conditions (apart from usual ucanto + * errors) which is why it's error is of type `never`. For unknown accounts + * list MUST be considered empty. + */ + list: ServiceMethod + /** + * Upload remove has no defined failure condition (apart from usual ucanto + * errors) which is why it's error is of type `never`. Removing an upload + * not in the list MUST be considered succesful NOOP. + */ + remove: ServiceMethod + } } export interface AgentMeta { @@ -116,3 +135,39 @@ export interface PullRegisterOptions { issuer: SigningPrincipal signal?: AbortSignal } + +/** + * Error MAY occur on `upload/add` if provided `shards` contain invalid CIDs e.g + * non CAR cids. + */ + +export interface InvalidUpload extends Failure { + name: 'InvalidUpload' +} + +/** + * On succeful upload/add provider will respond back with a `root` CID that + * was added. + */ +export interface UploadAddOk { + root: IPLDLink +} + +/** + * On succesful upload/list provider returns `uploads` list of `{root}` elements. + * Please note that by wrapping list in an object we create an opportunity to + * extend type in backwards compatible way to accomodate for paging information + * in the future. Likewise list contains `{root}` objects which also would allow + * us to add more fields in a future like size, date etc... + */ +export interface UploadListOk { + uploads: Array<{ root: IPLDLink }> +} + +/** + * On succesful upload/remove provider returns empty object. Please not that + * will allow us to extend result type with more things in the future in a + * backwards compatible way. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface UploadRemoveOk {} diff --git a/packages/access/test/capabilities/upload.test.js b/packages/access/test/capabilities/upload.test.js new file mode 100644 index 000000000..a973b42c6 --- /dev/null +++ b/packages/access/test/capabilities/upload.test.js @@ -0,0 +1,651 @@ +import assert from 'assert' +import { access } from '@ucanto/validator' +import { Principal } from '@ucanto/principal' +import { delegate, parseLink } from '@ucanto/core' +import * as Upload from '../../src/capabilities/upload.js' +import { codec as CARCodec } from '@ucanto/transport/car' +import { codec as CBOR } from '@ucanto/transport/cbor' +import { + alice, + bob, + service as w3, + mallory as account, +} from '../helpers/fixtures.js' + +describe('upload capabilities', function () { + // delegation from account to agent + const proof = delegate({ + issuer: account, + audience: alice, + capabilities: [ + { + can: '*', + with: account.did(), + }, + ], + }) + + it('upload/add can be derived from upload/* derived from *', async () => { + const add = Upload.add.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + }, + proofs: [await proof], + }) + + const result = await access(await add.delegate(), { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/add') + assert.deepEqual(result.capability.caveats, { + root: parseLink('bafkqaaa'), + }) + }) + + it('upload/add can be derived from *', async () => { + const upload = Upload.upload.invoke({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await proof], + }) + + const add = Upload.add.invoke({ + audience: w3, + issuer: bob, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + }, + proofs: [await upload.delegate()], + }) + + const result = await access(await add.delegate(), { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/add') + assert.deepEqual(result.capability.caveats, { + root: parseLink('bafkqaaa'), + }) + }) + + it('creating upload/add throws if shards is contains non CAR cid', async () => { + const proofs = [await proof] + assert.throws(() => { + Upload.add.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + shards: [ + // @ts-expect-error - not a CAR cid + parseLink('bafkqaaa'), + ], + }, + proofs, + }) + }, /Expected link to be CID with 0x202 codec/) + }) + + it('validator fails on upload/add if shard contains non CAR cid', async () => { + const add = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'upload/add', + with: account.did(), + root: parseLink('bafkqaaa'), + shards: [parseLink('bafkqaaa')], + }, + ], + proofs: [await proof], + }) + + const result = await access(add, { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + assert.equal(result.error, true) + assert.match(String(result), /Expected link to be CID with 0x202 codec/) + }) + + it('upload/add works with shards that are CAR cids', async () => { + const cbor = await CBOR.write({ hello: 'world' }) + const shard = await CARCodec.write({ roots: [cbor] }) + const add = Upload.add.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + shards: [shard.cid], + }, + proofs: [await proof], + }) + + const result = await access(await add.delegate(), { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/add') + assert.deepEqual(result.capability.caveats, { + root: parseLink('bafkqaaa'), + shards: [shard.cid], + }) + }) + + it('upload/add capability requires with to be a did', () => { + assert.throws(() => { + Upload.add.invoke({ + issuer: alice, + audience: w3, + // @ts-expect-error - not a CAR cid + with: 'mailto:alice@web.mail', + caveats: { + root: parseLink('bafkqaaa'), + }, + }) + }, /Expected did: URI instead got mailto:alice@web.mail/) + }) + + it('upload/add validation requires with to be a did', async () => { + const add = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'upload/add', + with: 'mailto:alice@web.mail', + root: parseLink('bafkqaaa'), + }, + ], + proofs: [await proof], + }) + + const result = await access(add, { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + assert.equal(result.error, true) + assert.match( + String(result), + /Expected did: URI instead got mailto:alice@web.mail/ + ) + }) + + it('upload/add should work when escalating root when caveats not imposed on proof', async () => { + const delegation = Upload.add + .invoke({ + issuer: alice, + audience: bob, + with: account.did(), + caveats: {}, + proofs: [await proof], + }) + .delegate() + + const cbor = await CBOR.write({ hello: 'world' }) + + const add = Upload.add.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + caveats: { + root: cbor.cid, + }, + proofs: [await delegation], + }) + + const result = await access(await add.delegate(), { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.capability.caveats, { + root: cbor.cid, + }) + }) + + it('upload/add should fail when escalating root', async () => { + const delegation = Upload.add + .invoke({ + issuer: alice, + audience: bob, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + }, + proofs: [await proof], + }) + .delegate() + + const cbor = await CBOR.write({ hello: 'world' }) + + const add = await Upload.add.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + caveats: { + root: cbor.cid, + }, + proofs: [await delegation], + }) + + const result = await access(await add.delegate(), { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + assert.equal(result.error, true) + assert.match( + String(result), + /bafyreidykglsfhoixmivffc5uwhcgshx4j465xwqntbmu43nb2dzqwfvae violates imposed root constraint bafkqaaa/ + ) + }) + + it('upload/add should fail when escalating shards', async () => { + const cbor = await CBOR.write({ hello: 'world' }) + const shard = await CARCodec.write({ roots: [cbor] }) + const delegation = Upload.add + .invoke({ + issuer: alice, + audience: bob, + with: account.did(), + caveats: { + shards: [shard.cid], + }, + proofs: [await proof], + }) + .delegate() + + const add = await Upload.add.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + }, + proofs: [await delegation], + }) + + const result = await access(await add.delegate(), { + capability: Upload.add, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + assert.equal(result.error, true) + assert.match( + String(result), + /imposed shards constraint bagbaieraha2ehrhh5ycdp76hijjo3eablsaikm5jlrbt4vmcn32p7reg3uiq/ + ) + }) + + it('upload/list can be derived from upload/* derived from *', async () => { + const list = Upload.list.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + proofs: [await proof], + }) + + const result = await access(await list.delegate(), { + capability: Upload.list, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/list') + assert.deepEqual(result.capability.caveats, {}) + }) + + it('upload/list can be derived from *', async () => { + const upload = Upload.upload.invoke({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await proof], + }) + + const list = Upload.list.invoke({ + audience: w3, + issuer: bob, + with: account.did(), + proofs: [await upload.delegate()], + }) + + const result = await access(await list.delegate(), { + capability: Upload.list, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/list') + assert.deepEqual(result.capability.caveats, {}) + }) + + it('upload/list can be derived from upload/list', async () => { + const delegation = Upload.list.invoke({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await proof], + }) + + const list = Upload.list.invoke({ + audience: w3, + issuer: bob, + with: account.did(), + proofs: [await delegation.delegate()], + }) + + const result = await access(await list.delegate(), { + capability: Upload.list, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/list') + assert.deepEqual(result.capability.caveats, {}) + }) + + it('upload/list capability requires with to be a did', () => { + assert.throws(() => { + Upload.list.invoke({ + issuer: alice, + audience: w3, + // @ts-expect-error - not a CAR cid + with: 'mailto:alice@web.mail', + }) + }, /Expected did: URI instead got mailto:alice@web.mail/) + }) + + it('upload/list validation requires with to be a did', async () => { + const list = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'upload/list', + with: 'mailto:alice@web.mail', + root: parseLink('bafkqaaa'), + }, + ], + proofs: [await proof], + }) + + const result = await access(list, { + capability: Upload.list, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + assert.equal(result.error, true) + assert.match( + String(result), + /Expected did: URI instead got mailto:alice@web.mail/ + ) + }) + + it('upload/remove can be derived from upload/* derived from *', async () => { + const remove = Upload.remove.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + proofs: [await proof], + caveats: { + root: parseLink('bafkqaaa'), + }, + }) + + const result = await access(await remove.delegate(), { + capability: Upload.remove, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/remove') + assert.deepEqual(result.capability.caveats, { + root: parseLink('bafkqaaa'), + }) + }) + + it('upload/remove can be derived from *', async () => { + const upload = Upload.upload.invoke({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await proof], + }) + + const remove = Upload.remove.invoke({ + audience: w3, + issuer: bob, + with: account.did(), + proofs: [await upload.delegate()], + caveats: { + root: parseLink('bafkqaaa'), + }, + }) + + const result = await access(await remove.delegate(), { + capability: Upload.remove, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/remove') + assert.deepEqual(result.capability.caveats, { + root: parseLink('bafkqaaa'), + }) + }) + + it('upload/remove can be derived from upload/remove', async () => { + const delegation = Upload.remove.invoke({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await proof], + caveats: { + root: parseLink('bafkqaaa'), + }, + }) + + const remove = Upload.remove.invoke({ + audience: w3, + issuer: bob, + with: account.did(), + proofs: [await delegation.delegate()], + caveats: { + root: parseLink('bafkqaaa'), + }, + }) + + const result = await access(await remove.delegate(), { + capability: Upload.remove, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + if (result.error) { + assert.fail(result.message) + } + + assert.deepEqual(result.audience.did(), w3.did()) + assert.equal(result.capability.can, 'upload/remove') + assert.deepEqual(result.capability.caveats, { + root: parseLink('bafkqaaa'), + }) + }) + + it('upload/remove capability requires with to be a did', () => { + assert.throws(() => { + Upload.remove.invoke({ + issuer: alice, + audience: w3, + // @ts-expect-error - not a DID + with: 'mailto:alice@web.mail', + root: parseLink('bafkqaaa'), + }) + }, /Expected did: URI instead got mailto:alice@web.mail/) + }) + + it('upload/list validation requires with to be a did', async () => { + const remove = await delegate({ + issuer: alice, + audience: w3, + capabilities: [ + { + can: 'upload/remove', + with: 'mailto:alice@web.mail', + root: parseLink('bafkqaaa'), + }, + ], + proofs: [await proof], + }) + + const result = await access(remove, { + capability: Upload.remove, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + assert.equal(result.error, true) + assert.match( + String(result), + /Expected did: URI instead got mailto:alice@web.mail/ + ) + }) + + it('upload/remove should fail when escalating root', async () => { + const delegation = Upload.remove + .invoke({ + issuer: alice, + audience: bob, + with: account.did(), + caveats: { + root: parseLink('bafkqaaa'), + }, + proofs: [await proof], + }) + .delegate() + + const cbor = await CBOR.write({ hello: 'world' }) + + const remove = await Upload.remove.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + caveats: { + root: cbor.cid, + }, + proofs: [await delegation], + }) + + const result = await access(await remove.delegate(), { + capability: Upload.remove, + principal: Principal, + canIssue: (claim, issuer) => { + return claim.with === issuer + }, + }) + + assert.equal(result.error, true) + assert.match( + String(result), + /bafyreidykglsfhoixmivffc5uwhcgshx4j465xwqntbmu43nb2dzqwfvae violates imposed root constraint bafkqaaa/ + ) + }) +})