diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index cfa37795e..635d247cb 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -69,10 +69,3 @@ jobs: - run: pnpm -r --filter @web3-storage/upload-client publish --tag next --access public env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - deploy-docs: - permissions: - contents: read - pages: write - id-token: write - if: github.event.inputs.package == 'docs' - uses: './.github/workflows/reusable-deploy-docs.yml' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe8a2804c..bcecd0fec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,20 +51,3 @@ jobs: - run: pnpm -r publish --access=public env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - docs: - permissions: - contents: read - pages: write - id-token: write - needs: release - if: | - contains(fromJson(needs.release.outputs.paths_released), 'packages/access-client') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/blob-index') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/capabilities') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/did-mailto') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/upload-client') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/upload-api') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/filecoin-client') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/filecoin-api') || - contains(fromJson(needs.release.outputs.paths_released), 'packages/w3up-client') - uses: './.github/workflows/reusable-deploy-docs.yml' diff --git a/.github/workflows/reusable-deploy-docs.yml b/.github/workflows/reusable-deploy-docs.yml deleted file mode 100644 index 994334c8f..000000000 --- a/.github/workflows/reusable-deploy-docs.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Reusable Deploy Docs -on: - workflow_call: - -jobs: - deploy-docs: - runs-on: ubuntu-latest - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2.2.3 - with: - version: 8 - - uses: actions/setup-node@v3 - with: - node-version: 18 - registry-url: 'https://registry.npmjs.org' - cache: 'pnpm' - - run: pnpm install - - run: pnpm build - - run: pnpm run lint - - run: pnpm run docs - - uses: actions/configure-pages@v2 - - uses: actions/upload-pages-artifact@v1 - with: - path: './docs' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v1 diff --git a/packages/capabilities/src/index/index.js b/packages/capabilities/src/index/index.js index 44df4f10b..d11a81352 100644 --- a/packages/capabilities/src/index/index.js +++ b/packages/capabilities/src/index/index.js @@ -39,7 +39,7 @@ export const add = capability({ with: SpaceDID, nb: Schema.struct({ /** Content Archive (CAR) containing the `Index`. */ - index: Schema.link({ code: CAR.code }), + index: Schema.link({ code: CAR.code, version: 1 }), }), derives: (claimed, delegated) => and(equalWith(claimed, delegated)) || diff --git a/packages/upload-api/package.json b/packages/upload-api/package.json index 29ec847ed..387a75ecf 100644 --- a/packages/upload-api/package.json +++ b/packages/upload-api/package.json @@ -201,11 +201,10 @@ "@web3-storage/access": "workspace:^", "@web3-storage/blob-index": "workspace:^", "@web3-storage/capabilities": "workspace:^", - "@web3-storage/content-claims": "^5.0.0", + "@web3-storage/content-claims": "^5.1.0", "@web3-storage/did-mailto": "workspace:^", "@web3-storage/filecoin-api": "workspace:^", "multiformats": "^12.1.2", - "p-retry": "^5.1.2", "uint8arrays": "^5.0.3" }, "devDependencies": { diff --git a/packages/upload-api/src/index/add.js b/packages/upload-api/src/index/add.js index 395018962..657d4e65c 100644 --- a/packages/upload-api/src/index/add.js +++ b/packages/upload-api/src/index/add.js @@ -2,6 +2,7 @@ import * as Server from '@ucanto/server' import { ok, error } from '@ucanto/server' import * as Index from '@web3-storage/capabilities/index' import { ShardedDAGIndex } from '@web3-storage/blob-index' +import { Assert } from '@web3-storage/content-claims/capability' import { concat } from 'uint8arrays' import * as API from '../types.js' @@ -61,13 +62,21 @@ const add = async ({ capability }, context) => { shardDigests.map((s) => assertAllocated(context, space, s, 'ShardNotFound')) ) for (const res of shardAllocRes) { - if (!res.ok) return res + if (res.error) return res } // TODO: randomly validate slices in the index correspond to slices in the blob - // publish the index data to IPNI - return context.ipniService.publish(idxRes.ok) + const publishRes = await Promise.all([ + // publish the index data to IPNI + context.ipniService.publish(idxRes.ok), + // publish a content claim for the index + publishIndexClaim(context, { content: idxRes.ok.content, index: idxLink }), + ]) + for (const res of publishRes) { + if (res.error) return res + } + return ok({}) } /** @@ -87,3 +96,23 @@ const assertAllocated = async (context, space, digest, errorName) => { ) return ok({}) } + +/** + * @param {API.ClaimsClientContext} ctx + * @param {{ content: API.UnknownLink, index: API.CARLink }} params + */ +const publishIndexClaim = async (ctx, { content, index }) => { + const { invocationConfig, connection } = ctx.claimsService + const { issuer, audience, with: resource, proofs } = invocationConfig + const res = await Assert.index + .invoke({ + issuer, + audience, + with: resource, + nb: { content, index }, + expiration: Infinity, + proofs, + }) + .execute(connection) + return res.out +} diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 55ef6730f..9329503ae 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -204,6 +204,8 @@ import { StorageGetError } from './types/storage.js' import { AllocationsStorage, BlobsStorage, BlobAddInput } from './types/blob.js' export type { AllocationsStorage, BlobsStorage, BlobAddInput } import { IPNIService, IndexServiceContext } from './types/index.js' +import { ClaimsClientConfig } from './types/content-claims.js' +import { Claim } from '@web3-storage/content-claims/client/api' export type { IndexServiceContext, IPNIService, @@ -211,6 +213,12 @@ export type { BlobNotFound, ShardedDAGIndex, } from './types/index.js' +export type { + ClaimsInvocationConfig, + ClaimsClientConfig, + ClaimsClientContext, + Service as ClaimsService, +} from './types/content-claims.js' export interface Service extends StorefrontService, W3sService { store: { @@ -392,7 +400,6 @@ export type UploadServiceContext = ConsumerServiceContext & ConcludeServiceContext & { signer: EdSigner.Signer uploadTable: UploadTable - dudewhereBucket: DudewhereBucket } export interface AccessClaimContext { @@ -591,6 +598,18 @@ export interface UcantoServerTestContext ipniService: IPNIService & { query(digest: MultihashDigest): Promise> } + + carStoreBucket: CarStoreBucket & Deactivator + blobsStorage: BlobsStorage & Deactivator + claimsService: ClaimsClientConfig & ClaimReader & Deactivator +} + +export interface ClaimReader { + read(digest: MultihashDigest): Promise> +} + +export interface Deactivator { + deactivate: () => Promise } export interface StoreTestContext {} @@ -628,10 +647,6 @@ export interface CarStoreBucketService { use(options?: CarStoreBucketOptions): Promise } -export interface DudewhereBucket { - put: (dataCid: string, carCid: string) => Promise -} - /** * Indicates the requested record was not present in the table. */ diff --git a/packages/upload-api/src/types/content-claims.ts b/packages/upload-api/src/types/content-claims.ts new file mode 100644 index 000000000..cb18c8756 --- /dev/null +++ b/packages/upload-api/src/types/content-claims.ts @@ -0,0 +1,31 @@ +import { + ConnectionView, + DID, + Principal, + Proof, + Signer, +} from '@ucanto/interface' +import { Service } from '@web3-storage/content-claims/server/service/api' + +export type { ConnectionView, DID, Principal, Proof, Signer } +export type { Service } + +export interface ClaimsInvocationConfig { + /** Signing authority issuing the UCAN invocation(s). */ + issuer: Signer + /** The principal delegated to in the current UCAN. */ + audience: Principal + /** The resource the invocation applies to. */ + with: DID + /** Proof(s) the issuer has the capability to perform the action. */ + proofs?: Proof[] +} + +export interface ClaimsClientConfig { + invocationConfig: ClaimsInvocationConfig + connection: ConnectionView +} + +export interface ClaimsClientContext { + claimsService: ClaimsClientConfig +} diff --git a/packages/upload-api/src/types/index.ts b/packages/upload-api/src/types/index.ts index cd963971e..2aaf4c89a 100644 --- a/packages/upload-api/src/types/index.ts +++ b/packages/upload-api/src/types/index.ts @@ -2,8 +2,9 @@ import { MultihashDigest } from 'multiformats' import { Failure, Result, Unit } from '@ucanto/interface' import { ShardedDAGIndex } from '@web3-storage/blob-index/types' import { AllocationsStorage } from './blob.js' +import { ClaimsClientContext } from './content-claims.js' -export type { ShardedDAGIndex } +export type { ShardedDAGIndex, ClaimsClientContext } /** * Service that allows publishing a set of multihashes to IPNI for a @@ -26,7 +27,7 @@ export interface BlobRetriever { ): Promise, BlobNotFound>> } -export interface IndexServiceContext { +export interface IndexServiceContext extends ClaimsClientContext { allocationsStorage: AllocationsStorage blobRetriever: BlobRetriever ipniService: IPNIService diff --git a/packages/upload-api/src/upload/add.js b/packages/upload-api/src/upload/add.js index 9c861570a..ff343b245 100644 --- a/packages/upload-api/src/upload/add.js +++ b/packages/upload-api/src/upload/add.js @@ -1,4 +1,3 @@ -import pRetry from 'p-retry' import * as Server from '@ucanto/server' import * as Upload from '@web3-storage/capabilities/upload' import * as API from '../types.js' @@ -10,7 +9,7 @@ import { allocate } from '../space-allocate.js' */ export function uploadAddProvider(context) { return Server.provide(Upload.add, async ({ capability, invocation }) => { - const { uploadTable, dudewhereBucket } = context + const { uploadTable } = context const { root, shards } = capability.nb const space = /** @type {import('@ucanto/interface').DIDKey} */ ( Server.DID.parse(capability.with).did() @@ -28,39 +27,12 @@ export function uploadAddProvider(context) { return allocated } - const [res] = await Promise.all([ - // Store in Database - uploadTable.upsert({ - space, - root, - shards, - issuer, - invocation: invocation.cid, - }), - writeDataCidToCarCidsMapping(dudewhereBucket, root, shards), - ]) - - return res - }) -} - -/** - * Writes to a "bucket DB" the mapping from a data CID to the car CIDs it is composed of. - * Retries up to 3 times, in case of failures. - * - * @param {import("../types.js").DudewhereBucket} dudewhereStore - * @param {Server.API.Link} root - * @param {Server.API.Link[] | undefined} shards - */ -async function writeDataCidToCarCidsMapping(dudewhereStore, root, shards) { - const dataCid = root.toString() - const carCids = - shards?.map((/** @type {{ toString: () => any; }} */ s) => s.toString()) || - [] - - return Promise.all( - carCids.map(async (/** @type {string} */ carCid) => { - await pRetry(() => dudewhereStore.put(dataCid, carCid), { retries: 3 }) + return uploadTable.upsert({ + space, + root, + shards, + issuer, + invocation: invocation.cid, }) - ) + }) } diff --git a/packages/upload-api/test/external-service/content-claims.js b/packages/upload-api/test/external-service/content-claims.js new file mode 100644 index 000000000..5677a46f5 --- /dev/null +++ b/packages/upload-api/test/external-service/content-claims.js @@ -0,0 +1,131 @@ +import * as API from '../../src/types.js' +import { connect } from '@ucanto/client' +import { ed25519 } from '@ucanto/principal' +import { CAR, HTTP } from '@ucanto/transport' +import { Assert } from '@web3-storage/content-claims/capability' +import * as Client from '@web3-storage/content-claims/client' +import * as Server from '@web3-storage/content-claims/server' +import { DigestMap } from '@web3-storage/blob-index' + +/** + * @param {object} params + * @param {API.Signer} params.serviceSigner + * @param {API.Transport.Channel} params.channel + * @returns {Promise} + */ +export const create = async ({ serviceSigner, channel }) => { + const agent = await ed25519.generate() + const proofs = [ + await Assert.assert.delegate({ + issuer: serviceSigner, + with: serviceSigner.did(), + audience: agent, + }), + ] + return { + invocationConfig: { + issuer: agent, + with: serviceSigner.did(), + audience: serviceSigner, + proofs, + }, + connection: connect({ + id: serviceSigner, + codec: CAR.outbound, + channel, + }), + } +} + +/** + * @param {{ http?: import('node:http') }} [options] + * @returns {Promise} + */ +export const activate = async ({ http } = {}) => { + const serviceSigner = await ed25519.generate() + + const claimStore = new ClaimStorage() + /** @param {API.MultihashDigest} content */ + const read = async (content) => { + /** @type {import('@web3-storage/content-claims/client/api').Claim[]} */ + const claims = [] + await Server.walkClaims( + { claimFetcher: claimStore }, + content, + new Set() + ).pipeTo( + new WritableStream({ + async write(block) { + const claim = await Client.decode(block.bytes) + claims.push(claim) + }, + }) + ) + return { ok: claims } + } + + const server = Server.createServer({ + id: serviceSigner, + codec: CAR.inbound, + claimStore, + validateAuthorization: () => ({ ok: {} }), + }) + + if (!http) { + const conf = await create({ serviceSigner, channel: server }) + return Object.assign(conf, { read, deactivate: async () => {} }) + } + + const httpServer = http.createServer(async (req, res) => { + const chunks = [] + for await (const chunk of req) { + chunks.push(chunk) + } + + const { headers, body } = await server.request({ + // @ts-expect-error + headers: req.headers, + body: new Uint8Array(await new Blob(chunks).arrayBuffer()), + }) + + res.writeHead(200, headers) + res.write(body) + res.end() + }) + await new Promise((resolve) => httpServer.listen(resolve)) + // @ts-expect-error + const { port } = httpServer.address() + const serviceURL = new URL(`http://127.0.0.1:${port}`) + + const channel = HTTP.open({ url: serviceURL, method: 'POST' }) + const conf = await create({ serviceSigner, channel }) + return Object.assign(conf, { + read, + deactivate: () => + new Promise((resolve, reject) => { + httpServer.closeAllConnections() + httpServer.close((err) => { + err ? reject(err) : resolve(undefined) + }) + }), + }) +} + +class ClaimStorage { + constructor() { + /** @type {Map} */ + this.data = new DigestMap() + } + + /** @param {import('@web3-storage/content-claims/server/api').Claim} claim */ + async put(claim) { + const claims = this.data.get(claim.content) ?? [] + claims.push(claim) + this.data.set(claim.content, claims) + } + + /** @param {API.MultihashDigest} content */ + async get(content) { + return this.data.get(content) ?? [] + } +} diff --git a/packages/upload-api/test/external-service/index.js b/packages/upload-api/test/external-service/index.js index fe65f50b5..3dc000b45 100644 --- a/packages/upload-api/test/external-service/index.js +++ b/packages/upload-api/test/external-service/index.js @@ -1,5 +1,11 @@ -import { IPNIService } from './ipni-service.js' +import { IPNIService } from './ipni.js' +import * as ClaimsService from './content-claims.js' -export const getExternalServiceImplementations = async () => ({ +/** + * @param {object} [options] + * @param {import('node:http')} [options.http] + */ +export const getExternalServiceImplementations = async (options) => ({ ipniService: new IPNIService(), + claimsService: await ClaimsService.activate(options), }) diff --git a/packages/upload-api/test/external-service/ipni-service.js b/packages/upload-api/test/external-service/ipni.js similarity index 100% rename from packages/upload-api/test/external-service/ipni-service.js rename to packages/upload-api/test/external-service/ipni.js diff --git a/packages/upload-api/test/handlers/index.js b/packages/upload-api/test/handlers/index.js index 95e600053..d74ff04d7 100644 --- a/packages/upload-api/test/handlers/index.js +++ b/packages/upload-api/test/handlers/index.js @@ -171,4 +171,78 @@ export const test = { assert.ok(receipt.out.error) assert.equal(receipt.out.error?.name, 'ShardNotFound') }, + 'index/add should publish index claim': async (assert, context) => { + const { proof, spaceDid } = await registerSpace(alice, context) + const contentCAR = await randomCAR(32) + const contentCARBytes = new Uint8Array(await contentCAR.arrayBuffer()) + + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + // upload the content CAR to the space + await uploadBlob( + context, + { + connection, + issuer: alice, + audience: context.id, + with: spaceDid, + proofs: [proof], + }, + { + cid: contentCAR.cid, + bytes: contentCARBytes, + } + ) + + const index = await fromShardArchives(contentCAR.roots[0], [ + contentCARBytes, + ]) + const indexCAR = Result.unwrap(await index.archive()) + const indexLink = await CAR.link(indexCAR) + + // upload the index CAR to the space + await uploadBlob( + context, + { + connection, + issuer: alice, + audience: context.id, + with: spaceDid, + proofs: [proof], + }, + { + cid: indexLink, + bytes: indexCAR, + } + ) + + const indexAdd = IndexCapabilities.add.invoke({ + issuer: alice, + audience: context.id, + with: spaceDid, + nb: { index: indexLink }, + proofs: [proof], + }) + const receipt = await indexAdd.execute(connection) + Result.try(receipt.out) + + // ensure an index claim exists for the content root + const claims = Result.unwrap( + await context.claimsService.read(contentCAR.roots[0].multihash) + ) + + let found = false + for (const c of claims) { + if ( + c.type === 'assert/index' && + c.index.toString() === indexLink.toString() + ) { + found = true + } + } + assert.ok(found, 'did not found index claim') + }, } diff --git a/packages/upload-api/test/helpers/context.js b/packages/upload-api/test/helpers/context.js index cbe8edddb..a6f58a3ac 100644 --- a/packages/upload-api/test/helpers/context.js +++ b/packages/upload-api/test/helpers/context.js @@ -5,8 +5,6 @@ import { getStoreImplementations as getFilecoinStoreImplementations, getQueueImplementations as getFilecoinQueueImplementations, } from '@web3-storage/filecoin-api/test/context/service' -import { BlobsStorage } from '../storage/blobs-storage.js' -import { CarStoreBucket } from '../storage/car-store-bucket.js' import * as Email from '../../src/utils/email.js' import { create as createRevocationChecker } from '../../src/utils/revocation.js' import { createServer, connect } from '../../src/lib.js' @@ -51,7 +49,7 @@ export const createContext = async ( } = getFilecoinStoreImplementations() const email = Email.debug() - const externalServices = await getExternalServiceImplementations() + const externalServices = await getExternalServiceImplementations(options) /** @type { import('../../src/types.js').UcantoServerContext } */ const serviceContext = { @@ -100,6 +98,7 @@ export const createContext = async ( return { ...serviceContext, + ...serviceStores, ...externalServices, mail: /** @type {TestTypes.DebugEmail} */ (serviceContext.email), service: /** @type {TestTypes.ServiceSigner} */ (serviceContext.id), @@ -113,14 +112,9 @@ export const createContext = async ( * * @param {Types.UcantoServerTestContext} context */ -export const cleanupContext = async (context) => { - /** @type {CarStoreBucket & { deactivate: () => Promise }}} */ - // @ts-ignore type misses S3 bucket properties like accessKey - const carStoreBucket = context.carStoreBucket - await carStoreBucket.deactivate() - - /** @type {BlobsStorage & { deactivate: () => Promise }}} */ - // @ts-ignore type misses S3 bucket properties like accessKey - const blobsStorage = context.blobsStorage - await blobsStorage.deactivate() -} +export const cleanupContext = (context) => + Promise.all([ + context.carStoreBucket.deactivate(), + context.blobsStorage.deactivate(), + context.claimsService.deactivate(), + ]) diff --git a/packages/upload-api/test/storage/dude-where-bucket.js b/packages/upload-api/test/storage/dude-where-bucket.js deleted file mode 100644 index a660a69c2..000000000 --- a/packages/upload-api/test/storage/dude-where-bucket.js +++ /dev/null @@ -1,15 +0,0 @@ -export class DudewhereBucket { - constructor() { - /** - * @type {{dataCid:string, carCid:string}[]} - */ - this.items = [] - } - /** - * @param {string} dataCid - * @param {string} carCid - */ - async put(dataCid, carCid) { - this.items.push({ dataCid, carCid }) - } -} diff --git a/packages/upload-api/test/storage/index.js b/packages/upload-api/test/storage/index.js index 3a6e738c5..c4aab381a 100644 --- a/packages/upload-api/test/storage/index.js +++ b/packages/upload-api/test/storage/index.js @@ -3,7 +3,6 @@ import { BlobsStorage } from './blobs-storage.js' import { CarStoreBucket } from './car-store-bucket.js' import { StoreTable } from './store-table.js' import { UploadTable } from './upload-table.js' -import { DudewhereBucket } from './dude-where-bucket.js' import { ProvisionsStorage } from './provisions-storage.js' import { DelegationsStorage } from './delegations-storage.js' import { RateLimitsStorage } from './rate-limits-storage.js' @@ -26,7 +25,6 @@ export async function getServiceStorageImplementations(options) { const uploadTable = new UploadTable() const blobsStorage = await BlobsStorage.activate(options) const carStoreBucket = await CarStoreBucket.activate(options) - const dudewhereBucket = new DudewhereBucket() const revocationsStorage = new RevocationsStorage() const plansStorage = new PlansStorage() const usageStorage = new UsageStorage(storeTable, allocationsStorage) @@ -43,7 +41,6 @@ export async function getServiceStorageImplementations(options) { blobsStorage, blobRetriever: blobsStorage, carStoreBucket, - dudewhereBucket, revocationsStorage, plansStorage, usageStorage, diff --git a/packages/upload-client/test/index.test.js b/packages/upload-client/test/index.test.js index 9dc5bb973..4c3b77911 100644 --- a/packages/upload-client/test/index.test.js +++ b/packages/upload-client/test/index.test.js @@ -163,7 +163,8 @@ describe('uploadFile', () => { assert.equal(dataCID.toString(), expectedCar.roots[0].toString()) }) - it('allows custom shard size to be set', async () => { + it('allows custom shard size to be set', async function () { + this.timeout(10_000) const space = await Signer.generate() const agent = await Signer.generate() // The "user" that will ask the service to accept the upload const bytes = await randomBytes(1024 * 1024 * 5) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba0ae0d17..25497f4e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,8 +447,8 @@ importers: specifier: workspace:^ version: link:../capabilities '@web3-storage/content-claims': - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^5.1.0 + version: 5.1.0 '@web3-storage/did-mailto': specifier: workspace:^ version: link:../did-mailto @@ -458,9 +458,6 @@ importers: multiformats: specifier: ^12.1.2 version: 12.1.3 - p-retry: - specifier: ^5.1.2 - version: 5.1.2 uint8arrays: specifier: ^5.0.3 version: 5.0.3 @@ -4470,6 +4467,17 @@ packages: carstream: 2.1.0 multiformats: 13.1.0 + /@web3-storage/content-claims@5.1.0: + resolution: {integrity: sha512-3VStFKoeieRpRU7brFjKTsAuAffQzYDIZ8F3Gh0+niw+MgzBK72osW+fftdquT8neWir34Ndu3mBUKKJ3ck1RQ==} + dependencies: + '@ucanto/client': 9.0.1 + '@ucanto/interface': 10.0.1 + '@ucanto/server': 10.0.0 + '@ucanto/transport': 9.1.1 + carstream: 2.1.0 + multiformats: 13.1.0 + dev: false + /@web3-storage/data-segment@3.2.0: resolution: {integrity: sha512-SM6eNumXzrXiQE2/J59+eEgCRZNYPxKhRoHX2QvV3/scD4qgcf4g+paWBc3UriLEY1rCboygGoPsnqYJNyZyfA==} dependencies: