diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index 27f70f0ba..7a8c092cc 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -83,6 +83,10 @@ "types": "./dist/space/blob.d.ts", "import": "./dist/space/blob.js" }, + "./space/replica": { + "types": "./dist/space/replica/index.d.ts", + "import": "./dist/space/replica/index.js" + }, "./web3.storage/blob": { "types": "./dist/web3.storage/blob.d.ts", "import": "./dist/web3.storage/blob.js" diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index 8f88d9807..953d257f1 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -19,6 +19,7 @@ import * as Aggregator from './filecoin/aggregator.js' import * as Dealer from './filecoin/dealer.js' import * as DealTracker from './filecoin/deal-tracker.js' import * as SpaceIndex from './space/index.js' +import * as SpaceReplica from './space/replica/index.js' import * as UCAN from './ucan.js' import * as Plan from './plan.js' import * as Usage from './usage.js' @@ -44,6 +45,7 @@ export { Subscription, Filecoin, SpaceIndex, + SpaceReplica, Storefront, Aggregator, Dealer, @@ -127,4 +129,6 @@ export const abilitiesAsStrings = [ HTTP.put.can, SpaceIndex.index.can, SpaceIndex.add.can, + SpaceReplica.replica.can, + SpaceReplica.list.can, ] diff --git a/packages/capabilities/src/space/replica/index.js b/packages/capabilities/src/space/replica/index.js new file mode 100644 index 000000000..5ce4efe9b --- /dev/null +++ b/packages/capabilities/src/space/replica/index.js @@ -0,0 +1,71 @@ +/** + * Space Replica Capabilities. + * + * An extension to the space protocol that allows agents to manage and query + * replica information for blobs stored in a space. + * + * These can be imported directly with: + * + * ```js + * import * as SpaceReplica from '@storacha/capabilities/space/replica' + * ``` + * + * @module + */ +import { capability, Schema, ok, fail } from '@ucanto/validator' +import { equals } from 'multiformats/bytes' +import { equalWith, SpaceDID } from '../../utils.js' + +/** + * Capability can only be delegated (but not invoked) allowing audience to + * derive any `space/replica/` prefixed capability for the space identified + * by DID in the `with` field. + */ +export const replica = capability({ + can: 'space/replica/*', + /** DID of the space where replica information is stored. */ + with: SpaceDID, + derives: equalWith, +}) + +/** + * The `space/replica/list` capability allows an agent to list current replicas + * for a given blob in the space identified by DID in the `with` field. + */ +export const list = capability({ + can: 'space/replica/list', + /** DID of the space where replica information is stored. */ + with: SpaceDID, + nb: Schema.struct({ + /** Blob to list replicas for. */ + blob: Schema.bytes(), + /** + * A pointer that can be moved back and forth on the list. + * It can be used to paginate a list for instance. + */ + cursor: Schema.string().optional(), + /** + * Maximum number of items per page. + */ + size: Schema.integer().optional(), + }), + derives: (claimed, delegated) => { + if (claimed.with !== delegated.with) { + return fail( + `Expected 'with: "${delegated.with}"' instead got '${claimed.with}'` + ) + } else if ( + delegated.nb.blob && + !equals(delegated.nb.blob, claimed.nb.blob) + ) { + return fail( + `Blob digest ${claimed.nb.blob ? `${claimed.nb.blob}` : ''} violates imposed ${delegated.nb.blob} constraint.` + ) + } + return ok({}) + }, +}) + +// ⚠️ We export imports here so they are not omitted in generated typedefs +// @see https://github.com/microsoft/TypeScript/issues/51548 +export { Schema } diff --git a/packages/capabilities/test/capabilities/space/replica/index.test.js b/packages/capabilities/test/capabilities/space/replica/index.test.js new file mode 100644 index 000000000..920e6a6bf --- /dev/null +++ b/packages/capabilities/test/capabilities/space/replica/index.test.js @@ -0,0 +1,115 @@ +import { strict as assert } from 'node:assert' +import { access } from '@ucanto/validator' +import { Verifier } from '@ucanto/principal' +import * as SpaceReplica from '../../../../src/space/replica/index.js' +import * as Capability from '../../../../src/top.js' +import { + alice, + service as storageNode, + mallory as account, +} from '../../../helpers/fixtures.js' +import { + createCar, + validateAuthorization, +} from '../../../helpers/utils.js' + +const top = () => + Capability.top.delegate({ + issuer: account, + audience: alice, + with: account.did(), + }) + +const replica = async () => + SpaceReplica.replica.delegate({ + issuer: account, + audience: alice, + with: account.did(), + proofs: [await top()], + }) + +describe('space replica capabilities', function () { + it('space/replica/list can be derived from *', async () => { + const car = await createCar('test') + + const list = SpaceReplica.list.invoke({ + issuer: alice, + audience: storageNode, + with: account.did(), + nb: { + blob: car.cid.multihash.bytes, + }, + proofs: [await top()], + }) + + const result = await access(await list.delegate(), { + capability: SpaceReplica.list, + principal: Verifier, + authority: storageNode, + validateAuthorization, + }) + + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), storageNode.did()) + assert.equal(result.ok.capability.can, SpaceReplica.list.can) + }) + + it('space/replica/list can be derived from space/replica/*', async () => { + const car = await createCar('test') + + const list = SpaceReplica.list.invoke({ + issuer: alice, + audience: storageNode, + with: account.did(), + nb: { + blob: car.cid.multihash.bytes, + }, + proofs: [await replica()], + }) + + const result = await access(await list.delegate(), { + capability: SpaceReplica.list, + principal: Verifier, + authority: storageNode, + validateAuthorization, + }) + + assert.ok(result.ok) + assert.deepEqual(result.ok.audience.did(), storageNode.did()) + assert.equal(result.ok.capability.can, SpaceReplica.list.can) + }) + + it('space/replica/list should fail when escalating space constraint', async () => { + const car1 = await createCar('test1') + const car2 = await createCar('test2') + + const list = await SpaceReplica.list.delegate({ + issuer: account, + audience: alice, + with: account.did(), + nb: { + blob: car1.cid.multihash.bytes, + }, + proofs: [await top()], + }) + + const invalidList = SpaceReplica.list.invoke({ + issuer: alice, + audience: storageNode, + with: account.did(), + nb: { + blob: car2.cid.multihash.bytes, + }, + }) + + const result = await access(await invalidList.delegate(), { + capability: SpaceReplica.list, + principal: Verifier, + authority: storageNode, + validateAuthorization, + proofs: [list], + }) + + assert.ok(!result.ok) + }) +})