From 471d7e5db24e12a06c1c52ae76bf95ff9471bac8 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 9 Nov 2023 00:26:33 +0000 Subject: [PATCH] feat: add `subscription/list` capability (#1088) Invoking `subscription/list` will retrieve a list of subscriptions for a given account. The list of subscriptions allows us to discover spaces (consumers) that are owned (paid for) by the account. With the list of owned spaces we can make calls to `usage/report` to retrieve and display usage information. --- packages/access-client/src/access.js | 1 + packages/access-client/src/agent-use-cases.js | 1 + packages/access-client/src/types.ts | 10 ++ packages/capabilities/src/customer.js | 4 +- packages/capabilities/src/index.js | 1 + packages/capabilities/src/plan.js | 6 +- packages/capabilities/src/provider.js | 4 +- packages/capabilities/src/subscription.js | 12 ++- packages/capabilities/src/types.ts | 14 +++ packages/capabilities/src/utils.js | 2 + packages/upload-api/src/subscription.js | 2 + packages/upload-api/src/subscription/list.js | 17 +++ packages/upload-api/src/types.ts | 11 ++ .../upload-api/src/types/subscriptions.ts | 12 +++ .../upload-api/test/handlers/subscription.js | 49 +++++++++ .../test/handlers/subscription.spec.js | 3 + packages/upload-api/test/handlers/usage.js | 1 - packages/upload-api/test/helpers/context.js | 6 +- packages/upload-api/test/lib.js | 2 + .../test/storage/subscriptions-storage.js | 29 +++++ packages/upload-api/test/util.js | 4 +- packages/w3up-client/package.json | 8 ++ .../src/capability/subscription.js | 40 +++++++ packages/w3up-client/src/capability/usage.js | 23 ++-- packages/w3up-client/src/client.js | 8 +- .../test/capability/subscription.test.js | 102 ++++++++++++++++++ .../w3up-client/test/capability/usage.test.js | 96 +++++++++++++++++ packages/w3up-client/test/helpers/mocks.js | 8 ++ packages/w3up-client/test/helpers/utils.js | 43 ++++++++ 29 files changed, 492 insertions(+), 27 deletions(-) create mode 100644 packages/upload-api/src/subscription/list.js create mode 100644 packages/upload-api/src/types/subscriptions.ts create mode 100644 packages/upload-api/test/handlers/subscription.js create mode 100644 packages/upload-api/test/handlers/subscription.spec.js create mode 100644 packages/upload-api/test/storage/subscriptions-storage.js create mode 100644 packages/w3up-client/src/capability/subscription.js create mode 100644 packages/w3up-client/test/capability/subscription.test.js create mode 100644 packages/w3up-client/test/capability/usage.test.js diff --git a/packages/access-client/src/access.js b/packages/access-client/src/access.js index 9d288875e..16845c385 100644 --- a/packages/access-client/src/access.js +++ b/packages/access-client/src/access.js @@ -334,6 +334,7 @@ export const spaceAccess = { 'upload/*': {}, 'access/*': {}, 'filecoin/*': {}, + 'usage/*': {}, } /** diff --git a/packages/access-client/src/agent-use-cases.js b/packages/access-client/src/agent-use-cases.js index 969f3b973..fe37f9df1 100644 --- a/packages/access-client/src/agent-use-cases.js +++ b/packages/access-client/src/agent-use-cases.js @@ -183,6 +183,7 @@ export async function authorizeAndWait(access, email, opts = {}) { { can: 'space/*' }, { can: 'store/*' }, { can: 'provider/add' }, + { can: 'subscription/list' }, { can: 'upload/*' }, { can: 'ucan/*' }, { can: 'plan/*' }, diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index 044735731..0bcc24011 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -52,6 +52,9 @@ import type { PlanGet, PlanGetSuccess, PlanGetFailure, + SubscriptionList, + SubscriptionListSuccess, + SubscriptionListFailure, } from '@web3-storage/capabilities/types' import type { SetRequired } from 'type-fest' import { Driver } from './drivers/types.js' @@ -116,6 +119,13 @@ export interface Service { space: { info: ServiceMethod } + subscription: { + list: ServiceMethod< + SubscriptionList, + SubscriptionListSuccess, + SubscriptionListFailure + > + } ucan: { revoke: ServiceMethod } diff --git a/packages/capabilities/src/customer.js b/packages/capabilities/src/customer.js index ac1f659e3..b9d50254f 100644 --- a/packages/capabilities/src/customer.js +++ b/packages/capabilities/src/customer.js @@ -1,11 +1,9 @@ import { capability, DID, struct, ok } from '@ucanto/validator' -import { equalWith, and, equal } from './utils.js' +import { AccountDID, equalWith, and, equal } from './utils.js' // e.g. did:web:web3.storage or did:web:staging.web3.storage export const ProviderDID = DID.match({ method: 'web' }) -export const AccountDID = DID.match({ method: 'mailto' }) - /** * Capability can be invoked by a provider to get information about the * customer. diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index 5d814cda3..d80fbff46 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -67,6 +67,7 @@ export const abilitiesAsStrings = [ Consumer.has.can, Consumer.get.can, Subscription.get.can, + Subscription.list.can, RateLimit.add.can, RateLimit.remove.can, RateLimit.list.can, diff --git a/packages/capabilities/src/plan.js b/packages/capabilities/src/plan.js index 5d8f2cf35..936949aeb 100644 --- a/packages/capabilities/src/plan.js +++ b/packages/capabilities/src/plan.js @@ -1,7 +1,5 @@ -import { capability, DID, ok } from '@ucanto/validator' -import { equalWith, and } from './utils.js' - -export const AccountDID = DID.match({ method: 'mailto' }) +import { capability, ok } from '@ucanto/validator' +import { AccountDID, equalWith, and } from './utils.js' /** * Capability can be invoked by an account to get information about diff --git a/packages/capabilities/src/provider.js b/packages/capabilities/src/provider.js index e8bc2339d..e217a6499 100644 --- a/packages/capabilities/src/provider.js +++ b/packages/capabilities/src/provider.js @@ -9,12 +9,12 @@ * @module */ import { capability, DID, struct, ok } from '@ucanto/validator' -import { equalWith, and, equal, SpaceDID } from './utils.js' +import { AccountDID, equalWith, and, equal, SpaceDID } from './utils.js' // e.g. did:web:web3.storage or did:web:staging.web3.storage export const Provider = DID.match({ method: 'web' }) -export const AccountDID = DID.match({ method: 'mailto' }) +export { AccountDID } /** * Capability can be invoked by an agent to add a provider to a space. diff --git a/packages/capabilities/src/subscription.js b/packages/capabilities/src/subscription.js index 9606353d2..ffda0fb72 100644 --- a/packages/capabilities/src/subscription.js +++ b/packages/capabilities/src/subscription.js @@ -1,5 +1,5 @@ import { capability, DID, struct, ok, Schema } from '@ucanto/validator' -import { equalWith, and, equal } from './utils.js' +import { AccountDID, equalWith, and, equal } from './utils.js' // e.g. did:web:web3.storage or did:web:staging.web3.storage export const ProviderDID = DID.match({ method: 'web' }) @@ -21,3 +21,13 @@ export const get = capability({ ) }, }) + +/** + * Capability can be invoked to retrieve the list of subscriptions for an + * account. + */ +export const list = capability({ + can: 'subscription/list', + with: AccountDID, + derives: equalWith, +}) diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 1038fed0b..d6c58dab6 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -196,6 +196,19 @@ export type SubscriptionGetFailure = | UnknownProvider | Ucanto.Failure +export type SubscriptionList = InferInvokedCapability< + typeof SubscriptionCaps.list +> +export interface SubscriptionListSuccess { + results: Array +} +export interface SubscriptionListItem { + subscription: string + provider: ProviderDID + consumers: SpaceDID[] +} +export type SubscriptionListFailure = Ucanto.Failure + // Rate Limit export type RateLimitAdd = InferInvokedCapability export interface RateLimitAddSuccess { @@ -622,6 +635,7 @@ export type AbilitiesArray = [ ConsumerHas['can'], ConsumerGet['can'], SubscriptionGet['can'], + SubscriptionList['can'], RateLimitAdd['can'], RateLimitRemove['can'], RateLimitList['can'], diff --git a/packages/capabilities/src/utils.js b/packages/capabilities/src/utils.js index 10c686c18..ac1e7e317 100644 --- a/packages/capabilities/src/utils.js +++ b/packages/capabilities/src/utils.js @@ -7,6 +7,8 @@ export const ProviderDID = DID.match({ method: 'web' }) export const SpaceDID = DID.match({ method: 'key' }) +export const AccountDID = DID.match({ method: 'mailto' }) + /** * Check URI can be delegated * diff --git a/packages/upload-api/src/subscription.js b/packages/upload-api/src/subscription.js index d612055e2..264f1388a 100644 --- a/packages/upload-api/src/subscription.js +++ b/packages/upload-api/src/subscription.js @@ -1,9 +1,11 @@ import * as Types from './types.js' import * as Get from './subscription/get.js' +import * as List from './subscription/list.js' /** * @param {Types.SubscriptionServiceContext} context */ export const createService = (context) => ({ get: Get.provide(context), + list: List.provide(context), }) diff --git a/packages/upload-api/src/subscription/list.js b/packages/upload-api/src/subscription/list.js new file mode 100644 index 000000000..61156e1b5 --- /dev/null +++ b/packages/upload-api/src/subscription/list.js @@ -0,0 +1,17 @@ +import * as API from '../types.js' +import * as Server from '@ucanto/server' +import { Subscription } from '@web3-storage/capabilities' + +/** + * @param {API.SubscriptionServiceContext} context + */ +export const provide = (context) => + Server.provide(Subscription.list, (input) => list(input, context)) + +/** + * @param {API.Input} input + * @param {API.SubscriptionServiceContext} context + * @returns {Promise>} + */ +const list = async ({ capability }, context) => + context.subscriptionsStorage.list(capability.with) diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index c487aa06f..38b8bb582 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -95,6 +95,9 @@ import { SubscriptionGet, SubscriptionGetSuccess, SubscriptionGetFailure, + SubscriptionList, + SubscriptionListSuccess, + SubscriptionListFailure, RateLimitAdd, RateLimitAddSuccess, RateLimitAddFailure, @@ -152,6 +155,8 @@ export type { export type { RateLimitsStorage, RateLimit } from './types/rate-limits.js' import { PlansStorage } from './types/plans.js' export type { PlansStorage } from './types/plans.js' +import { SubscriptionsStorage } from './types/subscriptions.js' +export type { SubscriptionsStorage } export interface Service extends StorefrontService { store: { @@ -209,6 +214,11 @@ export interface Service extends StorefrontService { SubscriptionGetSuccess, SubscriptionGetFailure > + list: ServiceMethod< + SubscriptionList, + SubscriptionListSuccess, + SubscriptionListFailure + > } 'rate-limit': { add: ServiceMethod @@ -319,6 +329,7 @@ export interface ProviderServiceContext { export interface SubscriptionServiceContext { signer: EdSigner.Signer provisionsStorage: Provisions + subscriptionsStorage: SubscriptionsStorage } export interface RateLimitServiceContext { diff --git a/packages/upload-api/src/types/subscriptions.ts b/packages/upload-api/src/types/subscriptions.ts new file mode 100644 index 000000000..6053e8262 --- /dev/null +++ b/packages/upload-api/src/types/subscriptions.ts @@ -0,0 +1,12 @@ +import { Result } from '@ucanto/interface' +import { + AccountDID, + SubscriptionListSuccess, + SubscriptionListFailure, +} from '@web3-storage/capabilities/types' + +export interface SubscriptionsStorage { + list: ( + customer: AccountDID + ) => Promise> +} diff --git a/packages/upload-api/test/handlers/subscription.js b/packages/upload-api/test/handlers/subscription.js new file mode 100644 index 000000000..2e58f60b9 --- /dev/null +++ b/packages/upload-api/test/handlers/subscription.js @@ -0,0 +1,49 @@ +import { Subscription } from '@web3-storage/capabilities' +import * as API from '../../src/types.js' +import { createServer, connect } from '../../src/lib.js' +import { alice, registerSpace } from '../util.js' +import { createAuthorization } from '../helpers/utils.js' + +/** @type {API.Tests} */ +export const test = { + 'subscription/list retrieves subscriptions for account': async ( + assert, + context + ) => { + const spaces = await Promise.all([ + registerSpace(alice, context, 'alic_e'), + registerSpace(alice, context, 'alic_e'), + ]) + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + const subListRes = await Subscription.list + .invoke({ + issuer: alice, + audience: context.id, + with: spaces[0].account.did(), + nb: {}, + proofs: await createAuthorization({ + agent: alice, + account: spaces[0].account, + service: context.service, + }), + }) + .execute(connection) + + assert.ok(subListRes.out.ok) + + const results = subListRes.out.ok?.results + const totalConsumers = results?.reduce( + (total, s) => total + s.consumers.length, + 0 + ) + assert.equal(totalConsumers, spaces.length) + + for (const space of spaces) { + assert.ok(results?.some((s) => s.consumers[0] === space.spaceDid)) + } + }, +} diff --git a/packages/upload-api/test/handlers/subscription.spec.js b/packages/upload-api/test/handlers/subscription.spec.js new file mode 100644 index 000000000..4fb3f0ba8 --- /dev/null +++ b/packages/upload-api/test/handlers/subscription.spec.js @@ -0,0 +1,3 @@ +import * as Subscription from './subscription.js' +import { test } from '../test.js' +test({ 'subscription/*': Subscription.test }) diff --git a/packages/upload-api/test/handlers/usage.js b/packages/upload-api/test/handlers/usage.js index 56c9b9caf..200ab28fc 100644 --- a/packages/upload-api/test/handlers/usage.js +++ b/packages/upload-api/test/handlers/usage.js @@ -43,7 +43,6 @@ export const test = { /** @type {import('../types.js').ProviderDID} */ (context.id.did()) const report = usageReportRes.out.ok?.[provider] - console.log(report) assert.equal(report?.space, spaceDid) assert.equal(report?.size.initial, 0) assert.equal(report?.size.final, size) diff --git a/packages/upload-api/test/helpers/context.js b/packages/upload-api/test/helpers/context.js index 313f4584b..0c126d122 100644 --- a/packages/upload-api/test/helpers/context.js +++ b/packages/upload-api/test/helpers/context.js @@ -21,6 +21,7 @@ import * as TestTypes from '../types.js' 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' /** * @param {object} options @@ -41,6 +42,8 @@ export const createContext = async ( const revocationsStorage = new RevocationsStorage() const plansStorage = new PlansStorage() const usageStorage = new UsageStorage(storeTable) + const provisionsStorage = new ProvisionsStorage(options.providers) + const subscriptionsStorage = new SubscriptionsStorage(provisionsStorage) const signer = await Signer.generate() const aggregatorSigner = await Signer.generate() const dealTrackerSigner = await Signer.generate() @@ -69,7 +72,8 @@ export const createContext = async ( signer: id, email, url: new URL('http://localhost:8787'), - provisionsStorage: new ProvisionsStorage(options.providers), + provisionsStorage, + subscriptionsStorage, delegationsStorage: new DelegationsStorage(), rateLimitsStorage: new RateLimitsStorage(), plansStorage, diff --git a/packages/upload-api/test/lib.js b/packages/upload-api/test/lib.js index 87c5a39c2..1d830e8cc 100644 --- a/packages/upload-api/test/lib.js +++ b/packages/upload-api/test/lib.js @@ -7,6 +7,7 @@ import * as RateLimitAdd from './handlers/rate-limit/add.js' import * as RateLimitList from './handlers/rate-limit/list.js' import * as RateLimitRemove from './handlers/rate-limit/remove.js' import * as Store from './handlers/store.js' +import * as Subscription from './handlers/subscription.js' import * as Upload from './handlers/upload.js' import * as Plan from './handlers/plan.js' import * as Usage from './handlers/usage.js' @@ -43,6 +44,7 @@ export const handlerTests = { ...RateLimitList, ...RateLimitRemove, ...Store.test, + ...Subscription.test, ...Upload.test, ...Plan.test, ...Usage.test, diff --git a/packages/upload-api/test/storage/subscriptions-storage.js b/packages/upload-api/test/storage/subscriptions-storage.js new file mode 100644 index 000000000..2b00be2b2 --- /dev/null +++ b/packages/upload-api/test/storage/subscriptions-storage.js @@ -0,0 +1,29 @@ +/** + * @typedef {import('../../src/types/subscriptions.js').SubscriptionsStorage} SubscriptionsStore + */ + +/** + * @implements {SubscriptionsStore} + */ +export class SubscriptionsStorage { + /** @param {import('./provisions-storage.js').ProvisionsStorage} provisions */ + constructor(provisions) { + this.provisionsStore = provisions + } + + /** @param {import('../types.js').AccountDID} customer */ + async list(customer) { + /** @type {import('../types.js').SubscriptionListItem[]} */ + const results = [] + const entries = Object.entries(this.provisionsStore.provisions) + for (const [subscription, provision] of entries) { + if (provision.customer !== customer) continue + results.push({ + subscription, + provider: provision.provider, + consumers: [provision.consumer], + }) + } + return { ok: { results } } + } +} diff --git a/packages/upload-api/test/util.js b/packages/upload-api/test/util.js index 77235a472..b40d9f975 100644 --- a/packages/upload-api/test/util.js +++ b/packages/upload-api/test/util.js @@ -50,9 +50,9 @@ export async function createSpace(audience) { } /** - * * @param {API.Principal & API.Signer} audience * @param {import('./types.js').UcantoServerTestContext} context + * @param {string} [username] */ export const registerSpace = async (audience, context, username = 'alice') => { const { proof, space, spaceDid } = await createSpace(audience) @@ -77,7 +77,7 @@ export const registerSpace = async (audience, context, username = 'alice') => { }) } - return { proof, space, spaceDid } + return { proof, space, spaceDid, account } } /** @param {number} size */ diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index 705045e15..41e1a21ec 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -44,10 +44,18 @@ "types": "./dist/src/capability/store.d.ts", "import": "./src/capability/store.js" }, + "./capability/subscription": { + "types": "./dist/src/capability/subscription.d.ts", + "import": "./src/capability/subscription.js" + }, "./capability/upload": { "types": "./dist/src/capability/upload.d.ts", "import": "./src/capability/upload.js" }, + "./capability/usage": { + "types": "./dist/src/capability/usage.d.ts", + "import": "./src/capability/usage.js" + }, "./types": "./src/types.js" }, "publishConfig": { diff --git a/packages/w3up-client/src/capability/subscription.js b/packages/w3up-client/src/capability/subscription.js new file mode 100644 index 000000000..5b841eb23 --- /dev/null +++ b/packages/w3up-client/src/capability/subscription.js @@ -0,0 +1,40 @@ +import { Subscription as SubscriptionCapabilities } from '@web3-storage/capabilities' +import { Base } from '../base.js' + +/** + * Client for interacting with the `subscription/*` capabilities. + */ +export class SubscriptionClient extends Base { + /** + * List subscriptions for the passed account. + * + * @param {import('@web3-storage/access').AccountDID} account + */ + async list(account) { + const result = await SubscriptionCapabilities.list + .invoke({ + issuer: this._agent.issuer, + audience: this._serviceConf.access.id, + with: account, + proofs: this._agent.proofs([ + { + can: SubscriptionCapabilities.list.can, + with: account, + }, + ]), + nb: {}, + }) + .execute(this._serviceConf.access) + + if (!result.out.ok) { + throw new Error( + `failed ${SubscriptionCapabilities.list.can} invocation`, + { + cause: result.out.error, + } + ) + } + + return result.out.ok + } +} diff --git a/packages/w3up-client/src/capability/usage.js b/packages/w3up-client/src/capability/usage.js index f84c0dc0a..f4cd98cd0 100644 --- a/packages/w3up-client/src/capability/usage.js +++ b/packages/w3up-client/src/capability/usage.js @@ -6,22 +6,23 @@ import { Base } from '../base.js' */ export class UsageClient extends Base { /** - * Get a usage report for the given time period. + * Get a usage report for the passed space in the given time period. * + * @param {import('../types.js').SpaceDID} space * @param {{ from: Date, to: Date }} period - * @param {object} [options] - * @param {import('../types.js').SpaceDID} [options.space] Obtain usage for a different space. */ - async report(period, options) { - const conf = await this._invocationConfig([UsageCapabilities.report.can]) - + async report(space, period) { const result = await UsageCapabilities.report .invoke({ - issuer: conf.issuer, - /* c8 ignore next */ - audience: conf.audience, - with: options?.space ?? conf.with, - proofs: conf.proofs, + issuer: this._agent.issuer, + audience: this._serviceConf.upload.id, + with: space, + proofs: this._agent.proofs([ + { + can: UsageCapabilities.report.can, + with: space, + }, + ]), nb: { period: { from: Math.floor(period.from.getTime() / 1000), diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index f2662dd59..03511b64e 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -13,6 +13,8 @@ import { Delegation as AgentDelegation } from './delegation.js' import { StoreClient } from './capability/store.js' import { UploadClient } from './capability/upload.js' import { SpaceClient } from './capability/space.js' +import { SubscriptionClient } from './capability/subscription.js' +import { UsageClient } from './capability/usage.js' import { AccessClient } from './capability/access.js' import { FilecoinClient } from './capability/filecoin.js' export * as Access from './capability/access.js' @@ -29,10 +31,12 @@ export class Client extends Base { super(agentData, options) this.capability = { access: new AccessClient(agentData, options), + filecoin: new FilecoinClient(agentData, options), + space: new SpaceClient(agentData, options), store: new StoreClient(agentData, options), + subscription: new SubscriptionClient(agentData, options), upload: new UploadClient(agentData, options), - space: new SpaceClient(agentData, options), - filecoin: new FilecoinClient(agentData, options), + usage: new UsageClient(agentData, options), } } diff --git a/packages/w3up-client/test/capability/subscription.test.js b/packages/w3up-client/test/capability/subscription.test.js new file mode 100644 index 000000000..e6e8c86c5 --- /dev/null +++ b/packages/w3up-client/test/capability/subscription.test.js @@ -0,0 +1,102 @@ +import assert from 'assert' +import { create as createServer, provide } from '@ucanto/server' +import * as CAR from '@ucanto/transport/car' +import * as Signer from '@ucanto/principal/ed25519' +import { Absentee } from '@ucanto/principal' +import * as SubscriptionCapabilities from '@web3-storage/capabilities/subscription' +import { AgentData } from '@web3-storage/access/agent' +import { mockService, mockServiceConf } from '../helpers/mocks.js' +import { Client } from '../../src/client.js' +import { createAuthorization, validateAuthorization } from '../helpers/utils.js' + +describe('SubscriptionClient', () => { + describe('list', () => { + it('should list subscriptions', async () => { + const space = await Signer.generate() + /** @type {import('@web3-storage/capabilities/types').SubscriptionListItem} */ + const subscription = { + provider: 'did:web:web3.storage', + subscription: 'test', + consumers: [space.did()], + } + const account = Absentee.from({ id: 'did:mailto:example.com:alice' }) + const service = mockService({ + subscription: { + list: provide(SubscriptionCapabilities.list, ({ capability }) => { + assert.equal(capability.with, account.did()) + return { + ok: { + results: [subscription], + }, + } + }), + }, + }) + + const serviceSigner = await Signer.generate() + const server = createServer({ + id: serviceSigner, + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + const auths = await createAuthorization({ + account, + service: serviceSigner, + agent: alice.agent.issuer, + }) + await alice.agent.addProofs(auths) + + const subs = await alice.capability.subscription.list(account.did()) + + assert(service.subscription.list.called) + assert.equal(service.subscription.list.callCount, 1) + assert.deepEqual(subs, { results: [subscription] }) + }) + + it('should throw on service failure', async () => { + const account = Absentee.from({ id: 'did:mailto:example.com:alice' }) + const service = mockService({ + subscription: { + list: provide(SubscriptionCapabilities.list, ({ capability }) => { + assert.equal(capability.with, account.did()) + return { error: new Error('boom') } + }), + }, + }) + + const serviceSigner = await Signer.generate() + const server = createServer({ + id: serviceSigner, + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + const auths = await createAuthorization({ + account, + service: serviceSigner, + agent: alice.agent.issuer, + }) + await alice.agent.addProofs(auths) + + await assert.rejects(alice.capability.subscription.list(account.did()), { + message: 'failed subscription/list invocation', + }) + + assert(service.subscription.list.called) + assert.equal(service.subscription.list.callCount, 1) + }) + }) +}) diff --git a/packages/w3up-client/test/capability/usage.test.js b/packages/w3up-client/test/capability/usage.test.js new file mode 100644 index 000000000..d814c05dd --- /dev/null +++ b/packages/w3up-client/test/capability/usage.test.js @@ -0,0 +1,96 @@ +import assert from 'assert' +import { create as createServer, provide } from '@ucanto/server' +import * as CAR from '@ucanto/transport/car' +import * as Signer from '@ucanto/principal/ed25519' +import * as UsageCapabilities from '@web3-storage/capabilities/usage' +import { AgentData } from '@web3-storage/access/agent' +import { mockService, mockServiceConf } from '../helpers/mocks.js' +import { Client } from '../../src/client.js' +import { validateAuthorization } from '../helpers/utils.js' + +describe('UsageClient', () => { + describe('report', () => { + it('should fetch usage report', async () => { + const service = mockService({ + usage: { + report: provide(UsageCapabilities.report, () => { + return { ok: { [report.provider]: report } } + }), + }, + }) + + 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 space = await alice.createSpace('test') + const auth = await space.createAuthorization(alice) + await alice.addSpace(auth) + + const period = { from: new Date(0), to: new Date() } + /** @type {import('@web3-storage/capabilities/types').UsageData} */ + const report = { + provider: 'did:web:web3.storage', + space: space.did(), + size: { initial: 0, final: 0 }, + period: { + from: period.from.toISOString(), + to: period.to.toISOString(), + }, + events: [], + } + + const subs = await alice.capability.usage.report(space.did(), period) + + assert(service.usage.report.called) + assert.equal(service.usage.report.callCount, 1) + assert.deepEqual(subs, { [report.provider]: report }) + }) + + it('should throw on service failure', async () => { + const service = mockService({ + usage: { + report: provide(UsageCapabilities.report, ({ capability }) => { + return { error: new Error('boom') } + }), + }, + }) + + const serviceSigner = await Signer.generate() + const server = createServer({ + id: serviceSigner, + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + const space = await alice.createSpace('test') + const auth = await space.createAuthorization(alice) + await alice.addSpace(auth) + + await assert.rejects( + () => { + const period = { from: new Date(), to: new Date() } + return alice.capability.usage.report(space.did(), period) + }, + { message: 'failed usage/report invocation' } + ) + + assert(service.usage.report.called) + assert.equal(service.usage.report.callCount, 1) + }) + }) +}) diff --git a/packages/w3up-client/test/helpers/mocks.js b/packages/w3up-client/test/helpers/mocks.js index 89fcbed1d..48f0a441b 100644 --- a/packages/w3up-client/test/helpers/mocks.js +++ b/packages/w3up-client/test/helpers/mocks.js @@ -11,10 +11,12 @@ const notImplemented = () => { * access: Partial * provider: Partial * store: Partial + * subscription: Partial * upload: Partial * space: Partial * ucan: Partial * filecoin: Partial + * usage: Partial * }>} impl */ export function mockService(impl) { @@ -32,6 +34,9 @@ export function mockService(impl) { space: { info: withCallCount(impl.space?.info ?? notImplemented), }, + subscription: { + list: withCallCount(impl.subscription?.list ?? notImplemented), + }, access: { claim: withCallCount(impl.access?.claim ?? notImplemented), authorize: withCallCount(impl.access?.authorize ?? notImplemented), @@ -47,6 +52,9 @@ export function mockService(impl) { offer: withCallCount(impl.filecoin?.offer ?? notImplemented), info: withCallCount(impl.filecoin?.info ?? notImplemented), }, + usage: { + report: withCallCount(impl.usage?.report ?? notImplemented), + }, } } diff --git a/packages/w3up-client/test/helpers/utils.js b/packages/w3up-client/test/helpers/utils.js index c173e7e70..4dd6dbc66 100644 --- a/packages/w3up-client/test/helpers/utils.js +++ b/packages/w3up-client/test/helpers/utils.js @@ -1 +1,44 @@ +import * as Server from '@ucanto/server' +import { UCAN } from '@web3-storage/capabilities' +import * as Types from '../../src/types.js' + export const validateAuthorization = () => ({ ok: {} }) + +/** + * Utility function that creates a delegation from account to agent and an + * attestation from service to proof it. Proofs can be used to invoke any + * capability on behalf of the account. + * + * @param {object} input + * @param {Types.UCAN.Signer} input.account + * @param {Types.Signer} input.service + * @param {Types.Signer} input.agent + */ +export const createAuthorization = async ({ account, agent, service }) => { + // Issue authorization from account DID to agent DID + const authorization = await Server.delegate({ + issuer: account, + audience: agent, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + expiration: Infinity, + }) + + const attest = await UCAN.attest + .invoke({ + issuer: service, + audience: agent, + with: service.did(), + nb: { + proof: authorization.cid, + }, + expiration: Infinity, + }) + .delegate() + + return [authorization, attest] +}