diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index fca9a67bf..f1c2bf534 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -244,6 +244,33 @@ export class Agent { return receipt.out } + /** + * Get receipts from executed task. Optionally follow tasks effects if already available. + * + * @param {API.UnknownLink} taskCid + * @param {object} [options] + * @param {boolean} [options.follow] + */ + async getTaskReceipts(taskCid, options = {}) { + const result = await this.invokeAndExecute(UCAN.receipt, { + with: this.issuer.did(), + nb: { + task: taskCid, + follow: options.follow || false, + }, + }) + + if (!result.out.ok) { + throw new Error(`failed ${UCAN.receipt.can} invocation`, { + cause: result.out.error, + }) + } + + // @ts-ignore no type on receipt output + const message = await CAR.outbound.decode(result.out.ok) + return message.receipts + } + /** * Get all the proofs matching the capabilities. * diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index 0bcc24011..a51d47223 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -46,6 +46,9 @@ import type { UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure, + UCANReceipt, + UCANReceiptSuccess, + UCANReceiptFailure, AccountDID, ProviderDID, SpaceDID, @@ -128,6 +131,7 @@ export interface Service { } ucan: { revoke: ServiceMethod + receipt: ServiceMethod } plan: { get: ServiceMethod diff --git a/packages/access-client/test/agent.test.js b/packages/access-client/test/agent.test.js index df10989d3..c1a2ada4b 100644 --- a/packages/access-client/test/agent.test.js +++ b/packages/access-client/test/agent.test.js @@ -1,5 +1,6 @@ import assert from 'assert' import * as ucanto from '@ucanto/core' +import { CAR } from '@ucanto/transport' import { URI } from '@ucanto/validator' import { Delegation, provide } from '@ucanto/server' import { Agent, Access, AgentData, connection } from '../src/agent.js' @@ -406,6 +407,142 @@ describe('Agent', function () { assert(result5.ok, `failed to revoke: ${result5.error?.message}`) }) + it('can get receipts for a given task', async () => { + const service = await ed25519.Signer.generate() + const server = createServer({ + ucan: { + /** + * + * @type {import('@ucanto/interface').ServiceMethod} + */ + receipt: provide(UCAN.receipt, async ({ capability, invocation }) => { + const { task } = capability.nb + const receipt = await ucanto.Receipt.issue({ + issuer: service, + // @ts-expect-error not specific CID multicoded + ran: task, + result: { + ok: {}, + }, + }) + // Encode receipts as an `ucanto` message so that they can be decoded on the other end + const message = await ucanto.Message.build({ receipts: [receipt] }) + const request = await CAR.outbound.encode(message) + + return { + ok: request, + } + }), + }, + }) + + const alice = await Agent.create(undefined, { + connection: connection({ principal: server.id, channel: server }), + }) + const task = ucanto.parseLink( + 'bafyreie4sutqdtk36msxzdnrgy3iawlgjfinszfl4hxkg3plvnhv7a2dea' + ) + const receiptChain = await alice.getTaskReceipts(task) + + assert.ok(receiptChain) + assert.equal(receiptChain.size, 1) + assert.ok( + receiptChain + .get(Array.from(receiptChain.keys())[0]) + ?.ran.link() + .equals(task) + ) + }) + + it('can get receipts for a given task and follow their effects', async () => { + const service = await ed25519.Signer.generate() + const server = createServer({ + ucan: { + /** + * + * @type {import('@ucanto/interface').ServiceMethod} + */ + receipt: provide(UCAN.receipt, async ({ capability, invocation }) => { + const { task } = capability.nb + const effectCid = ucanto.parseLink( + 'bafyreia5hlrtz52ozd2nnnru6kx2xqewgh3bb5itnydkgt6olrdlrmpdui' + ) + const effectReceipt = await ucanto.Receipt.issue({ + issuer: service, + // another invocation CID + ran: effectCid, + result: { + ok: {}, + }, + }) + const receipt = await ucanto.Receipt.issue({ + issuer: service, + // @ts-expect-error not specific CID multicoded + ran: task, + result: { + ok: {}, + }, + fx: { + join: effectCid, + fork: [], + }, + }) + // Encode receipts as an `ucanto` message so that they can be decoded on the other end + const message = await ucanto.Message.build({ + receipts: [receipt, effectReceipt], + }) + const request = await CAR.outbound.encode(message) + + return { + ok: request, + } + }), + }, + }) + + const alice = await Agent.create(undefined, { + connection: connection({ principal: server.id, channel: server }), + }) + const task = ucanto.parseLink( + 'bafyreie4sutqdtk36msxzdnrgy3iawlgjfinszfl4hxkg3plvnhv7a2dea' + ) + const receiptChain = await alice.getTaskReceipts(task) + + assert.ok(receiptChain) + assert.equal(receiptChain.size, 2) + }) + + it('can handle receipt not found response', async () => { + const server = createServer({ + ucan: { + /** + * + * @type {import('@ucanto/interface').ServiceMethod} + */ + receipt: provide(UCAN.receipt, async ({ capability, invocation }) => { + return { + error: { + name: 'ReceiptNotFound', + message: 'Could not find receipt', + }, + } + }), + }, + }) + + const alice = await Agent.create(undefined, { + connection: connection({ principal: server.id, channel: server }), + }) + const task = ucanto.parseLink( + 'bafyreie4sutqdtk36msxzdnrgy3iawlgjfinszfl4hxkg3plvnhv7a2dea' + ) + + await assert.rejects( + () => alice.getTaskReceipts(task), + /failed ucan\/receipt invocation/ + ) + }) + /** * An agent may manage a bunch of different proofs for the same agent key. e.g. proofs may authorize agent key to access various other service providers, each of which may have issued its own session. * When one of the proofs is a session proof issued by w3upA or w3upB, the Agent#proofs result should contain proofs appropriate for the session host. diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index d80fbff46..c53d713f8 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -63,6 +63,7 @@ export const abilitiesAsStrings = [ Access.access.can, Access.authorize.can, UCAN.attest.can, + UCAN.receipt.can, Customer.get.can, Consumer.has.can, Consumer.get.can, diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index d6c58dab6..9435efb7e 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -499,6 +499,7 @@ export interface UploadListSuccess extends ListResponse {} export type UCANRevoke = InferInvokedCapability export type UCANAttest = InferInvokedCapability +export type UCANReceipt = InferInvokedCapability export interface Timestamp { /** @@ -547,6 +548,17 @@ export type UCANRevokeFailure = | UnauthorizedRevocation | RevocationsStoreFailure +export type UCANReceiptSuccess = Ucanto.HTTPRequest + +/** + * Error is raised when Requested `Receipt` is not found. + */ +export interface ReceiptNotFound extends Ucanto.Failure { + name: 'ReceiptNotFound' +} + +export type UCANReceiptFailure = ReceiptNotFound + // Admin export type Admin = InferInvokedCapability export type AdminUploadInspect = InferInvokedCapability< @@ -631,6 +643,7 @@ export type AbilitiesArray = [ Access['can'], AccessAuthorize['can'], UCANAttest['can'], + UCANReceipt['can'], CustomerGet['can'], ConsumerHas['can'], ConsumerGet['can'], diff --git a/packages/capabilities/src/ucan.js b/packages/capabilities/src/ucan.js index fe38b757e..1382f3ff0 100644 --- a/packages/capabilities/src/ucan.js +++ b/packages/capabilities/src/ucan.js @@ -113,3 +113,49 @@ export const attest = capability({ // UCAN link MUST be the same checkLink(claim.nb.proof, from.nb.proof, 'nb.proof'), }) + +/** + * Issued by agent looking for receipts for a given executed task CID. + * + * @example + * ```js + * { + iss: "did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi", + aud: "did:web:web3.storage", + att: [{ + "with": "did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi", + "can": "ucan/receipt", + "nb": { + "task": { + "/": "bafyreifer23oxeyamllbmrfkkyvcqpujevuediffrpvrxmgn736f4fffui" + }, + "" + } + }], + exp: null + sig: "..." + } + * ``` + */ +export const receipt = capability({ + can: 'ucan/receipt', + /** + * DID of the agent. + */ + with: Schema.did(), + nb: Schema.struct({ + /** + * CID of the task that was execute. + */ + task: Schema.link(), + /** + * Whether should follow the receipt chain for forks and join effects. + */ + follow: Schema.boolean().optional(), + }), + derives: (claim, from) => + // With field MUST be the same + and(equalWith(claim, from)) ?? + // task link MUST be the same + checkLink(claim.nb.task, from.nb.task, 'nb.task'), +}) diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 38b8bb582..2c17246f8 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -127,6 +127,9 @@ import { UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure, + UCANReceipt, + UCANReceiptSuccess, + UCANReceiptFailure, PlanGet, PlanGetSuccess, PlanGetFailure, @@ -157,6 +160,8 @@ import { PlansStorage } from './types/plans.js' export type { PlansStorage } from './types/plans.js' import { SubscriptionsStorage } from './types/subscriptions.js' export type { SubscriptionsStorage } +import { ReceiptsStorage } from './types/receipts.js' +export type { ReceiptsStorage } export interface Service extends StorefrontService { store: { @@ -236,6 +241,7 @@ export interface Service extends StorefrontService { ucan: { revoke: ServiceMethod + receipt: ServiceMethod } admin: { @@ -277,6 +283,7 @@ export type StoreServiceContext = SpaceServiceContext & { export type UploadServiceContext = ConsumerServiceContext & SpaceServiceContext & + UcanReceiptServiceContext & RevocationServiceContext & { signer: EdSigner.Signer uploadTable: UploadTable @@ -340,6 +347,10 @@ export interface RevocationServiceContext { revocationsStorage: RevocationsStorage } +export interface UcanReceiptServiceContext { + receiptsStorage: ReceiptsStorage +} + export interface PlanServiceContext { plansStorage: PlansStorage } @@ -360,6 +371,7 @@ export interface ServiceContext SubscriptionServiceContext, RateLimitServiceContext, RevocationServiceContext, + UcanReceiptServiceContext, PlanServiceContext, UploadServiceContext, FilecoinServiceContext, diff --git a/packages/upload-api/src/types/receipts.ts b/packages/upload-api/src/types/receipts.ts new file mode 100644 index 000000000..fd6f5d1ac --- /dev/null +++ b/packages/upload-api/src/types/receipts.ts @@ -0,0 +1,22 @@ +import type { + UnknownLink, + Receipt, + Result, + Unit, + Failure, +} from '@ucanto/interface' +import { UCANReceiptFailure } from '../types.js' + +/** + * Stores receipts for executed tasks. + */ +export interface ReceiptsStorage { + /** + * Gets a record from the store. + */ + get: (key: UnknownLink) => Promise> + /** + * Puts a record into the store. + */ + put: (receipt: Receipt) => Promise> +} diff --git a/packages/upload-api/src/ucan.js b/packages/upload-api/src/ucan.js index 9bf9b14a6..01b283a5b 100644 --- a/packages/upload-api/src/ucan.js +++ b/packages/upload-api/src/ucan.js @@ -1,4 +1,5 @@ import { ucanRevokeProvider } from './ucan/revoke.js' +import { ucanReceiptProvider } from './ucan/receipt.js' import * as API from './types.js' /** @@ -7,5 +8,6 @@ import * as API from './types.js' export const createService = (context) => { return { revoke: ucanRevokeProvider(context), + receipt: ucanReceiptProvider(context), } } diff --git a/packages/upload-api/src/ucan/receipt.js b/packages/upload-api/src/ucan/receipt.js new file mode 100644 index 000000000..7b24732df --- /dev/null +++ b/packages/upload-api/src/ucan/receipt.js @@ -0,0 +1,85 @@ +import { provide } from '@ucanto/server' +import { Message } from '@ucanto/core' +import { CAR } from '@ucanto/transport' +import { receipt } from '@web3-storage/capabilities/ucan' +import * as API from '../types.js' + +/** + * @param {API.UcanReceiptServiceContext} context + * @returns {API.ServiceMethod} + */ +export const ucanReceiptProvider = ({ receiptsStorage }) => + provide(receipt, async ({ capability }) => { + const { task, follow } = capability.nb + + // Get requested receipt + const taskReceiptGet = await receiptsStorage.get(task) + if (taskReceiptGet.error) { + return { + error: taskReceiptGet.error, + } + } + + /** @type {import('@ucanto/interface').Receipt[]} */ + let receipts + if (follow) { + receipts = await followReceipt(taskReceiptGet.ok, receiptsStorage) + } else { + receipts = [taskReceiptGet.ok] + } + + // Encode receipts as an `ucanto` message so that they can be decoded on the other end + // @ts-ignore + const message = await Message.build({ receipts }) + const request = await CAR.outbound.encode(message) + + return { + ok: request, + } + }) + +/** + * Follows given receipt through its effects as a recursive function. + * Ends when either there are no forks/join effects, or the looked ones are not yet available. + * Given receipts may still not be available in the requested receipt chain, failures are ignored. + * + * @param {import('@ucanto/interface').Receipt} receipt + * @param {API.ReceiptsStorage} receiptsStorage + * @returns {Promise} + */ +const followReceipt = async (receipt, receiptsStorage) => { + let joinReceipt + if (receipt.fx.join) { + const taskReceiptGet = await receiptsStorage.get(receipt.fx.join) + // if not available, we just return + if (taskReceiptGet.ok) { + joinReceipt = taskReceiptGet.ok + } + } + + const forkReceiptsGet = await Promise.all( + receipt.fx.fork.map((f) => receiptsStorage.get(f)) + ) + // Skip the ones not found or errored + const forkReceipts = /** @type {import('@ucanto/interface').Receipt[]} */ ( + forkReceiptsGet.filter((g) => g.ok).map((g) => g.ok) + ) + + const receipts = [receipt] + // add join receipts + if (joinReceipt) { + receipts.push(...(await followReceipt(joinReceipt, receiptsStorage))) + } + // add for receipts + if (forkReceipts.length) { + const forkFollow = await Promise.all( + forkReceipts.map((f) => followReceipt(f, receiptsStorage)) + ) + + for (const receiptsToAdd of forkFollow) { + receipts.push(...receiptsToAdd) + } + } + + return receipts +} diff --git a/packages/upload-api/test/handlers/ucan.js b/packages/upload-api/test/handlers/ucan.js index 1686decd4..7e2545317 100644 --- a/packages/upload-api/test/handlers/ucan.js +++ b/packages/upload-api/test/handlers/ucan.js @@ -1,6 +1,8 @@ +import { UCAN, Console } from '@web3-storage/capabilities' +import { CAR } from '@ucanto/transport' import * as API from '../../src/types.js' import { alice, bob, mallory } from '../util.js' -import { UCAN, Console } from '@web3-storage/capabilities' +import { getReceipts } from '../helpers/receipts.js' /** * @type {API.Tests} @@ -352,4 +354,104 @@ export const test = { assert.ok(String(revoke.out.error?.message).match(/Constrain violation/)) }, + + 'issuer can retrieve a receipt without following effects': async ( + assert, + context + ) => { + const receipts = await getReceipts() + const storage = context.receiptsStorage + + // Store receipts + await Promise.all( + // @ts-expect-error no specific receipt types + receipts.map((r) => storage.put(r)) + ) + + const receiptsInv = await UCAN.receipt + .invoke({ + issuer: alice, + audience: context.id, + with: alice.did(), + nb: { + task: receipts[0].ran.link(), + follow: false, + }, + }) + .execute(context.connection) + + assert.ok(receiptsInv.out.ok) + if (receiptsInv.out.error) { + throw new Error('should not error to invoke receipt') + } + const message = await CAR.outbound.decode(receiptsInv.out.ok) + assert.equal(message.receipts.size, 1) + }, + + 'issuer can retrieve a receipt following effects fully resolved': async ( + assert, + context + ) => { + const receipts = await getReceipts() + const storage = context.receiptsStorage + + // Store receipts + await Promise.all( + // @ts-expect-error no specific receipt types + receipts.map((r) => storage.put(r)) + ) + + const receiptsInv = await UCAN.receipt + .invoke({ + issuer: alice, + audience: context.id, + with: alice.did(), + nb: { + task: receipts[0].ran.link(), + follow: true, + }, + }) + .execute(context.connection) + + assert.ok(receiptsInv.out.ok) + if (receiptsInv.out.error) { + throw new Error('should not error to invoke receipt') + } + const message = await CAR.outbound.decode(receiptsInv.out.ok) + assert.equal(message.receipts.size, receipts.length) + }, + + 'issuer can retrieve a receipt following effects partially resolved': async ( + assert, + context + ) => { + const receipts = await getReceipts() + const readyReceipts = receipts.slice(0, receipts.length - 2) + const storage = context.receiptsStorage + + // Store receipts + await Promise.all( + // @ts-expect-error no specific receipt types + readyReceipts.map((r) => storage.put(r)) + ) + + const receiptsInv = await UCAN.receipt + .invoke({ + issuer: alice, + audience: context.id, + with: alice.did(), + nb: { + task: receipts[0].ran.link(), + follow: true, + }, + }) + .execute(context.connection) + + assert.ok(receiptsInv.out.ok) + if (receiptsInv.out.error) { + throw new Error('should not error to invoke receipt') + } + const message = await CAR.outbound.decode(receiptsInv.out.ok) + assert.equal(message.receipts.size, readyReceipts.length) + }, } diff --git a/packages/upload-api/test/helpers/context.js b/packages/upload-api/test/helpers/context.js index 0c126d122..ebc47ea7f 100644 --- a/packages/upload-api/test/helpers/context.js +++ b/packages/upload-api/test/helpers/context.js @@ -22,6 +22,7 @@ import { confirmConfirmationUrl } from './utils.js' import { PlansStorage } from '../storage/plans-storage.js' import { UsageStorage } from '../storage/usage-storage.js' import { SubscriptionsStorage } from '../storage/subscriptions-storage.js' +import { ReceiptsStorage } from '../storage/receipts-storage.js' /** * @param {object} options @@ -79,6 +80,7 @@ export const createContext = async ( plansStorage, usageStorage, revocationsStorage, + receiptsStorage: new ReceiptsStorage(), errorReporter: { catch(error) { if (options.assert) { diff --git a/packages/upload-api/test/helpers/receipts.js b/packages/upload-api/test/helpers/receipts.js new file mode 100644 index 000000000..afe04d84c --- /dev/null +++ b/packages/upload-api/test/helpers/receipts.js @@ -0,0 +1,227 @@ +import * as API from '../../src/types.js' +import { Receipt, CBOR } from '@ucanto/core' +import * as Signer from '@ucanto/principal/ed25519' +import * as StorefrontCaps from '@web3-storage/capabilities/filecoin/storefront' +import * as AggregatorCaps from '@web3-storage/capabilities/filecoin/aggregator' +import * as DealerCaps from '@web3-storage/capabilities/filecoin/dealer' +import { randomAggregate } from '@web3-storage/filecoin-api/test' + +export const getReceipts = async () => { + const agentSigner = await Signer.generate() + const storefrontSigner = await Signer.generate() + const group = storefrontSigner.did() + const { pieces, aggregate } = await randomAggregate(10, 128) + const piece = pieces[0] + + // Create inclusion proof + const inclusionProof = aggregate.resolveProof(piece.link) + if (inclusionProof.error) { + throw new Error('could not compute inclusion proof') + } + // Create block + const offer = pieces.map((p) => p.link) + const piecesBlock = await CBOR.write(offer) + const dealMetadata = { + dataType: 0n, + dataSource: { + dealID: 100n, + }, + } + + const filecoinOfferInvocation = await StorefrontCaps.filecoinOffer + .invoke({ + issuer: agentSigner, + audience: storefrontSigner, + with: agentSigner.did(), + nb: { + piece: piece.link, + content: piece.content, + }, + expiration: Infinity, + }) + .delegate() + const filecoinSubmitInvocation = await StorefrontCaps.filecoinSubmit + .invoke({ + issuer: storefrontSigner, + audience: storefrontSigner, + with: storefrontSigner.did(), + nb: { + piece: piece.link, + content: piece.content, + }, + expiration: Infinity, + }) + .delegate() + const filecoinAcceptInvocation = await StorefrontCaps.filecoinAccept + .invoke({ + issuer: storefrontSigner, + audience: storefrontSigner, + with: storefrontSigner.did(), + nb: { + piece: piece.link, + content: piece.content, + }, + expiration: Infinity, + }) + .delegate() + const pieceOfferInvocation = await AggregatorCaps.pieceOffer + .invoke({ + issuer: storefrontSigner, + audience: storefrontSigner, + with: storefrontSigner.did(), + nb: { + piece: piece.link, + group, + }, + expiration: Infinity, + }) + .delegate() + const pieceAcceptInvocation = await AggregatorCaps.pieceAccept + .invoke({ + issuer: storefrontSigner, + audience: storefrontSigner, + with: storefrontSigner.did(), + nb: { + piece: piece.link, + group, + }, + expiration: Infinity, + }) + .delegate() + const aggregateOfferInvocation = await DealerCaps.aggregateOffer + .invoke({ + issuer: storefrontSigner, + audience: storefrontSigner, + with: storefrontSigner.did(), + nb: { + pieces: piecesBlock.cid, + aggregate: aggregate.link, + }, + expiration: Infinity, + }) + .delegate() + aggregateOfferInvocation.attach(piecesBlock) + const aggregateAcceptInvocation = await DealerCaps.aggregateAccept + .invoke({ + issuer: storefrontSigner, + audience: storefrontSigner, + with: storefrontSigner.did(), + nb: { + pieces: piecesBlock.cid, + aggregate: aggregate.link, + }, + expiration: Infinity, + }) + .delegate() + + // Receipts + const filecoinOfferReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: filecoinOfferInvocation.cid, + result: { + ok: /** @type {API.FilecoinOfferSuccess} */ ({ + piece: piece.link, + }), + }, + fx: { + join: filecoinAcceptInvocation.cid, + fork: [filecoinSubmitInvocation.cid], + }, + }) + + const filecoinSubmitReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: filecoinSubmitInvocation.cid, + result: { + ok: /** @type {API.FilecoinSubmitSuccess} */ ({ + piece: piece.link, + }), + }, + fx: { + join: pieceOfferInvocation.cid, + fork: [], + }, + }) + + const filecoinAcceptReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: filecoinAcceptInvocation.cid, + result: { + ok: { + piece: piece.link, + aggregate: aggregate.link, + inclusion: inclusionProof.ok, + aux: dealMetadata, + }, + }, + fx: { + join: undefined, + fork: [], + }, + }) + + const pieceOfferReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: pieceOfferInvocation.cid, + result: { + ok: /** @type {API.PieceOfferSuccess} */ ({ + piece: piece.link, + }), + }, + fx: { + join: pieceAcceptInvocation.cid, + fork: [], + }, + }) + + const pieceAcceptReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: pieceAcceptInvocation.cid, + result: { + ok: { + piece: piece.link, + aggregate: aggregate.link, + inclusion: inclusionProof.ok, + }, + }, + fx: { + join: aggregateOfferInvocation.cid, + fork: [], + }, + }) + + const aggregateOfferReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: aggregateOfferInvocation.cid, + result: { + ok: /** @type {API.AggregateOfferSuccess} */ ({ + aggregate: aggregate.link, + }), + }, + fx: { + join: aggregateAcceptInvocation.cid, + fork: [], + }, + }) + + const aggregateAcceptReceipt = await Receipt.issue({ + issuer: storefrontSigner, + ran: aggregateAcceptInvocation.cid, + result: { + ok: /** @type {API.AggregateAcceptSuccess} */ ({ + ...dealMetadata, + aggregate: aggregate.link, + }), + }, + }) + + return [ + filecoinOfferReceipt, + filecoinSubmitReceipt, + pieceOfferReceipt, + pieceAcceptReceipt, + aggregateOfferReceipt, + aggregateAcceptReceipt, + filecoinAcceptReceipt, + ] +} diff --git a/packages/upload-api/test/lib.js b/packages/upload-api/test/lib.js index 1d830e8cc..7c97f8f93 100644 --- a/packages/upload-api/test/lib.js +++ b/packages/upload-api/test/lib.js @@ -16,6 +16,7 @@ import { test as provisionsStorageTests } from './storage/provisions-storage-tes import { test as rateLimitsStorageTests } from './storage/rate-limits-storage-tests.js' import { test as revocationsStorageTests } from './storage/revocations-storage-tests.js' import { test as plansStorageTests } from './storage/plans-storage-tests.js' +import { test as receiptsStorageTests } from './storage/receipts-storage-tests.js' import { DebugEmail } from '../src/utils/email.js' export * as Context from './helpers/context.js' @@ -32,6 +33,7 @@ export const storageTests = { ...rateLimitsStorageTests, ...revocationsStorageTests, ...plansStorageTests, + ...receiptsStorageTests, } export const handlerTests = { @@ -58,5 +60,6 @@ export { rateLimitsStorageTests, revocationsStorageTests, plansStorageTests, + receiptsStorageTests, DebugEmail, } diff --git a/packages/upload-api/test/storage/receipts-storage-tests.js b/packages/upload-api/test/storage/receipts-storage-tests.js new file mode 100644 index 000000000..d659a7531 --- /dev/null +++ b/packages/upload-api/test/storage/receipts-storage-tests.js @@ -0,0 +1,32 @@ +import * as API from '../../src/types.js' +import { getReceipts } from '../helpers/receipts.js' + +/** + * @type {API.Tests} + */ +export const test = { + 'should persist and retrieve receipt': async (assert, context) => { + const receipts = await getReceipts() + const storage = context.receiptsStorage + + // Store receipts + await Promise.all( + // @ts-expect-error no specific receipt types + receipts.map((r) => storage.put(r)) + ) + // Get receipt + const r = await storage.get(receipts[0].ran.link()) + assert.ok(r.ok) + }, + 'should fail with not found error when no receipt is available': async ( + assert, + context + ) => { + const receipts = await getReceipts() + const storage = context.receiptsStorage + + const r = await storage.get(receipts[0].ran.link()) + assert.ok(r.error) + assert.equal(r.error?.name, 'ReceiptNotFound') + }, +} diff --git a/packages/upload-api/test/storage/receipts-storage.js b/packages/upload-api/test/storage/receipts-storage.js new file mode 100644 index 000000000..ee57d96a3 --- /dev/null +++ b/packages/upload-api/test/storage/receipts-storage.js @@ -0,0 +1,40 @@ +import * as Types from '../../src/types.js' + +/** + * @implements {Types.ReceiptsStorage} + */ +export class ReceiptsStorage { + constructor() { + /** + * @type {Record} + */ + this.receipts = {} + } + /** + * @param {Types.UnknownLink} task + */ + async get(task) { + const receipt = this.receipts[task.toString()] + if (receipt) { + return { ok: this.receipts[task.toString()] } + } else { + return { + error: { + name: /** @type {const} */ ('ReceiptNotFound'), + message: `could not find a task for ${task}`, + }, + } + } + } + + /** + * @param {Types.Receipt} receipt + */ + async put(receipt) { + this.receipts[receipt.ran.link().toString()] = receipt + + return { + ok: {}, + } + } +} diff --git a/packages/upload-api/test/storage/receipts-storage.spec.js b/packages/upload-api/test/storage/receipts-storage.spec.js new file mode 100644 index 000000000..b43c99d41 --- /dev/null +++ b/packages/upload-api/test/storage/receipts-storage.spec.js @@ -0,0 +1,3 @@ +import * as ReceiptsStorage from './receipts-storage-tests.js' +import { test } from '../test.js' +test({ 'in memory receipts storage': ReceiptsStorage.test }) diff --git a/packages/w3up-client/README.md b/packages/w3up-client/README.md index 92f5e14e4..c18d893b3 100644 --- a/packages/w3up-client/README.md +++ b/packages/w3up-client/README.md @@ -397,6 +397,7 @@ sequenceDiagram - [`addProof`](#addproof) - [`delegations`](#delegations) - [`createDelegation`](#createdelegation) + - [`getTaskReceipts`](#gettaskreceipts) - [`capability.access.authorize`](#capabilityaccessauthorize) - [`capability.access.claim`](#capabilityaccessclaim) - [`capability.space.info`](#capabilityspaceinfo) @@ -602,6 +603,17 @@ function createDelegation ( Create a delegation to the passed audience for the given abilities with the _current_ space as the resource. +### `getTaskReceipts` + +```ts +function getTaskReceipts ( + task: CID, + options?: { follow: boolean } +): Promise> +``` + +Get receipts from executed task. Optionally follow tasks effects if already available. + ### `capability.access.authorize` ```ts diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index 03511b64e..91783ee52 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -225,6 +225,17 @@ export class Client extends Base { return new AgentDelegation(root, blocks, { audience: audienceMeta }) } + /** + * Get receipts from executed task. Optionally follow tasks effects if already available. + * + * @param {import('@ucanto/interface').UnknownLink} taskCid + * @param {object} [options] + * @param {boolean} [options.follow] + */ + async getTaskReceipts(taskCid, options = {}) { + return this._agent.getTaskReceipts(taskCid, options) + } + /** * Revoke a delegation by CID. * diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index c010a6550..59eb86450 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -1,5 +1,12 @@ import assert from 'assert' -import { Delegation, create as createServer, provide } from '@ucanto/server' +import { + Delegation, + Receipt, + Message, + parseLink, + create as createServer, + provide, +} from '@ucanto/server' import * as CAR from '@ucanto/transport/car' import * as Signer from '@ucanto/principal/ed25519' import * as StoreCapabilities from '@web3-storage/capabilities/store' @@ -438,6 +445,61 @@ describe('Client', () => { }) }) + describe('getTaskReceipts', () => { + it('should get a receipt of an executed task from its CID', async () => { + const issuer = await Signer.generate() + const service = mockService({ + ucan: { + receipt: provide( + UCANCapabilities.receipt, + async ({ capability, invocation }) => { + const { task } = capability.nb + const receipt = await Receipt.issue({ + issuer, + // @ts-expect-error not specific CID multicoded + ran: task, + result: { + ok: {}, + }, + }) + // Encode receipts as an `ucanto` message so that they can be decoded on the other end + const message = await Message.build({ receipts: [receipt] }) + const request = await CAR.outbound.encode(message) + + return { + ok: request, + } + } + ), + }, + }) + + const server = createServer({ + id: await Signer.generate(), + service, + codec: CAR.inbound, + validateAuthorization, + }) + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + const task = parseLink( + 'bafyreie4sutqdtk36msxzdnrgy3iawlgjfinszfl4hxkg3plvnhv7a2dea' + ) + const receiptChain = await alice.getTaskReceipts(task) + + assert.ok(receiptChain) + assert.equal(receiptChain.size, 1) + assert.ok( + receiptChain + .get(Array.from(receiptChain.keys())[0]) + ?.ran.link() + .equals(task) + ) + }) + }) + describe('defaultProvider', () => { it('should return the connection ID', async () => { const alice = new Client(await AgentData.create()) diff --git a/packages/w3up-client/test/helpers/mocks.js b/packages/w3up-client/test/helpers/mocks.js index 48f0a441b..cbfcf5bf1 100644 --- a/packages/w3up-client/test/helpers/mocks.js +++ b/packages/w3up-client/test/helpers/mocks.js @@ -47,6 +47,7 @@ export function mockService(impl) { }, ucan: { revoke: withCallCount(impl.ucan?.revoke ?? notImplemented), + receipt: withCallCount(impl.ucan?.receipt ?? notImplemented), }, filecoin: { offer: withCallCount(impl.filecoin?.offer ?? notImplemented),