Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ucan receipts capability #1113

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/access-client/src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import type {
UCANRevoke,
UCANRevokeSuccess,
UCANRevokeFailure,
UCANReceipt,
UCANReceiptSuccess,
UCANReceiptFailure,
AccountDID,
ProviderDID,
SpaceDID,
Expand Down Expand Up @@ -128,6 +131,7 @@ export interface Service {
}
ucan: {
revoke: ServiceMethod<UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure>
receipt: ServiceMethod<UCANReceipt, UCANReceiptSuccess, UCANReceiptFailure>
}
plan: {
get: ServiceMethod<PlanGet, PlanGetSuccess, PlanGetFailure>
Expand Down
137 changes: 137 additions & 0 deletions packages/access-client/test/agent.test.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<import('../src/types.js').UCANReceipt, import('../src/types.js').UCANReceiptSuccess, import('../src/types.js').UCANReceiptFailure>}
*/
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<import('../src/types.js').UCANReceipt, import('../src/types.js').UCANReceiptSuccess, import('../src/types.js').UCANReceiptFailure>}
*/
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<import('../src/types.js').UCANReceipt, import('../src/types.js').UCANReceiptSuccess, import('../src/types.js').UCANReceiptFailure>}
*/
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.
Expand Down
1 change: 1 addition & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ export interface UploadListSuccess extends ListResponse<UploadListItem> {}

export type UCANRevoke = InferInvokedCapability<typeof UCANCaps.revoke>
export type UCANAttest = InferInvokedCapability<typeof UCANCaps.attest>
export type UCANReceipt = InferInvokedCapability<typeof UCANCaps.receipt>

export interface Timestamp {
/**
Expand Down Expand Up @@ -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<typeof AdminCaps.admin>
export type AdminUploadInspect = InferInvokedCapability<
Expand Down Expand Up @@ -631,6 +643,7 @@ export type AbilitiesArray = [
Access['can'],
AccessAuthorize['can'],
UCANAttest['can'],
UCANReceipt['can'],
CustomerGet['can'],
ConsumerHas['can'],
ConsumerGet['can'],
Expand Down
46 changes: 46 additions & 0 deletions packages/capabilities/src/ucan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
})
12 changes: 12 additions & 0 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ import {
UCANRevoke,
UCANRevokeSuccess,
UCANRevokeFailure,
UCANReceipt,
UCANReceiptSuccess,
UCANReceiptFailure,
PlanGet,
PlanGetSuccess,
PlanGetFailure,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -236,6 +241,7 @@ export interface Service extends StorefrontService {

ucan: {
revoke: ServiceMethod<UCANRevoke, UCANRevokeSuccess, UCANRevokeFailure>
receipt: ServiceMethod<UCANReceipt, UCANReceiptSuccess, UCANReceiptFailure>
}

admin: {
Expand Down Expand Up @@ -277,6 +283,7 @@ export type StoreServiceContext = SpaceServiceContext & {

export type UploadServiceContext = ConsumerServiceContext &
SpaceServiceContext &
UcanReceiptServiceContext &
RevocationServiceContext & {
signer: EdSigner.Signer
uploadTable: UploadTable
Expand Down Expand Up @@ -340,6 +347,10 @@ export interface RevocationServiceContext {
revocationsStorage: RevocationsStorage
}

export interface UcanReceiptServiceContext {
receiptsStorage: ReceiptsStorage
}

export interface PlanServiceContext {
plansStorage: PlansStorage
}
Expand All @@ -360,6 +371,7 @@ export interface ServiceContext
SubscriptionServiceContext,
RateLimitServiceContext,
RevocationServiceContext,
UcanReceiptServiceContext,
PlanServiceContext,
UploadServiceContext,
FilecoinServiceContext,
Expand Down
22 changes: 22 additions & 0 deletions packages/upload-api/src/types/receipts.ts
Original file line number Diff line number Diff line change
@@ -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<Result<Receipt, UCANReceiptFailure>>
/**
* Puts a record into the store.
*/
put: (receipt: Receipt) => Promise<Result<Unit, Failure>>
}
2 changes: 2 additions & 0 deletions packages/upload-api/src/ucan.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ucanRevokeProvider } from './ucan/revoke.js'
import { ucanReceiptProvider } from './ucan/receipt.js'
import * as API from './types.js'

/**
Expand All @@ -7,5 +8,6 @@ import * as API from './types.js'
export const createService = (context) => {
return {
revoke: ucanRevokeProvider(context),
receipt: ucanReceiptProvider(context),
}
}
Loading
Loading