diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json index 47701c634..2e77d6f45 100644 --- a/packages/capabilities/package.json +++ b/packages/capabilities/package.json @@ -56,6 +56,10 @@ "types": "./dist/src/filecoin/dealer.d.ts", "import": "./src/filecoin/dealer.js" }, + "./index": { + "types": "./dist/src/index/index.d.ts", + "import": "./src/index/index.js" + }, "./web3.storage/blob": { "types": "./dist/src/web3.storage/blob.d.ts", "import": "./src/web3.storage/blob.js" diff --git a/packages/capabilities/readme.md b/packages/capabilities/readme.md index 199bb4b0d..2b8e27b4f 100644 --- a/packages/capabilities/readme.md +++ b/packages/capabilities/readme.md @@ -33,6 +33,7 @@ import * as Filecoin from '@web3-storage/capabilities/filecoin' import * as Aggregator from '@web3-storage/capabilities/filecoin/aggregator' import * as DealTracker from '@web3-storage/capabilities/filecoin/deal-tracker' import * as Dealer from '@web3-storage/capabilities/filecoin/dealer' +import * as Index from '@web3-storage/capabilities/index' // This package has a "main" entrypoint but we recommend the usage of the specific imports above ``` diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index 5b2ba192c..c506fa36b 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -16,6 +16,7 @@ import * as Storefront from './filecoin/storefront.js' import * as Aggregator from './filecoin/aggregator.js' import * as Dealer from './filecoin/dealer.js' import * as DealTracker from './filecoin/deal-tracker.js' +import * as Index from './index/index.js' import * as UCAN from './ucan.js' import * as Plan from './plan.js' import * as Usage from './usage.js' @@ -37,6 +38,7 @@ export { RateLimit, Subscription, Filecoin, + Index, Storefront, Aggregator, Dealer, @@ -50,7 +52,7 @@ export { HTTP, } -/** @type {import('./types.js').AbilitiesArray} */ +/** @type {import('./types.js').ServiceAbility[]} */ export const abilitiesAsStrings = [ Top.top.can, Provider.add.can, @@ -101,4 +103,6 @@ export const abilitiesAsStrings = [ W3sBlob.allocate.can, W3sBlob.accept.can, HTTP.put.can, + Index.index.can, + Index.add.can, ] diff --git a/packages/capabilities/src/index/index.js b/packages/capabilities/src/index/index.js new file mode 100644 index 000000000..b0541618d --- /dev/null +++ b/packages/capabilities/src/index/index.js @@ -0,0 +1,52 @@ +/** + * Index Capabilities. + * + * W3 Indexing protocol allows authorized agents to submit verifiable claims + * about content-addressed data to be published on the InterPlanetary Network + * Indexer (IPNI), making it publicly queryable. + * + * These can be imported directly with: + * ```js + * import * as Index from '@web3-storage/capabilities/index' + * ``` + * + * @module + */ +import { CAR } from '@ucanto/core' +import { capability, Schema, ok } from '@ucanto/validator' +import { equalWith, SpaceDID, and, equal } from '../utils.js' + +/** + * Capability can only be delegated (but not invoked) allowing audience to + * derive any `index/` prefixed capability for the space identified by the DID + * in the `with` field. + */ +export const index = capability({ + can: 'index/*', + /** DID of the space where indexed data is stored. */ + with: SpaceDID, + derives: equalWith, +}) + +/** + * `index/add` capability allows an agent to submit verifiable claims + * about content-addressed data to be published on the InterPlanetary Network + * Indexer (IPNI), making it publicly queryable. + */ +export const add = capability({ + can: 'index/add', + /** DID of the space where indexed data is stored. */ + with: SpaceDID, + nb: Schema.struct({ + /** Content Archive (CAR) containing the `Index`. */ + index: Schema.link({ code: CAR.code }), + }), + derives: (claimed, delegated) => + and(equalWith(claimed, delegated)) || + and(equal(claimed.nb.index, delegated.nb.index, 'index')) || + 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/src/types.ts b/packages/capabilities/src/types.ts index 3438425dc..a9bebca3b 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -35,6 +35,7 @@ import * as StorefrontCaps from './filecoin/storefront.js' import * as AggregatorCaps from './filecoin/aggregator.js' import * as DealTrackerCaps from './filecoin/deal-tracker.js' import * as DealerCaps from './filecoin/dealer.js' +import * as IndexCaps from './index/index.js' import * as AdminCaps from './admin.js' import * as UCANCaps from './ucan.js' import * as PlanCaps from './plan.js' @@ -53,6 +54,8 @@ export interface UCANAwait { */ export type CARLink = Link +export type Multihash = Uint8Array + export type AccountDID = DID<'mailto'> export type SpaceDID = DID<'key'> @@ -449,6 +452,37 @@ export type UploadGetFailure = UploadNotFound | Ucanto.Failure // HTTP export type HTTPPut = InferInvokedCapability +// Index +export type Index = InferInvokedCapability +export type IndexAdd = InferInvokedCapability + +export type IndexAddSuccess = Unit + +export type IndexAddFailure = + | UnknownFormat + | ShardNotFound + | SliceNotFound + | Failure + +/** The index is not in a format understood by the service. */ +export interface UnknownFormat extends Failure { + name: 'UnknownFormat' +} + +/** A shard referenced by the index is not stored in the referenced space. */ +export interface ShardNotFound extends Failure { + name: 'ShardNotFound' + /** Multihash digest of the shard that could not be found. */ + digest: Multihash +} + +/** A slice referenced by the index was not found in the specified shard. */ +export interface SliceNotFound extends Failure { + name: 'SliceNotFound' + /** Multihash digest of the slice that could not be found. */ + digest: Multihash +} + // Blob export type Blob = InferInvokedCapability export type BlobAdd = InferInvokedCapability @@ -458,9 +492,8 @@ export type ServiceBlob = InferInvokedCapability export type BlobAllocate = InferInvokedCapability export type BlobAccept = InferInvokedCapability -export type BlobMultihash = Uint8Array export interface BlobModel { - digest: BlobMultihash + digest: Multihash size: number } @@ -841,7 +874,9 @@ export type ServiceAbilityArray = [ ServiceBlob['can'], BlobAllocate['can'], BlobAccept['can'], - HTTPPut['can'] + HTTPPut['can'], + Index['can'], + IndexAdd['can'] ] /** diff --git a/packages/capabilities/test/capabilities/index.test.js b/packages/capabilities/test/capabilities/index.test.js new file mode 100644 index 000000000..25b1f7d8d --- /dev/null +++ b/packages/capabilities/test/capabilities/index.test.js @@ -0,0 +1,155 @@ +import assert from 'assert' +import { access } from '@ucanto/validator' +import { Verifier } from '@ucanto/principal' +import * as Index from '../../src/index/index.js' +import * as Capability from '../../src/top.js' +import { + alice, + service as w3, + mallory as account, + bob, +} from '../helpers/fixtures.js' +import { createCarCid, validateAuthorization } from '../helpers/utils.js' + +const top = async () => + Capability.top.delegate({ + issuer: account, + audience: alice, + with: account.did(), + }) + +const index = async () => + Index.index.delegate({ + issuer: account, + audience: alice, + with: account.did(), + proofs: [await top()], + }) + +describe('index capabilities', function () { + it('index/add can be derived from *', async () => { + const add = Index.add.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + nb: { + index: await createCarCid('test'), + }, + proofs: [await top()], + }) + + const result = await access(await add.delegate(), { + capability: Index.add, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + if (result.error) { + assert.fail(result.error.message) + } + + assert.deepEqual(result.ok.audience.did(), w3.did()) + assert.equal(result.ok.capability.can, 'index/add') + assert.deepEqual(result.ok.capability.nb, { + index: await createCarCid('test'), + }) + }) + + it('index/add can be derived from index/*', async () => { + const add = Index.add.invoke({ + issuer: alice, + audience: w3, + with: account.did(), + nb: { + index: await createCarCid('test'), + }, + proofs: [await index()], + }) + + const result = await access(await add.delegate(), { + capability: Index.add, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + if (result.error) { + assert.fail(result.error.message) + } + + assert.deepEqual(result.ok.audience.did(), w3.did()) + assert.equal(result.ok.capability.can, 'index/add') + assert.deepEqual(result.ok.capability.nb, { + index: await createCarCid('test'), + }) + }) + + it('index/add can be derived from index/* derived from *', async () => { + const index = await Index.index.delegate({ + issuer: alice, + audience: bob, + with: account.did(), + proofs: [await top()], + }) + + const add = Index.add.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + nb: { + index: await createCarCid('test'), + }, + proofs: [index], + }) + + const result = await access(await add.delegate(), { + capability: Index.add, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + if (result.error) { + assert.fail(result.error.message) + } + + assert.deepEqual(result.ok.audience.did(), w3.did()) + assert.equal(result.ok.capability.can, 'index/add') + assert.deepEqual(result.ok.capability.nb, { + index: await createCarCid('test'), + }) + }) + + it('index/add should fail when escalating index constraint', async () => { + const delegation = await Index.add.delegate({ + issuer: alice, + audience: bob, + with: account.did(), + nb: { + index: await createCarCid('test'), + }, + proofs: [await top()], + }) + + const add = Index.add.invoke({ + issuer: bob, + audience: w3, + with: account.did(), + nb: { + index: await createCarCid('test2'), + }, + proofs: [delegation], + }) + + const result = await access(await add.delegate(), { + capability: Index.add, + principal: Verifier, + authority: w3, + validateAuthorization, + }) + + assert.ok(result.error) + assert(result.error.message.includes('violates imposed index constraint')) + }) +}) diff --git a/packages/upload-api/src/types/blob.ts b/packages/upload-api/src/types/blob.ts index 21adf1094..7fbb855f2 100644 --- a/packages/upload-api/src/types/blob.ts +++ b/packages/upload-api/src/types/blob.ts @@ -6,7 +6,7 @@ import type { DID, } from '@ucanto/interface' import { - BlobMultihash, + Multihash, BlobListItem, BlobRemoveSuccess, } from '@web3-storage/capabilities/types' @@ -19,11 +19,11 @@ export type TasksStorage = Storage export interface AllocationsStorage { get: ( space: DID, - blobMultihash: BlobMultihash + blobMultihash: Multihash ) => Promise> exists: ( space: DID, - blobMultihash: BlobMultihash + blobMultihash: Multihash ) => Promise> /** Inserts an item in the table if it does not already exist. */ insert: ( @@ -36,7 +36,7 @@ export interface AllocationsStorage { /** Removes an item from the table, returning zero on size if non existent. */ remove: ( space: DID, - digest: BlobMultihash + digest: Multihash ) => Promise> } @@ -46,7 +46,7 @@ export interface ListOptions { } export interface BlobModel { - digest: BlobMultihash + digest: Multihash size: number } @@ -64,9 +64,9 @@ export interface BlobGetOutput { } export interface BlobsStorage { - has: (content: BlobMultihash) => Promise> + has: (content: Multihash) => Promise> createUploadUrl: ( - content: BlobMultihash, + content: Multihash, size: number, /** * The number of seconds before the presigned URL expires