Skip to content

Commit

Permalink
feat: introduce capability for changing billing plan (storacha#1253)
Browse files Browse the repository at this point in the history
We'd like to allow users to update their billing plans from console -
introduce a new capability to enable this.
  • Loading branch information
travis authored Jan 11, 2024
1 parent 2f026a2 commit d33b3a9
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 2 deletions.
22 changes: 20 additions & 2 deletions packages/capabilities/src/plan.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { capability, ok } from '@ucanto/validator'
import { AccountDID, equalWith, and } from './utils.js'
import { DID, capability, ok, struct } from '@ucanto/validator'
import { AccountDID, equal, equalWith, and } from './utils.js'

/**
* Capability can be invoked by an account to get information about
Expand All @@ -12,3 +12,21 @@ export const get = capability({
return and(equalWith(child, parent)) || ok({})
},
})

/**
* Capability can be invoked by an account to change its billing plan.
*/
export const update = capability({
can: 'plan/update',
with: AccountDID,
nb: struct({
product: DID,
}),
derives: (child, parent) => {
return (
and(equalWith(child, parent)) ||
and(equal(child.nb.product, parent.nb.product, 'product')) ||
ok({})
)
},
})
14 changes: 14 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,20 @@ export interface PlanNotFound extends Ucanto.Failure {

export type PlanGetFailure = PlanNotFound

export type PlanUpdate = InferInvokedCapability<typeof PlanCaps.update>

export type PlanUpdateSuccess = Unit

export interface AccountNotFound extends Ucanto.Failure {
name: 'AccountNotFound'
}

export interface InvalidPlanName extends Ucanto.Failure {
name: 'InvalidPlanName'
}

export type PlanUpdateFailure = AccountNotFound

// Top
export type Top = InferInvokedCapability<typeof top>

Expand Down
166 changes: 166 additions & 0 deletions packages/capabilities/test/capabilities/plan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,169 @@ describe('plan/get', function () {
}
})
})

describe('plan/update', function () {
const agent = alice
const account = 'did:mailto:mallory.com:mallory'
it('can invoke as an account', async function () {
const auth = Plan.update.invoke({
issuer: agent,
audience: service,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
proofs: await createAuthorization({ agent, service, account }),
})
const result = await access(await auth.delegate(), {
capability: Plan.update,
principal: Verifier,
authority: service,
validateAuthorization,
})
if (result.error) {
assert.fail(`error in self issue: ${result.error.message}`)
} else {
assert.deepEqual(result.ok.audience.did(), service.did())
assert.equal(result.ok.capability.can, 'plan/update')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('fails without account delegation', async function () {
const agent = alice
const auth = Plan.update.invoke({
issuer: agent,
audience: service,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
})

const result = await access(await auth.delegate(), {
capability: Plan.update,
principal: Verifier,
authority: service,
validateAuthorization,
})

assert.equal(result.error?.message.includes('not authorized'), true)
})

it('fails when invoked by a different agent', async function () {
const auth = Plan.update.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
proofs: await createAuthorization({ agent, service, account }),
})

const result = await access(await auth.delegate(), {
capability: Plan.update,
principal: Verifier,
authority: service,
validateAuthorization,
})
assert.equal(result.error?.message.includes('not authorized'), true)
})

it('can delegate plan/update', async function () {
const invocation = Plan.update.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
proofs: [
await Plan.update.delegate({
issuer: agent,
audience: bob,
with: account,
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.update,
principal: Verifier,
authority: service,
validateAuthorization,
})
if (result.error) {
assert.fail(`error in self issue: ${result.error.message}`)
} else {
assert.deepEqual(result.ok.audience.did(), service.did())
assert.equal(result.ok.capability.can, 'plan/update')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('can invoke plan/update with the product that its delegation specifies', async function () {
const invocation = Plan.update.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
proofs: [
await Plan.update.delegate({
issuer: agent,
audience: bob,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.update,
principal: Verifier,
authority: service,
validateAuthorization,
})
if (result.error) {
assert.fail(`error in self issue: ${result.error.message}`)
} else {
assert.deepEqual(result.ok.audience.did(), service.did())
assert.equal(result.ok.capability.can, 'plan/update')
assert.deepEqual(result.ok.capability.with, account)
}
})

it('cannot invoke plan/update with a different product than its delegation specifies', async function () {
const invocation = Plan.update.invoke({
issuer: bob,
audience: service,
with: account,
nb: {
product: 'did:web:lite.web3.storage',
},
proofs: [
await Plan.update.delegate({
issuer: agent,
audience: bob,
with: account,
nb: {
product: 'did:web:starter.web3.storage',
},
proofs: await createAuthorization({ agent, service, account }),
}),
],
})
const result = await access(await invocation.delegate(), {
capability: Plan.update,
principal: Verifier,
authority: service,
validateAuthorization,
})
assert.equal(result.error?.message.includes('not authorized'), true)
})
})

0 comments on commit d33b3a9

Please sign in to comment.