Skip to content

Commit

Permalink
feat: add ucan receipts capability
Browse files Browse the repository at this point in the history
  • Loading branch information
vasco-santos committed Nov 10, 2023
1 parent 78ce4ee commit 9542f2a
Show file tree
Hide file tree
Showing 17 changed files with 777 additions and 1 deletion.
30 changes: 30 additions & 0 deletions packages/access-client/src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,36 @@ export class Agent {
return receipt.out
}

/**
* Get receipt from executed task. And optionally follow its effects if already available.
*
* @param {API.UnknownLink} taskCid
* @param {object} [options]
* @param {boolean} [options.follow]
*/
async getReceipts(taskCid, options = {}) {
const result = await this.invokeAndExecute(UCAN.receipt, {
// per https://github.com/web3-storage/w3up/blob/main/packages/capabilities/src/ucan.js#L38C6-L38C6 the resource here should be
// the current issuer - using the space DID here works for simple cases but falls apart when a delegee tries to revoke a delegation
// they have re-delegated, since they don't have "ucan/revoke" capabilities on the space
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
106 changes: 106 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,111 @@ 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.getReceipts(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.getReceipts(task)

assert.ok(receiptChain)
assert.equal(receiptChain.size, 2)
})

/**
* 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
21 changes: 21 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,25 @@ export type UCANRevokeFailure =
| UnauthorizedRevocation
| RevocationsStoreFailure

export type UCANReceiptSuccess = Ucanto.HTTPRequest // Ucanto.ByteView<Ucanto.AgentMessage>

/**
* Error is raised when requested executed task is issued by unauthorized principal,
* that is `with` field is not an `iss` of the executed invocation.
*/
export interface UnauthorizedReceiptIssuer extends Ucanto.Failure {
name: 'UnauthorizedReceiptIssuer'
}

/**
* 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 +651,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

0 comments on commit 9542f2a

Please sign in to comment.