diff --git a/packages/upload-client/src/blob.js b/packages/upload-client/src/blob.js index 69f0ac1f7..b9adda959 100644 --- a/packages/upload-client/src/blob.js +++ b/packages/upload-client/src/blob.js @@ -215,15 +215,15 @@ export async function add( const nextTasks = parseBlobAddReceiptNext(result) - const { receipt } = nextTasks.allocate + const { receipt: allocateReceipt } = nextTasks.allocate /* c8 ignore next 5 */ - if (!receipt.out.ok) { + if (!allocateReceipt.out.ok) { throw new Error(`failed ${BlobCapabilities.add.can} invocation`, { - cause: receipt.out.error, + cause: allocateReceipt.out.error, }) } - const { address } = receipt.out.ok + const { address } = allocateReceipt.out.ok if (address) { const fetchWithUploadProgress = options.fetchWithUploadProgress || @@ -280,32 +280,36 @@ export async function add( } // Invoke `conclude` with `http/put` receipt - const derivedSigner = ed25519.from( - /** @type {import('@ucanto/interface').SignerArchive} */ - (nextTasks.put.task.facts[0]['keys']) - ) - - const httpPutReceipt = await Receipt.issue({ - issuer: derivedSigner, - ran: nextTasks.put.task.cid, - result: { ok: {} }, - }) - const httpPutConcludeInvocation = createConcludeInvocation( - issuer, - // @ts-expect-error object of type unknown - audience, - httpPutReceipt - ) - const ucanConclude = await httpPutConcludeInvocation.execute(conn) - - if (!ucanConclude.out.ok) { - throw new Error(`failed ${BlobCapabilities.add.can} invocation`, { - cause: result.out.error, + let { receipt: httpPutReceipt } = nextTasks.put + if (!httpPutReceipt?.out.ok) { + const derivedSigner = ed25519.from( + /** @type {import('@ucanto/interface').SignerArchive} */ + (nextTasks.put.task.facts[0]['keys']) + ) + httpPutReceipt = await Receipt.issue({ + issuer: derivedSigner, + ran: nextTasks.put.task.cid, + result: { ok: {} }, }) + const httpPutConcludeInvocation = createConcludeInvocation( + issuer, + // @ts-expect-error object of type unknown + audience, + httpPutReceipt + ) + const ucanConclude = await httpPutConcludeInvocation.execute(conn) + if (!ucanConclude.out.ok) { + throw new Error(`failed ${BlobCapabilities.add.can} invocation`, { + cause: result.out.error, + }) + } } // Ensure the blob has been accepted - const acceptReceipt = await poll(nextTasks.accept.task.link(), options) + let { receipt: acceptReceipt } = nextTasks.accept + if (!acceptReceipt?.out.ok) { + acceptReceipt = await poll(nextTasks.accept.task.link(), options) + } const blocks = new Map( [...acceptReceipt.iterateIPLDBlocks()].map((block) => [ @@ -315,7 +319,7 @@ export async function add( ) const site = Delegation.view({ root: /** @type {import('@ucanto/interface').UCANLink} */ ( - acceptReceipt.out.ok.site + acceptReceipt.out.ok?.site ), blocks, }) diff --git a/packages/upload-client/test/blob.test.js b/packages/upload-client/test/blob.test.js index 402f15f7c..71ec04eae 100644 --- a/packages/upload-client/test/blob.test.js +++ b/packages/upload-client/test/blob.test.js @@ -16,6 +16,8 @@ import { setupBlobAddSuccessResponse, setupBlobAdd4xxResponse, setupBlobAdd5xxResponse, + setupBlobAddWithAcceptReceiptSuccessResponse, + setupBlobAddWithHttpPutReceiptSuccessResponse, receiptsEndpoint, } from './helpers/utils.js' import { fetchWithUploadProgress } from '../src/fetch-with-upload-progress.js' @@ -308,6 +310,136 @@ describe('Blob.add', () => { ) }) + it('reuses the blob/accept receipt when it is already available', async () => { + const space = await Signer.generate() + const agent = await Signer.generate() + const bytes = await randomBytes(128) + const bytesHash = await sha256.digest(bytes) + + const proofs = [ + await BlobCapabilities.add.delegate({ + issuer: space, + audience: agent, + with: space.did(), + expiration: Infinity, + }), + ] + + const service = mockService({ + ucan: { + conclude: provide(UCAN.conclude, () => { + return { ok: { time: Date.now() } } + }), + }, + space: { + blob: { + // @ts-ignore Argument of type + add: provide(BlobCapabilities.add, ({ invocation }) => { + return setupBlobAddWithAcceptReceiptSuccessResponse( + { issuer: space, audience: agent, with: space, proofs }, + invocation + ) + }), + }, + }, + }) + + const server = Server.create({ + id: serviceSigner, + service, + codec: CAR.inbound, + validateAuthorization, + }) + const connection = Client.connect({ + id: serviceSigner, + codec: CAR.outbound, + channel: server, + }) + + const { site, multihash } = await Blob.add( + { issuer: agent, with: space.did(), proofs, audience: serviceSigner }, + bytes, + { + connection, + receiptsEndpoint, + } + ) + + assert(multihash) + assert.deepEqual(multihash, bytesHash) + + assert(site) + assert.equal(site.capabilities[0].can, Assert.location.can) + // we're not verifying this as it's a mocked value + // @ts-ignore nb unknown + assert.ok(site.capabilities[0].nb.content.multihash.bytes) + }) + + it('reuses the http/put receipt when it is already available', async () => { + const space = await Signer.generate() + const agent = await Signer.generate() + const bytes = await randomBytes(128) + const bytesHash = await sha256.digest(bytes) + + const proofs = [ + await BlobCapabilities.add.delegate({ + issuer: space, + audience: agent, + with: space.did(), + expiration: Infinity, + }), + ] + + const service = mockService({ + ucan: { + conclude: provide(UCAN.conclude, () => { + return { ok: { time: Date.now() } } + }), + }, + space: { + blob: { + // @ts-ignore Argument of type + add: provide(BlobCapabilities.add, ({ invocation }) => { + return setupBlobAddWithHttpPutReceiptSuccessResponse( + { issuer: space, audience: agent, with: space, proofs }, + invocation + ) + }), + }, + }, + }) + + const server = Server.create({ + id: serviceSigner, + service, + codec: CAR.inbound, + validateAuthorization, + }) + const connection = Client.connect({ + id: serviceSigner, + codec: CAR.outbound, + channel: server, + }) + + const { site, multihash } = await Blob.add( + { issuer: agent, with: space.did(), proofs, audience: serviceSigner }, + bytes, + { + connection, + receiptsEndpoint, + } + ) + + assert(multihash) + assert.deepEqual(multihash, bytesHash) + + assert(site) + assert.equal(site.capabilities[0].can, Assert.location.can) + // we're not verifying this as it's a mocked value + // @ts-ignore nb unknown + assert.ok(site.capabilities[0].nb.content.multihash.bytes) + }) + it('throws for bucket URL client error 4xx', async () => { const space = await Signer.generate() const agent = await Signer.generate() diff --git a/packages/upload-client/test/helpers/receipts-server.js b/packages/upload-client/test/helpers/receipts-server.js index cc74b77ba..eae359f74 100644 --- a/packages/upload-client/test/helpers/receipts-server.js +++ b/packages/upload-client/test/helpers/receipts-server.js @@ -1,43 +1,16 @@ -import { createServer } from 'http' -import { parseLink } from '@ucanto/server' -import * as Signer from '@ucanto/principal/ed25519' -import { Receipt, Message } from '@ucanto/core' import * as CAR from '@ucanto/transport/car' -import { Assert } from '@web3-storage/content-claims/capability' +import { Message } from '@ucanto/core' +import { createServer } from 'http' import { randomCAR } from './random.js' +import { generateAcceptReceipt } from '../helpers/utils.js' const port = process.env.PORT ?? 9201 /** * @param {string} taskCid */ -const generateReceipt = async (taskCid) => { - const issuer = await Signer.generate() - const content = (await randomCAR(128)).cid - const locationClaim = await Assert.location.delegate({ - issuer, - audience: issuer, - with: issuer.toDIDKey(), - nb: { - content, - location: ['http://localhost'], - }, - expiration: Infinity, - }) - - const receipt = await Receipt.issue({ - issuer, - fx: { - fork: [locationClaim], - }, - ran: parseLink(taskCid), - result: { - ok: { - site: locationClaim.link(), - }, - }, - }) - +const encodeReceipt = async (taskCid) => { + const receipt = await generateAcceptReceipt(taskCid) const message = await Message.build({ receipts: [receipt], }) @@ -54,11 +27,11 @@ const server = createServer(async (req, res) => { res.writeHead(404) res.end() } else if (taskCid === 'failed') { - const body = await generateReceipt((await randomCAR(128)).cid.toString()) + const body = await encodeReceipt((await randomCAR(128)).cid.toString()) res.writeHead(200) res.end(body) } else { - const body = await generateReceipt(taskCid) + const body = await encodeReceipt(taskCid) res.writeHead(200) res.end(body) } diff --git a/packages/upload-client/test/helpers/utils.js b/packages/upload-client/test/helpers/utils.js index e24bef3d3..b35f0a171 100644 --- a/packages/upload-client/test/helpers/utils.js +++ b/packages/upload-client/test/helpers/utils.js @@ -1,9 +1,13 @@ +import { parseLink } from '@ucanto/server' +import * as Signer from '@ucanto/principal/ed25519' import { Receipt } from '@ucanto/core' +import { Assert } from '@web3-storage/content-claims/capability' import * as Server from '@ucanto/server' import * as HTTP from '@web3-storage/capabilities/http' import * as W3sBlobCapabilities from '@web3-storage/capabilities/web3.storage/blob' import { W3sBlob } from '@web3-storage/capabilities' import { createConcludeInvocation } from '../../../upload-client/src/blob.js' +import { randomCAR } from './random.js' export const validateAuthorization = () => ({ ok: {} }) @@ -15,7 +19,13 @@ export const setupBlobAddSuccessResponse = async function ( // @ts-ignore invocation ) { - return setupBlobAddResponse('http://localhost:9200', options, invocation) + return setupBlobAddResponse( + 'http://localhost:9200', + options, + invocation, + false, + false + ) } export const setupBlobAdd4xxResponse = async function ( @@ -24,7 +34,13 @@ export const setupBlobAdd4xxResponse = async function ( // @ts-ignore invocation ) { - return setupBlobAddResponse('http://localhost:9400', options, invocation) + return setupBlobAddResponse( + 'http://localhost:9400', + options, + invocation, + false, + false + ) } export const setupBlobAdd5xxResponse = async function ( @@ -33,16 +49,58 @@ export const setupBlobAdd5xxResponse = async function ( // @ts-ignore invocation ) { - return setupBlobAddResponse('http://localhost:9500', options, invocation) + return setupBlobAddResponse( + 'http://localhost:9500', + options, + invocation, + false, + false + ) } -const setupBlobAddResponse = async function ( +export const setupBlobAddWithAcceptReceiptSuccessResponse = async function ( + // @ts-ignore + options, // @ts-ignore + invocation +) { + return setupBlobAddResponse( + 'http://localhost:9200', + options, + invocation, + false, + true + ) +} + +export const setupBlobAddWithHttpPutReceiptSuccessResponse = async function ( + // @ts-ignore + options, + // @ts-ignore + invocation +) { + return setupBlobAddResponse( + 'http://localhost:9200', + options, + invocation, + true, + false + ) +} + +/** + * @param {string} url + * @param {boolean} hasHttpPutReceipt + * @param {boolean} hasAcceptReceipt + */ +const setupBlobAddResponse = async function ( url, // @ts-ignore { issuer, with: space, proofs, audience }, // @ts-ignore - invocation + invocation, + hasHttpPutReceipt, + hasAcceptReceipt ) { const blob = invocation.capabilities[0].nb.blob const blobAllocateTask = await W3sBlob.allocate @@ -97,6 +155,18 @@ const setupBlobAddResponse = async function ( expiration: Infinity, }) .delegate() + const blobPutReceipt = !hasHttpPutReceipt + ? await Receipt.issue({ + issuer, + ran: blobPutTask.cid, + result: { error: new Error() }, + }) + : await generateAcceptReceipt(blobPutTask.cid.toString()) + const blobConcludePut = await createConcludeInvocation( + issuer, + audience, + blobPutReceipt + ).delegate() const blobAcceptTask = await W3sBlobCapabilities.accept .invoke({ @@ -112,11 +182,14 @@ const setupBlobAddResponse = async function ( }) .delegate() - const blobAcceptReceipt = await Receipt.issue({ - issuer, - ran: blobAcceptTask.cid, - result: { ok: {} }, - }) + // FIXME not generating the right kind of receipt here, but it should be enough for mocking + const blobAcceptReceipt = !hasAcceptReceipt + ? await Receipt.issue({ + issuer, + ran: blobAcceptTask.cid, + result: { error: new Error() }, + }) + : await generateAcceptReceipt(blobAcceptTask.cid.toString()) const blobConcludeAccept = await createConcludeInvocation( issuer, audience, @@ -131,6 +204,39 @@ const setupBlobAddResponse = async function ( .fork(blobAllocateTask) .fork(blobConcludeAllocate) .fork(blobPutTask) + .fork(blobConcludePut) .fork(blobAcceptTask) .fork(blobConcludeAccept) } + +/** + * @param {string} taskCid + * @returns {Promise} + */ +export const generateAcceptReceipt = async (taskCid) => { + const issuer = await Signer.generate() + const content = (await randomCAR(128)).cid + const locationClaim = await Assert.location.delegate({ + issuer, + audience: issuer, + with: issuer.toDIDKey(), + nb: { + content, + location: ['http://localhost'], + }, + expiration: Infinity, + }) + + return await Receipt.issue({ + issuer, + fx: { + fork: [locationClaim], + }, + ran: parseLink(taskCid), + result: { + ok: { + site: locationClaim.link(), + }, + }, + }) +}