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] +}