From d33b3a9f72a5e7a738d2a084eb19388fa70d9433 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 11 Jan 2024 15:42:27 -0800 Subject: [PATCH] feat: introduce capability for changing billing plan (#1253) We'd like to allow users to update their billing plans from console - introduce a new capability to enable this. --- packages/capabilities/src/plan.js | 22 ++- packages/capabilities/src/types.ts | 14 ++ .../test/capabilities/plan.test.js | 166 ++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/capabilities/src/plan.js b/packages/capabilities/src/plan.js index 936949aeb..10c078fa6 100644 --- a/packages/capabilities/src/plan.js +++ b/packages/capabilities/src/plan.js @@ -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 @@ -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({}) + ) + }, +}) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index e921cff6b..38bbaaa22 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -630,6 +630,20 @@ export interface PlanNotFound extends Ucanto.Failure { export type PlanGetFailure = PlanNotFound +export type PlanUpdate = InferInvokedCapability + +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 diff --git a/packages/capabilities/test/capabilities/plan.test.js b/packages/capabilities/test/capabilities/plan.test.js index 2e7055abf..f8d5fe838 100644 --- a/packages/capabilities/test/capabilities/plan.test.js +++ b/packages/capabilities/test/capabilities/plan.test.js @@ -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) + }) +})